Skip to content

Commit 764dd88

Browse files
krisnyeclaude
andcommitted
docs(data-ai)+samples: entities-map State pattern; conform all samples
State spec pattern for the feature architecture, aligned with the id-removal: - State = named singletons (→ resources) + one `entities: ReadonlyMap<number, V>` keyed by a plain numeric id; entity value types carry NO id (identity is the key), are structural with a composing `is` guard, and property names are unique feature-wide (one name → one component); queries return ids (Set unordered / Array ordered by an `order` component). Rules: state.md (rewritten), flipped data-modelling.md's identity-collection guidance, fixed conformance.md. - @adobe/data-testing: `Match.ref` re-typed to fit numeric/map-key positions so a case's `after`/`samples` entity map keys use distinct `Match.ref` labels (`Match.anyNumber` is a shared singleton that would collapse duplicate keys). - Migrated every entity-bearing sample to the pattern (todo, pixie, space-rock, gpu-hopper): entity types lose id, State uses one `entities` map, transitions + conformance projections + derivations updated; all suites green. Singleton features (tictactoe, dashboard, p2p-tictactoe) need no change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 77cbe13 commit 764dd88

77 files changed

Lines changed: 1313 additions & 982 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/data-ai/.claude/rules/data-modelling.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,32 @@ function record(m: unknown) {
4141
A collection's type states whether its order is meaningful — the model is the
4242
single source of truth, not a downstream comparison flag:
4343

44-
- **`ReadonlyArray<T>`** — order is meaningful. A display list rendered in sequence,
45-
a drag-reorderable list, a positional tuple (`Vec2 = readonly [number, number]`).
46-
- **`ReadonlySet<T>`** — an unordered bag. Entities materialised in nondeterministic
47-
order, a membership set. Use this for **identity-keyed** collections: the element
48-
carries its own `id`, so a `ReadonlySet<Entity>` replaces any
49-
`ReadonlyMap<id, Entity>`.
50-
- **`ReadonlyMap<K, V>`** — a keyed lookup whose **keys are meaningful/deterministic**
51-
(an enum, a name, a stable string). Not for identity keys (those are Sets).
44+
- **`ReadonlyArray<T>`** — order is meaningful. A display list of scalar values, a
45+
positional tuple (`Vec2 = readonly [number, number]`), or an **ordered query
46+
result of entity ids** (`ReadonlyArray<number>`, sorted by an `order` component —
47+
see below). Not for the entity *store* itself.
48+
- **`ReadonlySet<T>`** — an unordered bag / membership set. A set of entity
49+
references is `ReadonlySet<number>` (a set of ids), and an unordered entity
50+
*query result* is `ReadonlySet<number>`.
51+
- **`ReadonlyMap<K, V>`** — a keyed lookup. This is how **identity-keyed entities**
52+
are modelled: `entities: ReadonlyMap<number, EntityValue>` — the `number` id is the
53+
key, and the value carries **no `id` of its own** (identity is the key, never a
54+
field). Also used for deterministic-key lookups (an enum, a name, a stable string).
55+
56+
**Entities are keyed, never id-bearing values.** An entity's identity is its map
57+
key, so entity value types (`Todo`, `Bullet`) have no `id` field, and there is no
58+
`ReadonlySet<T>`/`ReadonlyArray<T>` *of entity values* — the single
59+
`ReadonlyMap<number, …>` store is the only home for entities, and queries return
60+
their **ids** (`ReadonlySet<number>` unordered, `ReadonlyArray<number>` ordered).
61+
See `features/data/state.md` for the full `State` shape.
5262

5363
These are first-class `Data` (see `features/data/index.md`) — serialize a
5464
Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify`
5565
cannot represent them), and `equals` compares them faithfully. Conformance mirrors
5666
the semantics: `ReadonlyArray` compares positionally, `ReadonlySet` / `ReadonlyMap`
57-
order-independently, and a numeric `id` is ignored (the ECS allocates it) — so there
58-
is no separate "unordered" declaration when writing conformance cases.
67+
order-independently. Entity identity is the map key (resolved to the allocated ECS
68+
entity during conformance); the id is never a value field, so there is nothing to
69+
"ignore" when comparing entity content.
5970

6071
## Shape of keyed collections
6172

packages/data-ai/.claude/rules/features/data/state.md

Lines changed: 128 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,89 @@ State Specification is authoritative and the ECS conforms to it. A feature witho
1515
this rule does not apply — see `../index.md`, Two modes.
1616

1717
```ts
18-
// state.ts — the aggregate + the transition/derivation namespace.
19-
export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
18+
// data/todo/todo.ts — an entity value type: plain readonly data, NO id.
19+
export type Todo = { readonly name: string; readonly complete: boolean; readonly order: number };
20+
export * as Todo from "./public.js";
21+
22+
// data/todo/is.ts — a structural type guard, re-exported so it reads as `Todo.is`.
23+
// `"k" in v` narrows `v` so each field reads without a cast.
24+
export const is = (v: unknown): v is Todo =>
25+
typeof v === "object" && v !== null &&
26+
"name" in v && typeof v.name === "string" &&
27+
"complete" in v && typeof v.complete === "boolean" &&
28+
"order" in v && typeof v.order === "number";
29+
30+
// data/state/state.ts — singletons + one identity-keyed entity map.
31+
export type State = {
32+
readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name
33+
readonly entities: ReadonlyMap<number, Todo>; // ALL entities, keyed by id; the value has no id
34+
};
2035
export * as State from "./public.js";
2136
```
2237

23-
Every **state-based** feature owns a `State` (a scalar `{ playing: boolean }`, or
24-
`{}` when there is none). An ECS-based feature has no `State` at all.
38+
Every **state-based** feature owns a `State`. An ECS-based feature has none.
39+
40+
## The standard `State` shape
41+
42+
A `State` has two kinds of field:
43+
44+
- **Singletons** — every non-`entities` field (`displayCompleted`, a `score`, a
45+
`board`). Each becomes an ECS **resource** of the same name (see
46+
`../services/main-service/resources.md`).
47+
- **`entities`** — a single `ReadonlyMap<number, EntityValue>` holding *every* entity,
48+
keyed by a numeric id, where `EntityValue` is the union of the feature's entity value
49+
types (`Todo`, or `Bullet | Asteroid`). Omit `entities` entirely for a feature with
50+
no entities (`tictactoe`, `dashboard`) — it is then all singletons.
51+
52+
This maps 1:1 to the ECS with **zero dependency on it** — the key is a plain `number`,
53+
never the ECS `Entity`, so `data/` provably cannot reach ECS machinery. Singletons ↔
54+
resources; each `entities` value ↔ one entity whose **component set is the value's own
55+
keys**, so `fromState` inserts a value into the archetype named by `Object.keys(value)`
56+
and `toState` reads every entity back into the map. Identity lives in the key, never a
57+
field — see `../../data-modelling.md` (Entities are keyed, never id-bearing values).
58+
59+
### Entity value types — structural, id-less, one `is` guard each
2560

26-
**A `State` field's collection type carries its ordering** — see
27-
`../../data-modelling.md` (Collection ordering is carried by the type). `todos`
28-
above is a `ReadonlyArray` because todo order is a user-visible, reorderable fact;
29-
an unordered entity bag (bullets, sprites) is a `ReadonlySet`.
61+
Each entity type is its own `data/<type>/` namespace (`data/index.md`): a plain
62+
readonly type with **no `id`** plus a structural `is` guard (`is.ts`, re-exported so it
63+
reads `Todo.is`). A **sub-archetype** is an intersection — `Bar = Foo & { baz }` — and
64+
its guard **composes the base guard**: `Bar.is` calls `Foo.is(v)` first, then checks the
65+
added fields (`"baz" in v && typeof v.baz === "boolean"`). So `Bar.is ⟹ Foo.is` by
66+
construction.
67+
68+
**Model structurally, like the ECS — no tags by default.** ECS systems match purely on
69+
structure (a mover runs on anything with `position` + `velocity`, regardless of
70+
"kind"), and `State` mirrors that:
71+
72+
- **A property name means one thing feature-wide.** No two entity types may declare the
73+
same property name with a different type/semantics — that is what lets every property
74+
collapse to exactly one ECS component and keeps structural matching sound.
75+
- **No marker / `kind` / tag field unless a genuine modelling need demands it.** Prefer
76+
discriminating on the presence of real components; add a tag only when structure alone
77+
cannot express the distinction.
78+
79+
### Querying entities — `State.getXEntities`, returning ids
80+
81+
Entity queries are `state/` derivations that return entity **ids** (look the value up
82+
with `state.entities.get(id)`), mirroring the ECS `select`. Perf is irrelevant here
83+
(full-map scan + guard); the ECS realises them as archetype queries.
84+
85+
- **Unordered**`ReadonlySet<number>`: keys whose value passes the guard. It is a
86+
**superset** match — `getFooEntities` (via `Foo.is`) also includes `Bar` entities,
87+
just as `queryArchetypes(["…Foo's cols"])` returns the `Foo` *and* `Bar` archetypes.
88+
- **Ordered**`ReadonlyArray<number>`: the matching ids sorted by an explicit `order`
89+
component, exactly like an ECS `select(cols, { order })`. Order is **never** carried
90+
by the `entities` map (it is identity-keyed / unordered); a meaningful order is a
91+
component on the value and the derivation returns the sorted ids.
92+
93+
```ts
94+
// data/state/get-todos.ts — an ordered membership derivation returning ids
95+
export const getTodos = (state: State): readonly number[] =>
96+
[...state.entities]
97+
.filter(([, v]) => Todo.is(v))
98+
.sort(([, a], [, b]) => a.order - b.order)
99+
.map(([id]) => id);
100+
```
30101

31102
**`State` has a standard shape.** Two exports are conventional and drive
32103
conformance:
@@ -106,21 +177,28 @@ export const entity = ConformanceApi.entity;
106177
```ts
107178
// create-todo.ts
108179
import { Match } from "@adobe/data-testing";
109-
import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
110-
import type { Services } from "../../services/services.js"; // the feature's service map
180+
import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
181+
import type { Services } from "../../services/services.js"; // the feature's service map
111182
export const createTodo = (
112-
state: Pick<State, "todos">,
113-
{ name, complete, analytics }: { name: string; complete?: boolean } & Pick<Services, "analytics">,
114-
): Pick<State, "todos"> => {
183+
state: Pick<State, "entities">,
184+
{ name, analytics }: { name: string } & Pick<Services, "analytics">,
185+
): Pick<State, "entities"> => {
115186
analytics.todoCreated({ name });
116-
return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
187+
// The spec mints the id (the map key) and the order; the value carries neither an id
188+
// nor anything the ECS allocates — identity is the key.
189+
const id = Math.max(0, ...state.entities.keys()) + 1;
190+
const order = state.entities.size;
191+
return { entities: new Map(state.entities).set(id, { name, complete: false, order }) };
117192
};
118193

119194
export const cases: Conformance<typeof createTodo> = [
120-
{ name: "appends the first todo",
121-
before: {}, // empty delta — the default State.create()
195+
{ name: "adds the first todo",
196+
before: {}, // empty delta — the default State.create() (empty entities)
122197
args: { name: "a", analytics: AnalyticsService.createFake() },
123-
after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field
198+
// The map key is an id the ECS mints — use `Match.ref("label")` (a DISTINCT
199+
// label per entity). The value is id-less so content compares directly. (Maps
200+
// compare entry-wise / order-independently — see conformance.md.)
201+
after: { entities: new Map([[Match.ref("a"), { name: "a", complete: false, order: 0 }]]) },
124202
effects: { analytics: [["todoCreated", { name: "a" }]] } },
125203
];
126204
```
@@ -155,25 +233,42 @@ export const cases: Conformance<typeof createTodo> = [
155233
overrides the default wholesale.)
156234
- **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
157235
/ `Match.anyString`, imported from `@adobe/data-testing` — there is **no**
158-
per-feature `matchers.ts` anymore. An entity's own numeric `id` is **ignored by
159-
default** (the ECS allocates it from its own id-space), so a case simply **omits
160-
`id`** and the entity's content still compares — no `id: Match.anyNumber` needed.
161-
(Reach for `id: Match.anyNumber` only when the type makes `id` required and a
162-
literal would otherwise pin it.) Match by content, not by the value you don't
163-
control. `Match` is framework-agnostic and
236+
per-feature `matchers.ts` anymore. An entity's identity is the `entities` map **key**,
237+
not a value field, so entity content compares directly with nothing to ignore. The
238+
key is an ECS-minted id, so use **`Match.ref("label")` with a DISTINCT label per map
239+
entry** (`[Match.ref("a"), value]`) — `ref` returns a fresh object so distinct labels
240+
are distinct keys, and its injective binding both keeps entities distinct and lets an
241+
entity **correlate** with a reference elsewhere in the case (reuse the label, e.g. a
242+
`selectedId: Match.ref("a")` singleton). Do **not** use `Match.anyNumber` as a map key
243+
— it is a shared singleton, so two entries keyed by it collapse to one. `entity(specId)`
244+
is for **`args`** (it resolves via the seed map), **not** `after` keys — a case's `after`
245+
entities may be freshly created, with no seed mapping. Match by content, not by a value
246+
you don't control. `Match` is framework-agnostic and
164247
honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
165248
on the expected side too. When an id must **line up in two places** within one
166249
comparison — a `selectedId` that points at a specific todo, say — use
167250
`Match.ref(label)`: it asserts id *correspondence* (a bijection up to renaming),
168251
not a pinned value, so the two occurrences of the label must resolve to the same
169252
actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a
170253
case does not pin at all; `ref` for one that must be consistent across the case.
171-
- **Entity-addressed cases use `entity(specId)`.** A transition that addresses an
172-
entity by id writes it as `args: { id: entity(2) }``entity` imported from the
173-
feature's `conformance-case.ts` (re-exported from `@adobe/data-testing`). It types
174-
as the id it stands for (like `Match.anyNumber`), so it slots into the transform's
175-
own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS
176-
runners resolve it to the seeded entity (see `conformance.md`).
254+
- **Entity-addressed cases use `entity(specId)` in `args`.** A transition that
255+
addresses an entity by id writes it as `args: { id: entity(2) }``entity` imported
256+
from the feature's `conformance-case.ts` (re-exported from `@adobe/data-testing`). It
257+
types as the id it stands for, so it slots into the transform's own arg type. `runSpec`
258+
unwraps it to the plain data-id for the pure side; the ECS runners resolve it (via the
259+
`fromState` seed map) to the seeded entity (see `conformance.md`).
260+
- **The `entities` map key convention across a case, in one place:**
261+
- **`before`** (the seed): **plain spec-id numbers** (`new Map([[1, …], [2, …]])`).
262+
`fromState` seeds from these and returns the `spec-id → entity` map, so `args:
263+
{ id: entity(1) }` resolves to the entity seeded for `1`.
264+
- **`after`** (the expectation, compared against the ECS by content): **`Match.ref`
265+
with a distinct label per entry** (`[[Match.ref("a"), …], [Match.ref("b"), …]]`) —
266+
the ECS mints its own ids, so keys must be open; `ref` is a fresh object (distinct
267+
keys don't collapse) and injective (entities stay distinct, and a label reused in a
268+
singleton reference correlates). Never `entity(specId)` here — a case's `after` may
269+
hold freshly created entities with no seed mapping.
270+
- **`samples`** (round-tripped `toState ∘ fromState`): same as `after`**`Match.ref`
271+
distinct labels** (they compare against ECS-minted ids).
177272
- No per-transform test. The single **`spec.test.ts`** is one call —
178273
`Conformance.runSpec({ state: State, transitions })` importing `transitions`
179274
from the test-only `./transitions.js` (above) — that auto-discovers every module
@@ -220,8 +315,10 @@ state change and the service calls.
220315
## Derivations — `(state) => value`, cases `{ input, value }`
221316

222317
Pure selectors that **compose the aggregate** — a value drawn from **two or more
223-
`State` fields** (`visibleTodos` from `todos` + `displayCompleted`;
224-
`currentPlayer` from `board` + `firstPlayer`). A value computed from a **single**
318+
`State` fields** (`visibleTodos` from `entities` + `displayCompleted`;
319+
`currentPlayer` from `board` + `firstPlayer`). An entity query like `visibleTodos`
320+
returns entity **ids** (`ReadonlyArray<number>` / `ReadonlySet<number>`), never the
321+
values (look those up in `entities`). A value computed from a **single**
225322
`State` field is that field's own type math and lives on its `data/<type>`
226323
namespace (`winner`/`status` from `board``data/board-state`), tested there —
227324
**not** in `state/`. A feature may therefore have zero `state/` derivations.

packages/data-ai/.claude/rules/features/services/main-service/conformance.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency):
4141
`{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb
4242
F32↔f64 / trig noise. **Ordering is carried by the value's type**: a
4343
`ReadonlyArray` compares **in order**, a `ReadonlySet` / `ReadonlyMap`
44-
**order-independently** — there is no `unordered` option. A numeric `id` a case
45-
does not mention is **ignored** (the ECS allocates it), so entity content compares
46-
without pinning ids. Framework-agnostic: it honors any asymmetric matcher, so
47-
vitest's `expect.any(...)` interops.
44+
**order-independently** — there is no `unordered` option. Entity identity is the
45+
key of `State.entities` (a `ReadonlyMap<number, …>`), resolved to the allocated ECS
46+
entity via `resolver` (below); entity *values* carry no `id`, so there is no id to
47+
ignore when comparing content. Framework-agnostic: it honors any asymmetric matcher,
48+
so vitest's `expect.any(...)` interops.
4849
- **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
4950
`DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
5051
marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
@@ -144,10 +145,10 @@ Conformance.runFeature({
144145
`data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
145146
no `match`, no `ops` — moves are board-index addressed, so no `entity()`
146147
markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers.
147-
`data-lit-space-rock-game` models its entity bags (`bullets`, `asteroids`) as
148-
`ReadonlySet` on `State`, so they compare order-independently by type — no `match`
149-
option (its per-frame transitions are conformed by the systems tick loop, not here — see
150-
`systems.md`).
148+
`data-lit-space-rock-game` holds its `bullet` / `asteroid` entities in the single
149+
`entities: ReadonlyMap<number, Bullet | Asteroid>`, keyed by id, so they compare
150+
order-independently by the map — no `match` option (its per-frame transitions are
151+
conformed by the systems tick loop, not here — see `systems.md`).
151152

152153
## The pure spec — `data/state/spec.test.ts`
153154

@@ -205,9 +206,10 @@ positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its
205206
rationale live in `../../../data-modelling.md`). What's specific to writing
206207
conformance cases:
207208

208-
- **A numeric `id` is ignored unless a case pins it.** The ECS allocates entity ids
209-
from its own space, so a case omits `id` and the entity's content still compares.
210-
Pin it only to assert a reference (below).
209+
- **Entity identity is the `State.entities` key, not a value field.** Entity values
210+
carry no `id`, so there is nothing to omit or ignore — content compares directly.
211+
The map key is a spec-domain id the runner resolves to the allocated ECS entity via
212+
`resolver`; assert a *reference* between entities with `Match.ref` (below).
211213
- **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through
212214
`match?: { tolerance }`; raise it only when a case needs a looser grid.
213215
- **`Match.ref(label)`** on the expected side asserts id *correspondence* for a
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import type { Hazard } from "./hazard.js";
3+
4+
// A structural type guard, re-exported so it reads as `Hazard.is`. Discriminates a
5+
// hazard entity value by the presence and primitive shape of its own components —
6+
// no id, no tag (identity is the `State.entities` map key).
7+
export const is = (v: unknown): v is Hazard =>
8+
typeof v === "object" &&
9+
v !== null &&
10+
"kind" in v &&
11+
typeof v.kind === "string" &&
12+
"lane" in v &&
13+
typeof v.lane === "number" &&
14+
"x" in v &&
15+
typeof v.x === "number" &&
16+
"width" in v &&
17+
typeof v.width === "number" &&
18+
"velocity" in v &&
19+
typeof v.velocity === "number";

packages/data-gpu-hopper/src/features/main/data/hazard/public.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
22
export { schema } from "./schema.js";
3+
export { is } from "./is.js";
34
export { covers } from "./covers.js";
45
export { coversAt } from "./covers-at.js";
56
export { advance } from "./advance.js";

0 commit comments

Comments
 (0)