Skip to content

Feat/rings and turn edges / reversability - #5

Open
johndpope wants to merge 27 commits into
pixel-point:mainfrom
johndpope:feat/rings-and-turn-edges
Open

Feat/rings and turn edges / reversability#5
johndpope wants to merge 27 commits into
pixel-point:mainfrom
johndpope:feat/rings-and-turn-edges

Conversation

@johndpope

Copy link
Copy Markdown
# PRD v2 — Rings and turn intent for AVAL
# PRD — Turn edges and rings for AVAL

**Status:** Draft proposal
**Target:** AVAL project schema `0.4`, wire format `0.2`
**Affects:** `aval-compiler`, `aval-graph`, `aval-format`, `aval-player-web`
**Author:****Last updated:** 26 July 2026

> Written against AVAL's early technical preview. Field names and existing behavior should be re-verified against current docs before implementation; the format is still stabilizing.

---

## Table of contents

1. [Summary](#1-summary)
2. [Problem](#2-problem)
3. [Goals and non-goals](#3-goals-and-non-goals)
4. [User stories](#4-user-stories)
5. [Proposed model](#5-proposed-model)
6. [Schema changes](#6-schema-changes)
7. [Runtime semantics](#7-runtime-semantics)
8. [Compiler behavior](#8-compiler-behavior)
9. [Player and element API](#9-player-and-element-api)
10. [Error taxonomy](#10-error-taxonomy)
11. [Accessibility and reduced motion](#11-accessibility-and-reduced-motion)
12. [Performance budgets](#12-performance-budgets)
13. [Acceptance criteria](#13-acceptance-criteria)
14. [Test plan](#14-test-plan)
15. [Rollout and versioning](#15-rollout-and-versioning)
16. [Alternatives considered](#16-alternatives-considered)
17. [Open questions](#17-open-questions)

---

## 1. Summary

Add a **ring** construct and a **turn** edge kind to the AVAL project format.

A ring declares an ordered, optionally cyclic set of states that represent positions along a single axis — eight compass facings, five zoom levels, twelve clock positions, seven carousel panels. A turn edge describes how to move *one step* along that ring. The compiler expands a ring into the full set of step edges; the graph engine resolves multi-step requests by chaining steps, choosing the shorter arc on a cyclic ring.

This turns an O(n) hand-authoring burden into a single declaration, and gives multi-step traversal defined, deterministic semantics that today's authors must simulate in host code.

---

## 2. Problem

AVAL models transitions as cuts, reversible units, locked bridges, portals, and finish routes. These compose well for *state changes* — idle to engaged, loading to success. They do not have a good answer for *positional* state sets, where states are arranged along an axis and movement between them is a step along that axis.

The canonical case is an 8-direction character. Facing is a ring: N, NE, E, SE, S, SW, W, NW, and back to N. Today, authoring turning for a single action requires 16 hand-written edges (eight forward, eight backward), each with its own id, trigger name, portal config, and continuity declaration. With idle, walk, and run, that is 48 edges — nearly all of them copy-paste, and every one an opportunity for a typo in a portal name or a mismatched `maxWaitFrames`.

Three concrete failures follow from having no first-class ring:

**Authoring cost scales linearly with a constant-complexity idea.** "Adjacent facings connect" is one sentence. It should not be 48 JSON objects. The cost is worse than it looks because the edges are near-identical, so review fatigue makes mistakes likely and hard to spot.

**Multi-step requests have no defined behavior.** A player sweeping a stick from N to E crosses NE. Today `setState("walk_e")` from `walk_n` either finds no authored edge and rejects with `RouteError`, or — if the author added a direct N→E edge — plays a jump that skips the intermediate pose. There is no format-level way to say "get there by stepping through the ring." Authors work around it by decomposing the path in host code and awaiting each hop, which fights latest-wins semantics and produces `AbortError` churn.

**Direction of travel is ambiguous on a cycle.** From NW to NE there are two paths: forward through N (one step) or backward through W, SW, S, SE, E (five steps). Nothing in the format expresses that the short arc is intended, so the choice leaks into host code, where it must be reimplemented per consumer.

```mermaid
graph TD
  A["8 facings x 3 actions"] --> B["48 near-identical edges<br/>hand-authored"]
  B --> C["typos in portal names"]
  B --> D["drifting maxWaitFrames"]
  A --> E["multi-step sweep N to E"]
  E --> F["RouteError, or a pose-skipping jump"]
  E --> G["host code decomposes the path"]
  G --> H["fights latest-wins, AbortError churn"]
  A --> I["cyclic ambiguity NW to NE"]
  I --> J["short vs long arc undefined in format"]

3. Goals and non-goals

Goals

  • G1. Declare a ring of states once; the compiler derives step edges.
  • G2. Define deterministic multi-step traversal, including shortest-arc selection on cyclic rings.
  • G3. Preserve existing portal and continuity guarantees on every step. A turn is not a seek.
  • G4. Interoperate cleanly with latest-wins setState, including mid-traversal retargeting.
  • G5. Support both cut turns (shared portal, no bridge footage) and unit turns (an authored pivot clip) on a per-ring and per-step basis.
  • G6. Keep authored escape hatches: an explicit edge always overrides a derived one.
  • G7. Report ring-level continuity review warnings with the same rigor as hand-authored edges.

Non-goals

  • NG1. Interpolation or blending between ring positions. Rings are discrete; a turn plays authored frames or cuts. Continuous rotation remains out of scope for AVAL.
  • NG2. Automatic mirroring of source pixels. Mirrored facings remain an authoring-time concern.
  • NG3. Multi-axis products. A ring is one axis; combining facing × action stays the host's job via state naming. (See Open questions.)
  • NG4. Runtime ring mutation. Rings are compiled, not constructed at runtime.
  • NG5. Pathfinding over arbitrary graphs. Chaining applies to rings only, not to the general edge set.

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") from walk_n and the character visibly turns through NE, without my writing path decomposition.

As an application developer driving from an analog stick, I call setState on 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 cyclic flag 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
  }
Loading

5.2 Turn edge

A turn edge is a derived or authored edge whose kind is "turn". It carries a step of +1 or -1 and 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 setState to 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 via tieBreak). 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: resolve
Loading

5.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 setState arrives 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"]
Loading

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 rings array

{
  "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

Field Type Required Default Notes
rings[].id string yes Unique; namespaced ids recommended (facing.walk).
rings[].states string[] yes Min length 2. Every id must exist in states. Order defines adjacency.
rings[].cyclic boolean no false When true, last and first are adjacent.
rings[].tieBreak enum no forward Arc selection when both directions are equal length.
rings[].turn object yes Default policy applied to every derived step.
rings[].turn.mode enum yes cut or unit. unit at ring level requires per-step overrides.
rings[].turn.start object no inherits project default Portal negotiation config, same shape as existing edge start.
rings[].turn.continuity enum no exact-authored Same values as existing edges.
rings[].maxChainedSteps integer no ceil(len/2) Upper bound on a single plan; exceeding it rejects with RouteError.
rings[].overrides[] object[] no [] Per-step replacements. Matched by from+to.
edges[].kind enum Extended with "turn".
edges[].step 1 | -1 when kind: turn Direction along the ring.
edges[].ring string when kind: turn Ring the step belongs to.

6.4 Precedence

An explicitly authored edge between two ring-adjacent states always wins over a derived one. A ring override wins over the ring's default turn policy. 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"]
Loading

7. Runtime semantics

7.1 Planning

Given a setState(target) where current and target are on the same ring:

  1. Compute forward distance df and backward distance db (cyclic) or the single valid distance (acyclic).
  2. If the ring is acyclic and the target is unreachable in the declared direction set, reject RouteError.
  3. Choose the arc: smaller of df/db; on a tie use tieBreak.
  4. If the chosen distance exceeds maxChainedSteps, reject RouteError.
  5. Emit the plan as an ordered step list.

If current and target are 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 setState rejects with AbortError. Turn plans refine this rather than break it:

  • A new setState to a different ring position does not abort the in-flight request. The engine finishes the current step and replans. The original promise still rejects with AbortError (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.
  • A new setState to the same destination joins the in-flight plan, matching today's duplicate-destination behavior.
  • A new setState off-ring (say, to attack_ne) finishes the current step, then leaves the ring by the normal edge path. If no such edge exists from the landed state, it rejects RouteError.

This means a host driving from an analog stick can fire setState on 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 throughout
Loading

7.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 compile expands each ring into derived turn edges with generated ids of the form <ring>.<from>.<to> — for example facing.walk.n.ne. Derived edges appear in avl inspect output flagged derived: 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:

  • V1. A ring referencing a state id that does not exist.
  • V2. A ring with duplicate state ids.
  • V3. A ring of length < 2, or a cyclic ring of length < 3.
  • V4. turn.mode: "unit" at ring level without an override supplying a unit for every step.
  • V5. An override whose from/to pair is not adjacent in the ring.
  • V6. A kind: turn edge whose ring does not exist, or whose from/to are not adjacent in it.
  • V7. A state belonging to two rings where both rings would derive a step between the same pair (ambiguous derivation).
  • V8. A 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 inspect gains a rings section 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, the state attribute, send, and stateNames keep their current shapes and meanings. Rings are additive.

9.2 New introspection

interface AvalRingInfo {
  id: string;
  states: readonly string[];
  cyclic: boolean;
}

// on the element
player.rings: readonly AvalRingInfo[];
player.planFor(target: string): readonly string[] | null;  // dry-run, no side effects

planFor lets 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:turnstep event 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

Condition Result Notes
Target on same ring, reachable within maxChainedSteps resolves Normal path.
Target on same ring, exceeds maxChainedSteps RouteError Guardrail against accidental long arcs.
Acyclic ring, target past an end RouteError No wraparound.
Superseded by a new different target AbortError on old promise Motion continues; consistent with today.
Duplicate destination while planning joins in-flight Consistent with today.
Off-ring target with no edge from landed state RouteError Raised after the current step completes.
Portal not reached within maxWaitFrames existing behavior Unchanged by this proposal.

No new error classes are introduced. This is deliberate: hosts already handle AbortError and RouteError, and adding a PlanError would 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:turnstep event must still fire for the final landing under direct, 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.

Ring config Extra units Extra footage
8-way, cut mode 0 none
8-way, symmetric pivots (reversible) 8 8 short clips
8-way, asymmetric pivots 16 16 short clips

13. Acceptance criteria

  • AC1. A project declaring one 8-state cyclic ring in cut mode compiles to 16 derived step edges, visible in avl inspect with derived: true.
  • AC2. setState from walk_n to walk_e traverses walk_ne and resolves; planFor("walk_e") returns ["walk_ne", "walk_e"].
  • AC3. From walk_nw, setState("walk_ne") chooses the two-step forward arc through walk_n, not the six-step backward arc.
  • AC4. With tieBreak: "forward", a four-step tie on an 8-ring resolves forward, deterministically, across 100 runs.
  • AC5. Rapid successive setState calls simulating a stick sweep produce continuous motion with no rewind, with each superseded promise rejecting AbortError.
  • AC6. An explicit edge between two ring-adjacent states shadows the derived step, and the compiler emits a shadow note.
  • AC7. Each validation rule V1–V8 fails compilation with a message naming the offending ring and state ids.
  • AC8. No step performs a media seek; frame-continuity assertions hold across every seam in a full 8-step traversal.
  • AC9. turnPolicy: "direct" lands in the target state without intermediate steps and still fires a final avl:turnstep.
  • AC10. Existing fixtures with no rings key 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; maxChainedSteps rejection; replanning at step boundaries; promise resolution and rejection ordering under supersession.

Unit — aval-format. Round-trip of rings through parse, validate, and write. Rejection cases for V1–V8. Forward-compat: a 0.3 reader encountering 0.4 rings 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.30.4; wire format bumps 0.10.2 to carry the ring metadata needed by planFor and rings introspection.

Compatibility commitments:

  • Projects with no rings key compile unchanged (AC10).
  • A 0.4 compiler accepts 0.3 projects.
  • A 0.1-only player rejects a 0.2 asset 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 turnstep event, 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 setState find 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 — a facing ring crossed with an action axis — so walk_n to run_ne resolves as one plan? This is real ergonomic pain but a significantly larger design. Proposal: ship single-axis rings, gather usage, revisit.

Q2. Should maxChainedSteps default to ceil(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. turnstep timing. 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

v1 assumed Source says Consequence
The problem is authoring ergonomics (48 hand-written edges) GRAPH_LIMITS.maxStates: 32, maxEdges: 64, hard-enforced in validate.ts:60-66 The 8-way character does not fit in AVAL at all. Capacity is the blocking issue; ergonomics is secondary.
Add a multi-step "turn plan" as an ordered step list RoutePlan is a fixed four-slot structure (pending, active, followOn, reversal) with exactly one follow-on — "the one direct continuation" An unbounded step list would break a deliberate bounded-memory invariant. Rejected.
Multi-step traversal needs new machinery The engine already re-derives intent per completion, and queueFollowOn gives one hop of lookahead The traversal machinery already exists. What is missing is a persistent destination intent to re-derive against.

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

graph TD
  A["8-way character"] --> B["idle + walk + run<br/>= 24 states"]
  B --> C["+ attack 8 = 32<br/>at maxStates cap"]
  C --> D["+ hurt 8 = 40<br/>REJECTED at validate.ts:60"]
  A --> E["turn edges<br/>16 per action x 3 = 48"]
  E --> F["+ move/stop 16 = 64<br/>at maxEdges cap"]
  F --> G["no budget for attack,<br/>hurt, finish routes"]
Loading

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, in pendingOrReject:

function pendingOrReject(context, from, target) {
  const edge = directEdge(context.indexes, from, target);
  return edge === null
    ? freezePlan({ kind: "reject" })          // <-- multi-step dies here
    : freezePlan({ kind: "replace-pending", edge });
}

directEdge is a single-hop map lookup (directEdgesByState: Map<from, Map<to, edge>>). A request from walk_n to walk_e finds nothing and rejects with RouteError. The same single-hop lookup gates the follow-on branch at the end of planStateIntent.

This is a good place to intervene: planStateIntent is 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 steps walk_n → walk_ne while the host actually wants walk_e, the prospective state is walk_ne and nothing anywhere remembers that walk_e was 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 rings array to MotionGraphDefinition. 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 into edges[].

That distinction is what solves capacity. Derived steps live in a separate ringStepsByState index and are counted against a separate maxRingSteps budget, so a ring costs one declaration against maxEdges instead of 2n. The 48 turn edges collapse to 3 ring declarations.

graph LR
  R["rings[]<br/>3 declarations"] --> V["validate.ts<br/>derive adjacency"]
  V --> I["ringStepsByState index<br/>separate budget"]
  I --> P["planStateIntent<br/>next-step lookup"]
  X["edges[] budget<br/>maxEdges 64"] -.->|"unaffected"| P
Loading

2.2 Ring intent (traversal)

Add one nullable field to engine state: ringIntent: GraphStateId | null.

  • Set when a setState resolves to a ring traversal of more than one step.
  • Cleared when the visual state reaches it, or when any non-ring request supersedes it.
  • Consulted on each active-route completion: if unsatisfied, derive the next single step and queue it via the existing 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:

Limit Now Proposed Rationale
maxStates 32 64 8-way × (idle, walk, run, attack, hurt) = 40, plus headroom.
maxEdges 64 96 Authored edges only; ring steps no longer inflate this.
maxRings 8 New.
maxRingLength 16 Covers 12-position clock faces; bounds derivation cost.
maxRingSteps 128 New, separate budget. Bounds total derived adjacency.
maxRoutingOperationsPerTick 64 64 Unchanged — per-tick work is still one step.

maxStates: 64 needs 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)

export type GraphRingId = string;

export interface GraphRingStepPolicy {
  readonly start: GraphStartPolicy;
  readonly transition?: GraphTransitionDefinition;
  readonly continuity: GraphContinuity;
}

export interface GraphRingStepOverride {
  readonly from: GraphStateId;
  readonly to: GraphStateId;
  readonly policy: GraphRingStepPolicy;
}

export interface GraphRingDefinition {
  readonly id: GraphRingId;
  readonly states: readonly GraphStateId[];
  readonly cyclic: boolean;
  readonly tieBreak: "forward" | "backward";
  readonly step: GraphRingStepPolicy;
  readonly overrides?: readonly GraphRingStepOverride[];
  readonly maxChainedSteps?: number;
}

export interface MotionGraphDefinition {
  readonly initialState: GraphStateId;
  readonly states: readonly GraphStateDefinition[];
  readonly edges: readonly GraphEdgeDefinition[];
  readonly rings?: readonly GraphRingDefinition[];   // NEW, optional
}

rings is optional, so every existing fixture parses unchanged.

Note the step policy reuses GraphStartPolicy and GraphTransitionDefinition verbatim. A cut turn is start: { type: "cut", targetPort, maxWaitFrames: 1 } with no transition — that variant already exists in the model. A pivot turn is transition: { kind: "reversible", ... }. No new transition kind is needed, which is a meaningful simplification over v1's proposed kind: "turn".

3.2 Index additions (validate.ts)

export interface ValidatedRingStep {
  readonly ringId: GraphRingId;
  readonly from: GraphStateId;
  readonly to: GraphStateId;
  readonly step: 1 | -1;
  readonly edge: GraphEdgeDefinition;   // synthesized, id `ring:<ringId>:<from>:<to>`
}

export interface ValidatedGraphIndexes {
  // ... existing seven maps unchanged ...
  readonly ringsById: ReadonlyMap<GraphRingId, GraphRingDefinition>;
  readonly ringByState: ReadonlyMap<GraphStateId, GraphRingId>;
  readonly ringStepsByState: ReadonlyMap<
    GraphStateId,
    ReadonlyMap<GraphStateId, ValidatedRingStep>
  >;
  readonly ringPositions: ReadonlyMap<GraphRingId, ReadonlyMap<GraphStateId, number>>;
}

Synthesized edges carry a reserved ring: id prefix. GRAPH_IDENTIFIER_PATTERN is /^[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

function ringNextStep(
  indexes: ValidatedGraphIndexes,
  from: GraphStateId,
  target: GraphStateId
): ValidatedRingStep | null {
  const ringId = indexes.ringByState.get(from);
  if (ringId === undefined || indexes.ringByState.get(target) !== ringId) return null;

  const positions = indexes.ringPositions.get(ringId)!;
  const ring = indexes.ringsById.get(ringId)!;
  const i = positions.get(from)!, j = positions.get(target)!;
  const n = ring.states.length;

  let delta: 1 | -1;
  if (ring.cyclic) {
    const fwd = (j - i + n) % n;
    const back = (i - j + n) % n;
    const limit = ring.maxChainedSteps ?? Math.ceil(n / 2);
    if (Math.min(fwd, back) > limit) return null;
    delta = fwd < back ? 1 : back < fwd ? -1 : (ring.tieBreak === "forward" ? 1 : -1);
  } else {
    if (Math.abs(j - i) > (ring.maxChainedSteps ?? n)) return null;
    delta = j > i ? 1 : -1;
  }

  const next = ring.states[(i + delta + n) % n];
  return indexes.ringStepsByState.get(from)?.get(next) ?? null;
}

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:

function pendingOrReject(context, from, target) {
  const edge = directEdge(context.indexes, from, target)
    ?? ringNextStep(context.indexes, from, target)?.edge   // NEW
    ?? null;
  return edge === null
    ? freezePlan({ kind: "reject" })
    : freezePlan({ kind: "replace-pending", edge });
}

and, at the tail of planStateIntent:

const followOn = directEdge(context.indexes, effective.edge.to, target)
  ?? ringNextStep(context.indexes, effective.edge.to, target)?.edge   // NEW
  ?? null;

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() promotes followOn to pending. 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"
Loading

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:

  • Retarget mid-traversal → the superseded request rejects AbortError (unchanged), ringIntent is overwritten, and the follow-on branch queues a step toward the new target. Motion stays continuous; promise semantics stay identical.
  • Same destination → join-pending / continue-active-target, unchanged.
  • Off-ring target with no edge from the landed state → rejectRouteError, unchanged.

No new GraphSettlementError variant. Hosts' existing catch blocks keep working.

4.5 Ring intent lifecycle

Event Effect on ringIntent
setState resolving to a ring step, target ≠ next step set to target
setState resolving to a single step (target === next) cleared (no chaining needed)
setState to an off-ring target cleared
send(event) resolving any edge cleared — events are authored intent and outrank derived traversal
visual state reaches intent cleared
dispose, fail-playback, readiness loss cleared

The send rule 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:

  • V1 ring id matches GRAPH_IDENTIFIER_PATTERN; unique across rings.
  • V2 every state in states[] exists in statesById.
  • V3 no duplicate states within a ring; length ≥ 2, and ≥ 3 if cyclic.
  • V4 no state belongs to two rings (ringByState must be injective) — avoids ambiguous derivation.
  • V5 every override's from/to is adjacent in the declared order.
  • V6 maxChainedSteps, if present, is a positive safe integer ≤ ring length.
  • V7 for a cut-mode step, the target body exposes the referenced targetPort (checked against portsByState).
  • V8 for a pivot-mode step, the referenced unit exists and frameCount is positive.
  • V9 ring count ≤ maxRings; ring length ≤ maxRingLength; total derived steps ≤ maxRingSteps.
  • V10 an authored edge shadowing a derived step is allowed but recorded, so the compiler can emit an informational note.

6. Patch plan

Ordered so each stage is independently testable and green.

# Package / file Change Est.
1 graph/src/limits.ts Raise maxStates/maxEdges; add ring limits XS
2 graph/src/model.ts Add GraphRingDefinition and friends; rings? on definition S
3 graph/src/validate.ts Ring validation V1–V10; build four new indexes; synthesize step edges L
4 graph/src/intent-router.ts ringNextStep + two fallback call sites M
5 graph/src/engine-state.ts ringIntent field, lifecycle per §4.5 M
6 graph/src/engine.ts Consult intent on completion; queue next step M
7 graph/src/index.ts Export ring types, ringNextStep for host preview XS
8 format/ Wire encode/decode for rings; version bump L
9 compiler/ Project rings → graph rings; grouped continuity report L
10 element/, react/ rings introspection, planFor, turnstep event M
11 fixtures/ 8-way character fixture M

Stages 1–7 are self-contained in the 3.5k-line graph package 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 MotionGraphDefinition with rings directly and drive the engine.


7. Acceptance criteria

  • AC1 A graph with an 8-state cyclic ring validates; ringStepsByState contains 16 entries; edges[] budget consumption is unchanged from the ringless equivalent.
  • AC2 setState("walk_e") from walk_n traverses walk_ne and settles target-committed; two transitionend effects are emitted.
  • AC3 From walk_nw, setState("walk_ne") picks the 2-step forward arc, not the 6-step backward arc.
  • AC4 Tie at 4 steps on an 8-ring resolves per tieBreak, identically across 100 runs.
  • AC5 Retarget mid-traversal: superseded request rejects AbortError; no rewind; the visual state sequence is monotonic along the ring.
  • AC6 An authored edge between ring-adjacent states shadows the derived step (authored wins).
  • AC7 V1–V10 each fail validation with a message naming ring and state ids.
  • AC8 send(event) mid-traversal clears ring intent; the machine does not resume the turn afterward.
  • AC9 No seek occurs at any seam across a full 8-step traversal (existing frame-continuity assertions extended).
  • AC10 Every existing fixture and test passes unchanged; graphs without rings produce byte-identical compiled output.
  • AC11 A 40-state 8-way character (idle/walk/run/attack/hurt) validates under the new limits.

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 against performance-and-budgets before landing. Mitigation: raise maxStates in 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 existing MotionGraphTraceRecord machinery 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 maxEdges at all? Proposed: no, separate maxRingSteps budget. 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 — send clearing 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 ringNextStep be exported? Useful for host preview (planFor) and for tests, but it widens the public surface of aval-graph. Alternative: expose only a higher-level planFor on the element.

Q5 — maxStates: 64 or 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

Claim Location
maxStates: 32, maxEdges: 64 packages/graph/src/limits.ts
Limits enforced at validation packages/graph/src/validate.ts:60-66
Four-slot route plan, one follow-on packages/graph/src/route-plan.ts
Single-hop rejection point packages/graph/src/intent-router.tspendingOrReject
Pure intent planning packages/graph/src/intent-router.tsplanStateIntent
Body kinds loop / finite / held packages/graph/src/model.tsGraphBodyKind
Start policies portal / finish / cut packages/graph/src/model.tsGraphStartPolicy
Settlement errors packages/graph/src/model.tsGraphSettlementError
Identifier pattern excludes : packages/graph/src/limits.tsGRAPH_IDENTIFIER_PATTERN

Package sizes at time of writing: graph 3,485 LOC, format 14,923, compiler 17,161, element 19,153, player-web 85,096, react 1,095. The react package exists in main despite being listed as a TODO in the published README — treat the docs as lagging the source generally.

johndpope and others added 27 commits July 17, 2026 05:26
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.
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

@johndpope is attempting to deploy a commit to the pixelpoint Team on Vercel.

A member of the Team first needs to authorize it.

@johndpope

Copy link
Copy Markdown
Author
Screenshot From 2026-07-26 23-31-09

theres a demo somewhere - fixtures/rings/v1-eight-way-facing/test.html

I dont expect you to merge this - but feel free to upstream idea with cherry picking.

@johndpope

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant