Feat/rings and turn edges / reversability - #5
Open
johndpope wants to merge 27 commits into
Open
Conversation
Dart ports of the pure packages with 1:1 test parity: - aval_graph (132 tests): MotionGraphEngine, portal search, route planning, intent router, operation journal - aval_format (204 tests): container parser, AVC inspection, strict PNG decoder, plus a Phase 1 integration checkpoint that parses the real grass-rabbit.avl and deep-equals the TS build's ground truth (units, states, edges, rendition geometry, all 311 access units) Rust decode core (flutter/rust/aval_decode, 29 tests): - openh264-backed decoder session porting the decoder-worker protocol (configure/submit/take/release/snapshot/dispose), FrameCreditLedger, and sample-sequence validation - BT.709 limited-range I420->RGBA converter (openh264's built-in conversion is BT.601) - C ABI with catch_unwind for dart:ffi + NativeFinalizer Master plan in flutter/ARCHITECTURE.md. aval_player (Phase 2 runtime modules) is in progress and lands separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/setup.sh: dart pub get + cargo fetch across all packages - scripts/analyze.sh: dart analyze + cargo clippy -D warnings - scripts/test.sh: all Dart + Rust test suites, optionally scoped by package name Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 2 (part 1) of the Flutter port — pure-Dart 1:1 ports with 48
passing tests:
- decode_timeline, edge_lead, submission_horizon, path_sequence,
path_scheduler_model
- frozen partial type surfaces the model needs: presentation_ring,
decoder_worker/{protocol,client_support}, model, worker_samples,
platform seams
scripts/run.sh builds the Rust decode core and launches a Flutter
example with AVAL_DECODE_LIB wired through --dart-define.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First runnable Flutter example (scripts/run.sh grass_rabbit): - Hand-written dart:ffi bindings to the aval_decode Rust core — proves the Phase 3 Dart<->Rust round-trip on macOS - Decodes all 5 units (311 access units) of the real grass-rabbit.avl via one decoder session per unit - MotionGraphEngine drives which unit plays: intro once, then idle-loop / hover-in / hover-loop / hover-out follow the graph state on hover (approximation; frame-accurate portals arrive with aval_player) - 24fps Ticker gating, state badge, macOS sandbox disabled for the dev dylib load (documented in README) - examples/README.md: parity table for the 7 web examples Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1:1 Dart port of the full path-scheduler from player-web/src/runtime (11 modules, ~2.5k LOC TS) plus all 19 test cases from path-scheduler.test.ts. 67 tests green, analyzer clean. Extended frozen surfaces faithfully: full PresentationRing (hard dependency of the output owner), AbortController/DOMException seams, DecoderWorkerError taxonomy, runtimeTraceCapacity. Documented judgment calls in file headers with TS line refs; the concrete WorkerSampleFactory/asset-catalog remain for a later phase (test uses a faithful fake driving the same DecodeTimeline plan). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Project skill capturing the AVAL content production workflow: graph-first authoring, hub-pose pattern for scalable gesture libraries, AI video generation prompt templates (camera lock, single action, explicit start/end pose), ffmpeg post-processing for loop seams and color drift, and the compile/preview iteration loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Dart PathScheduler now provably produces the byte-identical trace (512 records) and takeNext() media sequence (223 takes) as the TS scheduler for the real grass-rabbit.avl, across idle-loop wrap, a portal-edge hover transition, and a finish-edge exit. Ports the concrete sample-production layer into aval_player: - worker_samples (concrete WorkerSampleFactory), asset_catalog (+index), borrowed_avc_inspection, runtime errors taxonomy, verified_blob_store contract - 16 new tests (83 total green) Golden fixture committed at test/fixtures/grass_rabbit_golden_trace.json; regenerated via the vitest harness added at packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts. avc-candidate-factory deliberately deferred (pulls the browser/GPU resource tree scheduled for Phases 8-10; not on the trace path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the reworked decoder-worker vocabulary: samples -> decode chunks (decodeIndex/unitChunkCount + presentationOrdinalBase/presentation Indices/displayedFrameCount), per-unit sequence tracking replacing the global ordinal, credit gated per displayed frame, hidden-chunk fail-closed rule, codec-neutral config (VideoCodec enum; non-H.264 and 10-bit rejected Unsupported at configure — openh264 stays the only native backend per the DecoderAdapter seam). C ABI: aval_decode_submit_access_unit -> aval_decode_submit_chunk; AvalDecodeChunk/Config/Frame/Metrics reshaped; status discriminants 0-8 preserved, Unsupported=9 added. Also fixes a latent bug: abort_generation no longer drops delivered-but-unreleased frames (would dangle FFI pointers Dart still holds); leased frames now survive retire, matching TS semantics. 36 tests green, clippy clean. FrameCreditLedger unchanged upstream — left untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rendering: add a dart:ui FragmentProgram (Impeller/SkSL) frame compositor (shaders/frame.frag + GpuFramePainter), ported from the web player's WebGL2 shader, replacing the CPU Canvas.drawImageRect path. CPU painter kept as the fallback while the shader program loads. Decode: introduce the DecoderAdapter trait seam (openh264 as first backend, behavior-preserving) and add a VideoToolbox hardware-decode backend on Apple targets (Objective-C decoder compiled via build.rs, exposed through a C ABI; Annex-B->AVCC, SPS/PPS extraction, decode-order output, stride-aware BGRA->RGBA readback). Default backend on Apple; AVAL_DECODE_OPENH264=1 forces software. iOS Runner links VideoToolbox/CoreMedia/CoreVideo. UX: fix video freezing during InteractiveViewer pan/zoom (willChange: true so the layer is never raster-cached); clamp panning to content edges (boundaryMargin: zero) so dragging can't reveal the black background; maximized view uses contain (not cover) so the whole scene is the zoom/pan space; app starts in maximized/immersive mode. ARCHITECTURE.md updated: §2(c)/§3 marked implemented, Phases 12-14 added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- grass_rabbit now builds and runs with `flutter build web --wasm`: decode goes through a per-platform interface — the aval_decode Rust core via dart:ffi on native, WebCodecs VideoDecoder on web (the .avl chunks are Annex-B H.264, which WebCodecs accepts directly; no Rust-to-WASM codec build needed). - Fix dart2wasm interop trap: Uint8List.toJS copies on wasm, so VideoFrame.copyTo must fill a JS-heap array that is converted back with .toDart (was producing fully transparent frames). - New flutter/packages/aval_flutter: AvalView, the Flutter equivalent of the web player's <aval-video> element. State→unit mapping, loop kinds, intro unit, and gesture→event wiring are all derived from the manifest (bindings: tap→activate, hover/long-press→engagement.on), so any compiled .avl plays with zero app code. Includes the GPU fragment-shader compositor (CPU fallback) and the frame-rate-gated presentation clock. - grass_rabbit reduced to chrome (zoom UI, state badge, unit audio via AvalView.onUnitChanged); tests rewritten against the format-1.0 mansion-woman asset (old grass-rabbit.avl fixture is format 0.1, unsupported by the parser) using the new decode-free installGraph seam. Verified: web (Chrome, tap→great→idle round trip), macOS, iOS builds; all tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A ring is an ordered, optionally cyclic set of states along one axis. When a requested state shares a ring with the departure state, the engine walks the shorter arc one authored step at a time instead of demanding an edge for every ordered pair. Ties resolve through the ring's tieBreak, and an arc longer than maxChainedSteps is refused as a route failure rather than silently truncated. Each landing emits a turnstep effect, and the chain is replanned at every step boundary: a newer request lets the in-flight step finish, then departs on a fresh arc from what actually landed, so a rapid sweep keeps moving while the superseded request aborts as before. turnPolicy "direct" collapses an arc into its departure boundary for hosts honouring a reduced motion preference, and planFor exposes the same planning as a dry run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an optional top-level rings array and optional ring/step/derived fields on edges, so an asset can declare an ordered axis and mark the edges that step along it. Relations validation requires a ring to be fully walkable: every member resolves to a state, every ordered adjacency has an edge in both directions, and no two rings claim the same pair. The key is omitted entirely when nothing authors a ring, which keeps assets compiled before rings existed byte-identical. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A ring is authoring shorthand: the compiler derives one ordinary edge per ordered neighbour pair, so nothing downstream has to understand rings to play them. Derived steps are named <ring>.<from>.<to> with the prefix every member shares removed, and are flagged derived: true so avl inspect (which now reports edges and rings) can tell them apart from authored edges. An authored edge always wins over the step it shadows, and the shadowing is reported as a build warning rather than silently dropping the step. Compile-time validation refuses every ring that could not be walked: unknown or duplicated members, a ring too short to be an axis or to close a cycle, unit mode without per-step units, a non-adjacent override, a turn edge whose ring or adjacency does not exist, two rings claiming one pair, and a step whose bodies share no compatible port. Each message names the ring and the states involved. Adds fixtures/rings/v1-eight-way-facing: eight facings, no authored edges, the whole ring derived. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ring landings ride the existing effect pipeline: turnstep is a post-draw effect, so a landing is only published once the body it landed on has actually been drawn, and it reaches listeners as a "turnstep" DOM event carrying the ring, the pair, and how many steps remain. The element also mirrors the asset's rings as a detached read-only list and delegates planFor to the graph, so a host can ask which landings a setState would visit before committing to it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers the authoring surface (project 1.0), what lands in the asset (format 1.0), the runtime contract for a chained request (states and triggers), and the element additions. Regenerates the API reports for the new public surface. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The policy shapes every route the player plans, so it is read once at construction and fixed for the player's lifetime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…racter Both ring validators built their pair key from a literal NUL byte, which made the sources non-text and, in the graph validator, silently disabled the "edge declares ring X but steps inside ring Y" check, because the key was split on a space that was never in it. The graph now keeps the pair beside its owner instead of re-parsing a key, and the check has a test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derived turn edges from ring expansion carry source-only fields (kind, ring, step, derived) that the compiled-asset schema validator rejects as unknown fields. Strip them before writing to the manifest chunk. Also adds the v1-eight-way-facing fixture test bed: - 64 placeholder RGBA PNG frames (8 per direction, color+arrow+bob) - Compiled public/vp9.avl with 16 derived turn edges from facing.walk ring - test.html: direction buttons, planFor output, turnstep/statechange log Run: python3 -m http.server 8080 from fixture dir, open localhost:8080/test.html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…umps Port ring adjacency, planFor, and turn-edge validation to aval_graph (Dart) and a Rust twin. Add rings_eight_way and rings_climbing Flutter demos with a stamina ring, pivot-symmetric reversible units (PRD v3 AC12), and rear-camera jump coils/apex stills (left, right, up). Fix the eight-way fixture host (Vite, portal maxWait, hard-cut fallback) so NE/E paths resolve under CDP.
|
@johndpope is attempting to deploy a commit to the pixelpoint Team on Vercel. A member of the Team first needs to authorize it. |
Author
Author
|
beta.scrya.com/rings-test/test.html - this is fable + opus work so quality is high - you can see clicking around the ring - the colors transition between routes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

3. Goals and non-goals
Goals
setState, including mid-traversal retargeting.Non-goals
4. User stories
As a motion author shipping an 8-way character, I declare one ring and one turn policy, and get correct step edges for every adjacent pair without writing them.
As a motion author with an asymmetric pivot (turning left looks different from turning right), I override the two steps that need custom units and let the rest derive.
As an application developer, I call
setState("walk_e")fromwalk_nand the character visibly turns through NE, without my writing path decomposition.As an application developer driving from an analog stick, I call
setStateon every octant change and the engine coalesces a rapid N→NE→E→SE sweep into continuous motion rather than a queue of aborted promises.As a reviewer, the build report tells me which ring steps have questionable continuity, named by step (
facing.n.ne), not buried in 48 generated edge ids.5. Proposed model
5.1 Ring
A ring is an ordered list of state ids plus a
cyclicflag and a default turn policy. Order defines adjacency: consecutive entries are one step apart, and on a cyclic ring the last and first are also adjacent.stateDiagram-v2 direction LR [*] --> ring state "ring: facing" as ring { n --> ne : step +1 ne --> e : step +1 e --> se : step +1 se --> s : step +1 note right of s cyclic: sw, w, nw, back to n end note }5.2 Turn edge
A turn edge is a derived or authored edge whose
kindis"turn". It carries astepof+1or-1and a transition that is either:{"mode": "cut"}— depart at a portal shared by both bodies, no bridge footage.{"mode": "unit", "unit": "pivot_n_ne", "direction": "forward"}— play an authored pivot, exactly like today's reversible.5.3 Traversal
A
setStateto a state on the same ring resolves to a turn plan: an ordered list of steps. On a cyclic ring the engine picks the arc with fewer steps; ties break toward+1(configurable viatieBreak). The plan executes step by step, each step honoring its own portal negotiation.sequenceDiagram participant Host participant Graph as aval-graph participant Body as current body loop Host->>Graph: setState("walk_e") from walk_n Graph->>Graph: plan = [n->ne, ne->e] (short arc, +1) Graph->>Body: request depart at portal Body-->>Graph: portal reached (<= maxWaitFrames) Graph->>Graph: execute step 1, settle in walk_ne Note over Graph: checkpoint: replannable here Graph->>Graph: execute step 2, settle in walk_e Graph-->>Host: resolve5.4 Retargeting mid-plan
Because each completed step lands the graph in a real, valid state, a plan is replannable at every step boundary. If a new
setStatearrives mid-plan, the engine finishes the in-flight step, then replans from the state it just landed in. This is what makes an analog sweep feel continuous: the character never rewinds or snaps, it just keeps stepping toward the newest target.graph LR A["executing step n to ne"] --> B{"new setState arrived?"} B -->|no| C["continue plan"] B -->|yes| D["finish current step"] D --> E["land in walk_ne"] E --> F["replan from walk_ne to new target"] F --> C C --> G{"plan empty?"} G -->|no| A G -->|yes| H["resolve"]This differs from today's latest-wins behavior in an important way, spelled out in 7.3.
6. Schema changes
6.1 New top-level
ringsarray{ "rings": [ { "id": "facing.walk", "states": ["walk_n", "walk_ne", "walk_e", "walk_se", "walk_s", "walk_sw", "walk_w", "walk_nw"], "cyclic": true, "tieBreak": "forward", // "forward" | "backward" "turn": { "mode": "cut", // default policy for every step "start": { "type": "portal", "sourcePort": "default", "targetPort": "default", "maxWaitFrames": 8 }, "continuity": "exact-authored", "trigger": { "type": "derived" } // steps are reachable via setState }, "maxChainedSteps": 4, "overrides": [ { "from": "walk_nw", "to": "walk_n", "mode": "unit", "unit": "pivot_nw_n", "direction": "forward", "continuity": "exact-authored" } ] } ] }6.2 New edge kind
Authors may also write a turn edge directly, outside any ring, for a one-off step:
{ "id": "walk_n.walk_ne", "kind": "turn", "from": "walk_n", "to": "walk_ne", "step": 1, "ring": "facing.walk", "transition": { "mode": "cut" }, "start": { "type": "portal", "sourcePort": "default", "targetPort": "default", "maxWaitFrames": 8 }, "continuity": "exact-authored" }6.3 Field reference
rings[].idfacing.walk).rings[].statesstates. Order defines adjacency.rings[].cyclicfalserings[].tieBreakforwardrings[].turnrings[].turn.modecutorunit.unitat ring level requires per-stepoverrides.rings[].turn.startstart.rings[].turn.continuityexact-authoredrings[].maxChainedStepsceil(len/2)RouteError.rings[].overrides[][]from+to.edges[].kind"turn".edges[].step1 | -1kind: turnedges[].ringkind: turn6.4 Precedence
An explicitly authored edge between two ring-adjacent states always wins over a derived one. A ring
overridewins over the ring's defaultturnpolicy. The compiler emits an informational note when an explicit edge shadows a derived step, so shadowing is visible rather than silent.graph TD A["step needed: walk_nw to walk_n"] --> B{"explicit edge in edges[]?"} B -->|yes| C["use it — emit shadow note"] B -->|no| D{"matching ring override?"} D -->|yes| E["use override"] D -->|no| F["derive from ring turn policy"]7. Runtime semantics
7.1 Planning
Given a
setState(target)wherecurrentandtargetare on the same ring:dfand backward distancedb(cyclic) or the single valid distance (acyclic).RouteError.df/db; on a tie usetieBreak.maxChainedSteps, rejectRouteError.If
currentandtargetare on different rings, or one is not on a ring, planning does not apply and the existing edge-resolution path handles the request unchanged.7.2 Execution
Each step executes as a normal transition: negotiate departure at a portal (respecting
maxWaitFrames), play the cut or pivot unit, settle into the destination body. A step never seeks. The decoder timeline continues to move forward across each seam, preserving AVAL's core property.A plan is atomic per step, not per plan. Interruption at a step boundary is always safe because the machine is in a declared state. Interruption within a step is not permitted; the step completes first.
7.3 Interaction with latest-wins
Today, a superseded
setStaterejects withAbortError. Turn plans refine this rather than break it:setStateto a different ring position does not abort the in-flight request. The engine finishes the current step and replans. The original promise still rejects withAbortError(its requested destination was superseded), and the new promise owns the continued traversal. Behavior is consistent with existing semantics; only the motion is continuous rather than restarted.setStateto the same destination joins the in-flight plan, matching today's duplicate-destination behavior.setStateoff-ring (say, toattack_ne) finishes the current step, then leaves the ring by the normal edge path. If no such edge exists from the landed state, it rejectsRouteError.This means a host driving from an analog stick can fire
setStateon every octant change without special handling. Rejections are expected and swallowed; the visual result is one smooth sweep.sequenceDiagram participant H as Host (stick sweep) participant G as aval-graph H->>G: setState("walk_ne") H->>G: setState("walk_e") G-->>H: walk_ne rejects (AbortError) Note over G: current step finishes,<br/>replan from landed state H->>G: setState("walk_se") G-->>H: walk_e rejects (AbortError) G-->>H: walk_se resolves Note over G: motion was continuous throughout7.4 Determinism
Given the same start state, target, and ring declaration, the plan is identical every time. No timing input affects arc selection. Portal wait duration may vary with where the loop happens to be, but the sequence of states traversed is fixed. This preserves the determinism guarantee of the existing graph engine and keeps the feature testable.
8. Compiler behavior
8.1 Expansion
avl compileexpands each ring into derived turn edges with generated ids of the form<ring>.<from>.<to>— for examplefacing.walk.n.ne. Derived edges appear inavl inspectoutput flaggedderived: true, so the compiled graph remains fully introspectable and there is no hidden runtime magic.8.2 Validation
The compiler must reject, at compile time:
turn.mode: "unit"at ring level without an override supplying a unit for every step.from/topair is not adjacent in the ring.kind: turnedge whoseringdoes not exist, or whosefrom/toare not adjacent in it.cut-mode step where the two bodies share no compatible port id.8.3 Continuity review
Every derived step is analyzed exactly as a hand-authored edge is — in linear-light premultiplied RGBA, with out-of-heuristic boundaries published as
status: "review"with a CLI warning, not blocking compilation. Ring steps are reported grouped by ring, with the step named (facing.walk: nw->n — review), so an author scanning a build report sees "two of eight steps need a look" rather than parsing 48 flat edge ids.Cut-mode steps deserve extra scrutiny: a cut asserts that two loops share a compatible pose at their portals. That is precisely the claim the numeric check is weakest at judging semantically, so the report should mark cut steps distinctly and the docs should tell authors to eyeball each one in
avl dev.8.4 Report additions
avl inspectgains aringssection listing each ring, its length, cyclicity, derived step count, override count, shadowed steps, and per-step continuity status.9. Player and element API
9.1 No breaking changes
setState, thestateattribute,send, andstateNameskeep their current shapes and meanings. Rings are additive.9.2 New introspection
planForlets a host preview the traversal — useful for driving a UI affordance ("this will step through NE") and for tests.9.3 New event
A
avl:turnstepevent fires at each step boundary with{ ring, from, to, remaining }. Hosts that need to sync game logic to visual facing (a common need — the game's authoritative facing should update when the visual facing does) can listen rather than poll.10. Error taxonomy
maxChainedStepsmaxChainedStepsRouteErrorRouteErrorAbortErroron old promiseRouteErrormaxWaitFramesNo new error classes are introduced. This is deliberate: hosts already handle
AbortErrorandRouteError, and adding aPlanErrorwould force every existing consumer to update its catch block for no semantic gain.11. Accessibility and reduced motion
Motion preference remains host policy, not an asset binding — this proposal does not change that.
However, chained turns produce longer continuous motion than a single transition, so the docs must be explicit: under a host reduced-motion policy, a turn plan should collapse to a direct settle into the target state, skipping intermediate steps and any pivot units. The engine should expose this as a player-level option (
turnPolicy: "chain" | "direct") that the host sets from its own reduced-motion decision, rather than the graph inferring it.The
avl:turnstepevent must still fire for the final landing underdirect, so hosts syncing game state to visual state do not silently break when a user enables reduced motion.12. Performance budgets
Rings are compile-time expansion; there is no runtime pathfinding beyond an integer distance comparison, so CPU cost is negligible.
The real budget concern is asset size, and it is an author-facing one. Cut-mode rings add zero footage — they are pure graph. Unit-mode rings add one pivot clip per step: a fully authored 8-way ring with distinct pivots in both directions is 16 additional units per action. Documentation should lead with cut mode and present unit mode as the deliberate, costed upgrade.
Decoder and texture budgets are unchanged: a ring does not add concurrent decode, since only one body is resident at a time.
13. Acceptance criteria
avl inspectwithderived: true.setStatefromwalk_ntowalk_etraverseswalk_neand resolves;planFor("walk_e")returns["walk_ne", "walk_e"].walk_nw,setState("walk_ne")chooses the two-step forward arc throughwalk_n, not the six-step backward arc.tieBreak: "forward", a four-step tie on an 8-ring resolves forward, deterministically, across 100 runs.setStatecalls simulating a stick sweep produce continuous motion with no rewind, with each superseded promise rejectingAbortError.turnPolicy: "direct"lands in the target state without intermediate steps and still fires a finalavl:turnstep.ringskey compile to byte-identical output versus the prior compiler version.14. Test plan
Unit —
aval-graph. Arc selection across cyclic and acyclic rings; tie-break;maxChainedStepsrejection; replanning at step boundaries; promise resolution and rejection ordering under supersession.Unit —
aval-format. Round-trip ofringsthrough parse, validate, and write. Rejection cases for V1–V8. Forward-compat: a0.3reader encountering0.4rings fails with a clear version error rather than silently ignoring the key.Integration —
aval-compiler. Golden-file expansion for an 8-way ring in cut and unit modes; override matching; shadow-note emission; grouped continuity reporting.Browser — Playwright. Frame-accuracy assertions across a full traversal; the analog-sweep scenario from AC5 driven by synthetic input; reduced-motion collapse; fallback slot behavior unchanged.
Fixture. Add an 8-way character fixture to
fixtures/— it doubles as the reference example for docs, and it is the first fixture exercising a positional state set rather than a semantic one.15. Rollout and versioning
Project schema bumps
0.3→0.4; wire format bumps0.1→0.2to carry the ring metadata needed byplanForandringsintrospection.Compatibility commitments:
ringskey compile unchanged (AC10).0.4compiler accepts0.3projects.0.1-only player rejects a0.2asset with an explicit version error, per existing versioning policy.Suggested sequence: land format and graph support behind validation first, then compiler expansion, then element introspection and the
turnstepevent, then docs and the fixture. Ship the fixture in the same release as the docs — a positional-ring example is the thing that makes the feature legible.16. Alternatives considered
Do nothing; author all edges. Works today. Rejected because it scales linearly with ring size times action count, and because multi-step traversal remains undefined regardless of how many edges are authored — the host still has to decompose paths, which is the deeper problem.
Host-side path decomposition helper (a library, not a format change). A utility that awaits each hop. Rejected because it fights latest-wins: each hop is a separate
setState, so a retarget mid-sweep produces a rejection storm and the helper must debounce, reintroducing timing dependence and destroying determinism. Traversal belongs in the graph engine, which already owns portal negotiation.Generalized graph pathfinding over all edges. Let
setStatefind any multi-hop route through the whole edge set. Rejected as too powerful and too implicit: it would make unrelated states accidentally reachable, make asset behavior hard to reason about, and turn a continuity bug into a mystery route. Rings scope the chaining to an axis the author explicitly declared.Parameterized states (facing as a numeric property of one state). Elegant, and closer to how a blend tree thinks. Rejected because it implies interpolation, which AVAL deliberately does not do, and it would require rethinking how bodies map to states — a much larger change for the same authoring win.
17. Open questions
Q1. Multi-axis products. A character has facing × action. Today the host encodes both in the state name (
walk_ne) and declares one ring per action. Should rings compose — afacingring crossed with anactionaxis — sowalk_ntorun_neresolves as one plan? This is real ergonomic pain but a significantly larger design. Proposal: ship single-axis rings, gather usage, revisit.Q2. Should
maxChainedStepsdefault toceil(len/2)? On a cyclic ring that is the maximum possible short-arc distance, so the default never rejects a legitimate short arc. But it means the guardrail only ever fires on acyclic rings, which may make it feel vestigial. Alternative: default to unbounded and let authors opt in.Q3. Per-step
maxWaitFrames. Ring-level config is right for the common case, but a pivot that departs from a sparse-portal body may need a longer wait than its neighbors. Overrides can carry it — should they, or is that over-configuration?Q4. Cut-step continuity confidence. Cut mode makes a strong claim the numeric heuristic is weak at judging. Is a distinct, louder report status warranted for cut steps specifically, or does that train authors to ignore warnings?
Q5.
turnsteptiming. Should the event fire when the step's transition begins or when the destination body is settled? Game logic syncing authoritative facing probably wants settled; UI affordances probably want begin. Two events, or one with a phase field?References
GRAPH_LIMITS.maxStates: 32,maxEdges: 64, hard-enforced invalidate.ts:60-66RoutePlanis a fixed four-slot structure (pending,active,followOn,reversal) with exactly one follow-on — "the one direct continuation"queueFollowOngives one hop of lookaheadThe revised proposal is therefore: raise capacity, add a ring index, and add one field of engine state. No new plan structure, no new error classes, no change to the four-slot route model.
1. Problem, restated against the source
1.1 Capacity is the wall
An 8-direction character with idle, walk, and run needs 24 body states. Add an 8-way attack and it is 32 — exactly at
maxStates. Add hurt and it is 40, and the graph is rejected outright.Edges are worse. Per action, turning is 16 edges (8 forward, 8 backward). For three locomotion actions that is 48, plus 8 idle↔walk pairs (16) — 64 edges, precisely at
maxEdges, with zero remaining budget for attack entry, hurt entry, or finish routes.These limits are not arbitrary — they bound decoder scheduling, validation cost, and the operation journal. But they were plainly sized for semantic state sets (idle/loading/success/error), not positional ones. A positional axis multiplies states by its cardinality, and AVAL currently has no way to express "these 8 states are one axis" so the multiplication is unavoidable.
1.2 The rejection point is one line
intent-router.ts, inpendingOrReject:directEdgeis a single-hop map lookup (directEdgesByState: Map<from, Map<to, edge>>). A request fromwalk_ntowalk_efinds nothing and rejects withRouteError. The same single-hop lookup gates the follow-on branch at the end ofplanStateIntent.This is a good place to intervene:
planStateIntentis explicitly a pure function ("Decide state intent without mutating routes, effects, or request groups"), so ring awareness can be added without touching mutation paths.1.3 Intent is not persisted
RoutePlan.prospectiveState()returns the end of the current slot topology. If the engine stepswalk_n → walk_newhile the host actually wantswalk_e, the prospective state iswalk_neand nothing anywhere remembers thatwalk_ewas the goal. On completion, the machine settles and stops.This is the one genuinely missing piece. Everything else needed for chained traversal is already built.
2. Proposal
Three changes, in dependency order.
2.1 Rings as a first-class axis (capacity + ergonomics)
Add a
ringsarray toMotionGraphDefinition. A ring names an ordered, optionally cyclic list of states on one axis. From it, the validator derives an adjacency index — it does not materialize edges intoedges[].That distinction is what solves capacity. Derived steps live in a separate
ringStepsByStateindex and are counted against a separatemaxRingStepsbudget, so a ring costs one declaration againstmaxEdgesinstead of 2n. The 48 turn edges collapse to 3 ring declarations.2.2 Ring intent (traversal)
Add one nullable field to engine state:
ringIntent: GraphStateId | null.setStateresolves to a ring traversal of more than one step.queueFollowOn.No step list. The engine already re-derives; we are only giving it a destination to re-derive toward.
2.3 Capacity revision
Even with rings, positional sets need headroom. Proposed:
maxStatesmaxEdgesmaxRingsmaxRingLengthmaxRingStepsmaxRoutingOperationsPerTickmaxStates: 64needs a decoder-budget review before landing; it is the one number here with real runtime consequences, since more states means more distinct bodies the scheduler may be asked to prepare.3. Schema
3.1 Graph model additions (
model.ts)ringsis optional, so every existing fixture parses unchanged.Note the step policy reuses
GraphStartPolicyandGraphTransitionDefinitionverbatim. A cut turn isstart: { type: "cut", targetPort, maxWaitFrames: 1 }with no transition — that variant already exists in the model. A pivot turn istransition: { kind: "reversible", ... }. No new transition kind is needed, which is a meaningful simplification over v1's proposedkind: "turn".3.2 Index additions (
validate.ts)Synthesized edges carry a reserved
ring:id prefix.GRAPH_IDENTIFIER_PATTERNis/^[a-z][a-z0-9._-]{0,63}$/and does not permit:, so the prefix cannot collide with an authored id. That is a happy accident of the existing pattern and worth an explicit test.4. Runtime semantics
4.1 Next-step derivation
Pure, allocation-free apart from the return, and deterministic — arc choice depends only on positions and
tieBreak, never on timing. That preserves the engine's existing determinism guarantee.4.2 Router integration
Two edits in
intent-router.ts, both falling back to ring lookup only where a direct edge is absent — so authored edges always win, which gives the override behavior for free:and, at the tail of
planStateIntent:That second edit is what makes a mid-traversal retarget continuous: a new target arriving during an active step queues the next step toward the new target from where the current step lands.
4.3 Completion and re-derivation
RoutePlan.completeActive()promotesfollowOntopending. The engine additionally consults ring intent:sequenceDiagram participant H as Host participant E as engine participant R as RoutePlan H->>E: setState("walk_e") from walk_n E->>E: ringNextStep -> walk_ne E->>E: ringIntent = "walk_e" E->>R: replacePending(n->ne) Note over R: portal negotiation, activate, play R->>E: completeActive() -> visual = walk_ne E->>E: ringIntent unsatisfied E->>E: ringNextStep(walk_ne, walk_e) -> walk_e E->>R: replacePending(ne->e) Note over R: second step R->>E: completeActive() -> visual = walk_e E->>E: visual === ringIntent, clear E-->>H: settle resolve "target-committed"Each step is a normal transition with its own portal negotiation. No seek occurs at any seam — the existing guarantee is untouched because we are reusing the existing transition path verbatim.
4.4 Latest-wins is preserved exactly
The existing settlement semantics need no change:
AbortError(unchanged),ringIntentis overwritten, and the follow-on branch queues a step toward the new target. Motion stays continuous; promise semantics stay identical.join-pending/continue-active-target, unchanged.reject→RouteError, unchanged.No new
GraphSettlementErrorvariant. Hosts' existing catch blocks keep working.4.5 Ring intent lifecycle
ringIntentsetStateresolving to a ring step, target ≠ next stepsetStateresolving to a single step (target === next)setStateto an off-ring targetsend(event)resolving any edgedispose,fail-playback, readiness lossThe
sendrule matters: if an attack event fires mid-turn, the turn should abandon rather than resume after the attack. Authored events beating derived traversal is the safer default.5. Validation rules
Added to
validate.ts:GRAPH_IDENTIFIER_PATTERN; unique across rings.states[]exists instatesById.cyclic.ringByStatemust be injective) — avoids ambiguous derivation.from/tois adjacent in the declared order.maxChainedSteps, if present, is a positive safe integer ≤ ring length.targetPort(checked againstportsByState).frameCountis positive.maxRings; ring length ≤maxRingLength; total derived steps ≤maxRingSteps.6. Patch plan
Ordered so each stage is independently testable and green.
graph/src/limits.tsmaxStates/maxEdges; add ring limitsgraph/src/model.tsGraphRingDefinitionand friends;rings?on definitiongraph/src/validate.tsgraph/src/intent-router.tsringNextStep+ two fallback call sitesgraph/src/engine-state.tsringIntentfield, lifecycle per §4.5graph/src/engine.tsgraph/src/index.tsringNextStepfor host previewformat/compiler/rings→ graph rings; grouped continuity reportelement/,react/ringsintrospection,planFor,turnstepeventfixtures/Stages 1–7 are self-contained in the 3.5k-line
graphpackage and deliver the whole runtime behavior. That is the sensible first PR; the format/compiler/element work can follow behind it.Suggested first PR scope
Stages 1–7 plus unit tests. It touches no encoding, so no version bump is required to prototype — a test can construct a
MotionGraphDefinitionwith rings directly and drive the engine.7. Acceptance criteria
ringStepsByStatecontains 16 entries;edges[]budget consumption is unchanged from the ringless equivalent.setState("walk_e")fromwalk_ntraverseswalk_neand settlestarget-committed; twotransitionendeffects are emitted.walk_nw,setState("walk_ne")picks the 2-step forward arc, not the 6-step backward arc.tieBreak, identically across 100 runs.AbortError; no rewind; the visual state sequence is monotonic along the ring.send(event)mid-traversal clears ring intent; the machine does not resume the turn afterward.ringsproduce byte-identical compiled output.8. Risks
Decoder pressure from
maxStates: 64. More states means more distinct bodies the scheduler may prepare. This is the only change with real runtime cost and should be measured againstperformance-and-budgetsbefore landing. Mitigation: raisemaxStatesin a separate PR from the ring work, with its own benchmark.Ring intent as hidden state. A persistent destination that outlives individual requests is a new kind of engine state and could surprise. Mitigation: surface it in
MotionGraphSnapshot(ringIntent: GraphStateId | null) so it is visible in traces and the existingMotionGraphTraceRecordmachinery covers it.Cut-step continuity confidence. Cut turns assert two loops share a compatible pose — exactly what the numeric heuristic judges least well. Mitigation: mark derived cut steps distinctly in the build report and document eyeballing them in
avl dev.Scope creep into multi-axis. Facing × action is the real shape of a character, and single-axis rings only solve half of it. Deliberately deferred; see below.
9. Open questions
Q1 — multi-axis. A character is facing × action. With single-axis rings you declare one ring per action and still cannot express "keep facing, change action" generically. Should rings compose? This is the highest-value follow-up and probably wants its own PRD once single-axis rings have shipped and been used.
Q2 — should ring steps count against
maxEdgesat all? Proposed: no, separatemaxRingStepsbudget. Counter-argument: one budget is simpler to reason about and harder to accidentally blow past. The separate budget is what makes the capacity math work, so this leans yes, but it deserves a maintainer's opinion.Q3 —
sendclearing ring intent. §4.5 proposes authored events outrank derived traversal. The opposite (resume the turn after a one-shot completes) is defensible and arguably nicer for a character that gets hit mid-turn. Resuming would require intent to survive a finish route, which is a larger change.Q4 — should
ringNextStepbe exported? Useful for host preview (planFor) and for tests, but it widens the public surface ofaval-graph. Alternative: expose only a higher-levelplanForon the element.Q5 —
maxStates: 64or higher? 64 covers 8-way × 5 actions with headroom. A 16-position ring × 4 actions would need 64 exactly. If 16-position rings are a real use case, 96 may be the right number.Appendix A — source references
maxStates: 32,maxEdges: 64packages/graph/src/limits.tspackages/graph/src/validate.ts:60-66packages/graph/src/route-plan.tspackages/graph/src/intent-router.ts→pendingOrRejectpackages/graph/src/intent-router.ts→planStateIntentpackages/graph/src/model.ts→GraphBodyKindpackages/graph/src/model.ts→GraphStartPolicypackages/graph/src/model.ts→GraphSettlementError:packages/graph/src/limits.ts→GRAPH_IDENTIFIER_PATTERNPackage sizes at time of writing:
graph3,485 LOC,format14,923,compiler17,161,element19,153,player-web85,096,react1,095. Thereactpackage exists inmaindespite being listed as a TODO in the published README — treat the docs as lagging the source generally.