@adobe/data-ai 0.9.98 → 0.9.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,21 +41,32 @@ function record(m: unknown) {
41
41
  A collection's type states whether its order is meaningful — the model is the
42
42
  single source of truth, not a downstream comparison flag:
43
43
 
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.
52
62
 
53
63
  These are first-class `Data` (see `features/data/index.md`) — serialize a
54
64
  Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify`
55
65
  cannot represent them), and `equals` compares them faithfully. Conformance mirrors
56
66
  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.
59
70
 
60
71
  ## Shape of keyed collections
61
72
 
@@ -15,18 +15,89 @@ State Specification is authoritative and the ECS conforms to it. A feature witho
15
15
  this rule does not apply — see `../index.md`, Two modes.
16
16
 
17
17
  ```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
+ };
20
35
  export * as State from "./public.js";
21
36
  ```
22
37
 
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
25
60
 
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
+ ```
30
101
 
31
102
  **`State` has a standard shape.** Two exports are conventional and drive
32
103
  conformance:
@@ -106,21 +177,28 @@ export const entity = ConformanceApi.entity;
106
177
  ```ts
107
178
  // create-todo.ts
108
179
  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
111
182
  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"> => {
115
186
  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 }) };
117
192
  };
118
193
 
119
194
  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)
122
197
  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 }]]) },
124
202
  effects: { analytics: [["todoCreated", { name: "a" }]] } },
125
203
  ];
126
204
  ```
@@ -155,12 +233,17 @@ export const cases: Conformance<typeof createTodo> = [
155
233
  overrides the default wholesale.)
156
234
  - **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
157
235
  / `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
164
247
  honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
165
248
  on the expected side too. When an id must **line up in two places** within one
166
249
  comparison — a `selectedId` that points at a specific todo, say — use
@@ -168,12 +251,32 @@ export const cases: Conformance<typeof createTodo> = [
168
251
  not a pinned value, so the two occurrences of the label must resolve to the same
169
252
  actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a
170
253
  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` — open,
271
+ distinct keys against ECS-minted ids.
272
+ - **Building the open-keyed map — `Match.refMap(values)`.** When the entries come
273
+ from a list rather than hand-authored labels (a `samples` entry, a case `after`
274
+ whose entities you don't cross-reference), don't hand-roll the `Match.ref` keys:
275
+ `Match.refMap(iterableOfValues)` returns a `ReadonlyMap<number, V>` keyed by
276
+ process-unique open `ref`s — the standard way to build an identity-keyed collection
277
+ for comparison. Reach for a **hand-written `Match.ref(label)`** only when a key must
278
+ **correspond** to a reference elsewhere in the same case (a `selectedId`), where the
279
+ shared label is the whole point.
177
280
  - No per-transform test. The single **`spec.test.ts`** is one call —
178
281
  `Conformance.runSpec({ state: State, transitions })` importing `transitions`
179
282
  from the test-only `./transitions.js` (above) — that auto-discovers every module
@@ -220,8 +323,10 @@ state change and the service calls.
220
323
  ## Derivations — `(state) => value`, cases `{ input, value }`
221
324
 
222
325
  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**
326
+ `State` fields** (`visibleTodos` from `entities` + `displayCompleted`;
327
+ `currentPlayer` from `board` + `firstPlayer`). An entity query like `visibleTodos`
328
+ returns entity **ids** (`ReadonlyArray<number>` / `ReadonlySet<number>`), never the
329
+ values (look those up in `entities`). A value computed from a **single**
225
330
  `State` field is that field's own type math and lives on its `data/<type>`
226
331
  namespace (`winner`/`status` from `board` → `data/board-state`), tested there —
227
332
  **not** in `state/`. A feature may therefore have zero `state/` derivations.
@@ -37,14 +37,18 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency):
37
37
 
38
38
  - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
39
39
  expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
40
- `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are
40
+ `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`, and the collection
41
+ builder `Match.refMap(values)` (an identity-keyed `ReadonlyMap<number, V>` of
42
+ id-less values with distinct open keys — the standard way to author a `samples`
43
+ entry or a created-entity `after`; see `../../../data/state.md`). Options are
41
44
  `{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb
42
45
  F32↔f64 / trig noise. **Ordering is carried by the value's type**: a
43
46
  `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.
47
+ **order-independently** — there is no `unordered` option. Entity identity is the
48
+ key of `State.entities` (a `ReadonlyMap<number, …>`), resolved to the allocated ECS
49
+ entity via `resolver` (below); entity *values* carry no `id`, so there is no id to
50
+ ignore when comparing content. Framework-agnostic: it honors any asymmetric matcher,
51
+ so vitest's `expect.any(...)` interops.
48
52
  - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
49
53
  `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
50
54
  marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
@@ -144,10 +148,10 @@ Conformance.runFeature({
144
148
  `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
145
149
  no `match`, no `ops` — moves are board-index addressed, so no `entity()`
146
150
  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`).
151
+ `data-lit-space-rock-game` holds its `bullet` / `asteroid` entities in the single
152
+ `entities: ReadonlyMap<number, Bullet | Asteroid>`, keyed by id, so they compare
153
+ order-independently by the map — no `match` option (its per-frame transitions are
154
+ conformed by the systems tick loop, not here — see `systems.md`).
151
155
 
152
156
  ## The pure spec — `data/state/spec.test.ts`
153
157
 
@@ -205,9 +209,10 @@ positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its
205
209
  rationale live in `../../../data-modelling.md`). What's specific to writing
206
210
  conformance cases:
207
211
 
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).
212
+ - **Entity identity is the `State.entities` key, not a value field.** Entity values
213
+ carry no `id`, so there is nothing to omit or ignore — content compares directly.
214
+ The map key is a spec-domain id the runner resolves to the allocated ECS entity via
215
+ `resolver`; assert a *reference* between entities with `Match.ref` (below).
211
216
  - **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through
212
217
  `match?: { tolerance }`; raise it only when a case needs a looser grid.
213
218
  - **`Match.ref(label)`** on the expected side asserts id *correspondence* for a
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adobe-data-ai",
3
- "version": "0.9.98",
3
+ "version": "0.9.100",
4
4
  "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
5
5
  "author": {
6
6
  "name": "Adobe"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-ai",
3
- "version": "0.9.98",
3
+ "version": "0.9.100",
4
4
  "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).",
5
5
  "type": "module",
6
6
  "private": false,
@@ -29,12 +29,12 @@
29
29
  "skills"
30
30
  ],
31
31
  "peerDependencies": {
32
- "@adobe/data": "0.9.98"
32
+ "@adobe/data": "0.9.100"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.3",
36
36
  "vitest": "^1.6.0",
37
- "@adobe/data": "0.9.98"
37
+ "@adobe/data": "0.9.100"
38
38
  },
39
39
  "license": "MIT",
40
40
  "scripts": {