@forgeax/engine-ecs 0.1.20 → 0.1.23

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.
Files changed (100) hide show
  1. package/README.md +50 -16
  2. package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
  3. package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
  4. package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
  5. package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/component-version-surface.test.d.ts +2 -0
  7. package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
  8. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
  9. package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
  11. package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
  13. package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
  14. package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
  15. package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
  16. package/dist/component-schema.d.ts +17 -0
  17. package/dist/component-schema.d.ts.map +1 -1
  18. package/dist/component.d.ts +7 -1
  19. package/dist/component.d.ts.map +1 -1
  20. package/dist/errors/query-and-component-errors.d.ts +1 -1
  21. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  22. package/dist/errors/validation-errors.d.ts +24 -0
  23. package/dist/errors/validation-errors.d.ts.map +1 -1
  24. package/dist/errors.d.ts +14 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/externalization/index.mjs.map +1 -1
  27. package/dist/index.mjs +415 -128
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/internal.d.ts +16 -0
  30. package/dist/internal.d.ts.map +1 -0
  31. package/dist/internal.mjs +15 -2
  32. package/dist/internal.mjs.map +1 -1
  33. package/dist/projection/index.d.ts +27 -52
  34. package/dist/projection/index.d.ts.map +1 -1
  35. package/dist/projection/index.mjs +66 -76
  36. package/dist/projection/index.mjs.map +1 -1
  37. package/dist/query/derived-range-writer.d.ts +48 -0
  38. package/dist/query/derived-range-writer.d.ts.map +1 -0
  39. package/dist/query/query.d.ts +1 -1
  40. package/dist/query/query.d.ts.map +1 -1
  41. package/dist/relationship-index.d.ts +8 -0
  42. package/dist/relationship-index.d.ts.map +1 -1
  43. package/dist/shared-ref-store.d.ts +5 -9
  44. package/dist/shared-ref-store.d.ts.map +1 -1
  45. package/dist/shared.mjs.map +1 -1
  46. package/dist/storage/change-detection.d.ts +1 -3
  47. package/dist/storage/change-detection.d.ts.map +1 -1
  48. package/dist/storage/structural-evidence.d.ts +30 -0
  49. package/dist/storage/structural-evidence.d.ts.map +1 -0
  50. package/dist/world-component-access.d.ts +4 -3
  51. package/dist/world-component-access.d.ts.map +1 -1
  52. package/dist/world-core.d.ts +2 -2
  53. package/dist/world-core.d.ts.map +1 -1
  54. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  55. package/dist/world-internal.d.ts +1 -1
  56. package/dist/world-internal.d.ts.map +1 -1
  57. package/dist/world.d.ts +10 -14
  58. package/dist/world.d.ts.map +1 -1
  59. package/package.json +5 -5
  60. package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
  61. package/src/__tests__/component-requirements.unit.test.ts +76 -0
  62. package/src/__tests__/component-version-surface.test.ts +45 -0
  63. package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
  64. package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
  65. package/src/__tests__/errors.unit.test.ts +21 -5
  66. package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
  67. package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
  68. package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
  69. package/src/__tests__/query.unit.test.ts +10 -6
  70. package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
  71. package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
  72. package/src/__tests__/structural-evidence.contract.test.ts +49 -0
  73. package/src/component-schema.ts +51 -0
  74. package/src/component.ts +12 -2
  75. package/src/errors/query-and-component-errors.ts +1 -1
  76. package/src/errors/validation-errors.ts +30 -0
  77. package/src/errors.ts +29 -5
  78. package/src/internal.ts +56 -0
  79. package/src/projection/index.ts +68 -149
  80. package/src/query/derived-range-writer.ts +332 -0
  81. package/src/query/query.ts +89 -15
  82. package/src/relationship-index.ts +13 -1
  83. package/src/shared-ref-store.ts +16 -24
  84. package/src/storage/change-detection.ts +0 -15
  85. package/src/storage/structural-evidence.ts +64 -0
  86. package/src/world-component-access.ts +53 -5
  87. package/src/world-core.ts +2 -2
  88. package/src/world-entity-lifecycle.ts +10 -1
  89. package/src/world-internal.ts +5 -5
  90. package/src/world.ts +45 -50
  91. package/dist/.tsbuildinfo +0 -1
  92. package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
  93. package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
  94. package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
  95. package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
  96. package/dist/world-change-journal.d.ts +0 -34
  97. package/dist/world-change-journal.d.ts.map +0 -1
  98. package/src/__tests__/world-change-journal.unit.test.ts +0 -97
  99. package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
  100. package/src/world-change-journal.ts +0 -84
@@ -0,0 +1,339 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { componentId, defineComponent } from '../component';
3
+ import { ChangeEpochExhaustedError } from '../errors';
4
+ import { getDerivedWriter } from '../internal';
5
+ import type { Query } from '../query/query';
6
+ import { World } from '../world';
7
+ import { worldInternal } from '../world-internal';
8
+
9
+ const Value = defineComponent('DerivedRangeWriterValue', { value: 'f32' });
10
+ const Input = defineComponent('DerivedRangeWriterInput', { value: 'f32' });
11
+
12
+ function fixture(): {
13
+ world: World;
14
+ query: Query<readonly [typeof Input], readonly [typeof Value]>;
15
+ } {
16
+ const world = new World();
17
+ for (let index = 0; index < 4; index += 1) {
18
+ world
19
+ .spawn(
20
+ { component: Input, data: { value: index } },
21
+ { component: Value, data: { value: index } },
22
+ )
23
+ .unwrap();
24
+ }
25
+ const query = world.query({ read: [Input], write: [Value] }).unwrap();
26
+ return { world, query };
27
+ }
28
+
29
+ describe('package-internal derived range writer contract', () => {
30
+ it('publishes only the exact span-relative range with one component epoch', () => {
31
+ const { world, query } = fixture();
32
+ const writer = getDerivedWriter(query, Value).unwrap();
33
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
34
+
35
+ expect(
36
+ writer.writeRange(
37
+ 0,
38
+ 0,
39
+ 1,
40
+ 2,
41
+ (binding, _base, start, count) => {
42
+ expect(count).toBe(2);
43
+ binding.write.value[start] = 11;
44
+ binding.write.value[start + 1] = 12;
45
+ },
46
+ undefined,
47
+ ),
48
+ ).toEqual({ ok: true, value: undefined });
49
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
50
+ const values: number[] = [];
51
+ for (const row of world.query({ read: [Value] }).unwrap()) {
52
+ values.push(row.get(Value).value);
53
+ }
54
+ expect(values).toEqual([0, 11, 12, 3]);
55
+
56
+ const graph = world[worldInternal].getGraph();
57
+ const record = world[worldInternal].getRecords()[0];
58
+ const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
59
+ const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
60
+ expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, beforeEpoch + 1, beforeEpoch + 1, 4]));
61
+ });
62
+
63
+ it('does not call the kernel or consume an epoch for a zero-length range', () => {
64
+ const { world, query } = fixture();
65
+ const writer = getDerivedWriter(query, Value).unwrap();
66
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
67
+ let called = false;
68
+ expect(
69
+ writer.writeRange(
70
+ 0,
71
+ 0,
72
+ 2,
73
+ 0,
74
+ () => {
75
+ called = true;
76
+ },
77
+ undefined,
78
+ ),
79
+ ).toEqual({ ok: true, value: undefined });
80
+ expect(called).toBe(false);
81
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
82
+ });
83
+
84
+ it('probes each row once and commits only the exact changed runs', () => {
85
+ const { world, query } = fixture();
86
+ const writer = getDerivedWriter(query, Value).unwrap();
87
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
88
+ let probes = 0;
89
+ let commits = 0;
90
+ const result = writer.probeAndCommitRange(
91
+ 0,
92
+ 0,
93
+ 0,
94
+ 4,
95
+ (_binding, row) => {
96
+ probes += 1;
97
+ return row === 1 || row === 3;
98
+ },
99
+ (binding, row) => {
100
+ commits += 1;
101
+ binding.write.value[row] = row + 10;
102
+ },
103
+ undefined,
104
+ );
105
+ expect(result).toEqual({ ok: true, value: undefined });
106
+ expect(probes).toBe(4);
107
+ expect(commits).toBe(2);
108
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
109
+ const values: number[] = [];
110
+ for (const row of world.query({ read: [Value] }).unwrap()) values.push(row.get(Value).value);
111
+ expect(values).toEqual([0, 11, 2, 13]);
112
+ const graph = world[worldInternal].getGraph();
113
+ const record = world[worldInternal].getRecords()[0];
114
+ const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
115
+ const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
116
+ expect(epochs?.[1]).toBe(beforeEpoch + 1);
117
+ expect(epochs?.[3]).toBe(beforeEpoch + 1);
118
+ });
119
+
120
+ it('poisons the World when a changed-row commit fails after partial output', () => {
121
+ const { world, query } = fixture();
122
+ const writer = getDerivedWriter(query, Value).unwrap();
123
+ const failed = writer.probeAndCommitRange(
124
+ 0,
125
+ 0,
126
+ 0,
127
+ 2,
128
+ () => true,
129
+ (binding, row) => {
130
+ binding.write.value[row] = 31;
131
+ if (row === 1) throw new Error('partial row commit failure');
132
+ },
133
+ undefined,
134
+ );
135
+ expect(failed.ok).toBe(false);
136
+ expect(world.execution.health).toBe('poisoned');
137
+ expect(
138
+ writer.probeAndCommitRange(
139
+ 0,
140
+ 0,
141
+ 0,
142
+ 1,
143
+ () => false,
144
+ () => undefined,
145
+ undefined,
146
+ ).ok,
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('keeps the World healthy when the initial epoch reservation is exhausted', () => {
151
+ const { world, query } = fixture();
152
+ const writer = getDerivedWriter(query, Value).unwrap();
153
+ const rows = query[Symbol.iterator]();
154
+ const first = rows.next();
155
+ const firstEntity = first.done ? undefined : first.value.entity;
156
+ rows.return?.();
157
+ if (firstEntity === undefined) throw new Error('expected a fixture entity');
158
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
159
+ const originalNextEpoch = world[worldInternal].nextMutationEpoch;
160
+ Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
161
+ configurable: true,
162
+ value: () => {
163
+ throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
164
+ },
165
+ });
166
+ try {
167
+ let commits = 0;
168
+ const failed = writer.probeAndCommitRange(
169
+ 0,
170
+ 0,
171
+ 0,
172
+ 2,
173
+ (_binding, row) => row === 0,
174
+ (binding, row) => {
175
+ commits += 1;
176
+ binding.write.value[row] = 10;
177
+ },
178
+ undefined,
179
+ );
180
+ expect(failed.ok).toBe(false);
181
+ if (!failed.ok) expect(failed.error.code).toBe('change-epoch-exhausted');
182
+ expect(commits).toBe(0);
183
+ expect(world.execution.health).toBe('healthy');
184
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
185
+ expect(world.get(firstEntity, Value).unwrap().value).toBe(0);
186
+ const graph = world[worldInternal].getGraph();
187
+ const record = world[worldInternal].getRecords()[0];
188
+ const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
189
+ const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
190
+ expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, 2, 3, 4]));
191
+ } finally {
192
+ Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
193
+ configurable: true,
194
+ value: originalNextEpoch,
195
+ });
196
+ }
197
+
198
+ const retried = writer.probeAndCommitRange(
199
+ 0,
200
+ 0,
201
+ 0,
202
+ 2,
203
+ () => true,
204
+ (binding, row) => {
205
+ binding.write.value[row] = 10;
206
+ },
207
+ undefined,
208
+ );
209
+ expect(retried.ok).toBe(true);
210
+ expect(world.execution.health).toBe('healthy');
211
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch + 1);
212
+ expect(world.get(firstEntity, Value).unwrap().value).toBe(10);
213
+ });
214
+
215
+ it('poisons and restores the epoch when a later sparse probe fails', () => {
216
+ const { world, query } = fixture();
217
+ const writer = getDerivedWriter(query, Value).unwrap();
218
+ const rows = query[Symbol.iterator]();
219
+ const first = rows.next();
220
+ const firstEntity = first.done ? undefined : first.value.entity;
221
+ rows.return?.();
222
+ if (firstEntity === undefined) throw new Error('expected a fixture entity');
223
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
224
+ const failed = writer.probeAndCommitRange(
225
+ 0,
226
+ 0,
227
+ 0,
228
+ 3,
229
+ (_binding, row) => {
230
+ if (row === 2) throw new Error('later sparse probe failure');
231
+ return row === 0;
232
+ },
233
+ (binding, row) => {
234
+ binding.write.value[row] = row + 10;
235
+ },
236
+ undefined,
237
+ );
238
+ expect(failed.ok).toBe(false);
239
+ expect(world.execution.health).toBe('poisoned');
240
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
241
+ expect(world.get(firstEntity, Value).unwrap().value).toBe(10);
242
+ const graph = world[worldInternal].getGraph();
243
+ const record = world[worldInternal].getRecords()[0];
244
+ const table = graph.tables[graph.archetypes[record?.archetypeId ?? -1]?.tableId ?? -1];
245
+ const epochs = table?.storage.get(componentId(Value))?.epochs.changed;
246
+ expect(epochs?.slice(0, 4)).toEqual(new Float64Array([1, 2, 3, 4]));
247
+ expect(
248
+ writer.probeAndCommitRange(
249
+ 0,
250
+ 0,
251
+ 0,
252
+ 1,
253
+ () => false,
254
+ () => undefined,
255
+ undefined,
256
+ ).ok,
257
+ ).toBe(false);
258
+ });
259
+
260
+ it('rejects an out-of-bounds range before the kernel and permits same-cursor retry', () => {
261
+ const { world, query } = fixture();
262
+ const writer = getDerivedWriter(query, Value).unwrap();
263
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
264
+ const failed = writer.writeRange(
265
+ 0,
266
+ 0,
267
+ 3,
268
+ 2,
269
+ () => {
270
+ throw new Error('kernel must not run');
271
+ },
272
+ undefined,
273
+ );
274
+ expect(failed.ok).toBe(false);
275
+ if (!failed.ok && failed.error.code === 'derived-range-out-of-bounds') {
276
+ expect(failed.error.code).toBe('derived-range-out-of-bounds');
277
+ expect(failed.error.expected).toContain('span-relative range');
278
+ expect(failed.error.hint).toContain('start');
279
+ expect(failed.error.detail).toMatchObject({ start: 3, count: 2 });
280
+ }
281
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
282
+ expect(
283
+ writer.writeRange(
284
+ 0,
285
+ 0,
286
+ 2,
287
+ 1,
288
+ (binding, _base, start) => {
289
+ binding.write.value[start] = 22;
290
+ },
291
+ undefined,
292
+ ).ok,
293
+ ).toBe(true);
294
+ });
295
+
296
+ it('keeps state on epoch exhaustion and poisons after a partial kernel write', () => {
297
+ const { world, query } = fixture();
298
+ const writer = getDerivedWriter(query, Value).unwrap();
299
+ const beforeEpoch = world[worldInternal].getMutationEpoch();
300
+ const originalNextEpoch = world[worldInternal].nextMutationEpoch;
301
+ Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
302
+ configurable: true,
303
+ value: () => {
304
+ throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
305
+ },
306
+ });
307
+ const exhausted = writer.writeRange(
308
+ 0,
309
+ 0,
310
+ 0,
311
+ 1,
312
+ () => {
313
+ throw new Error('kernel must not run');
314
+ },
315
+ undefined,
316
+ );
317
+ expect(exhausted.ok).toBe(false);
318
+ expect(world[worldInternal].getMutationEpoch()).toBe(beforeEpoch);
319
+ Object.defineProperty(world[worldInternal], 'nextMutationEpoch', {
320
+ configurable: true,
321
+ value: originalNextEpoch,
322
+ });
323
+
324
+ const partial = writer.writeRange(
325
+ 0,
326
+ 0,
327
+ 0,
328
+ 2,
329
+ (binding) => {
330
+ binding.write.value[0] = 31;
331
+ throw new Error('partial kernel failure');
332
+ },
333
+ undefined,
334
+ );
335
+ expect(partial.ok).toBe(false);
336
+ expect(world.execution.health).toBe('poisoned');
337
+ expect(writer.writeRange(0, 0, 0, 1, () => undefined, undefined).ok).toBe(false);
338
+ });
339
+ });
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
  import { err, ok, type Result } from '@forgeax/engine-types';
24
24
  import { describe, expect, expectTypeOf, it, test } from 'vitest';
25
25
  import { componentSchema, defineComponent } from '../component';
26
- import type { EcsErrorCode } from '../errors';
26
+ import type { EcsErrorCode, EcsErrorDetail } from '../errors';
27
27
  import {
28
28
  ComponentAlreadyPresentError,
29
29
  ComponentNotPresentError,
@@ -977,25 +977,41 @@ import { World } from '../world';
977
977
  expect(code).toBe('spawn-light-invalid-bounds');
978
978
  });
979
979
 
980
- it("EcsErrorDetail discriminated variant for 'spawn-light-invalid-bounds' carries field three-branch", () => {
981
- const range = {
980
+ it("EcsErrorDetail discriminated variant for 'spawn-light-invalid-bounds' carries scalar and RGB fields", () => {
981
+ type InvalidLightBoundsDetail = Extract<
982
+ EcsErrorDetail,
983
+ { readonly code: 'spawn-light-invalid-bounds' }
984
+ >;
985
+ const range: InvalidLightBoundsDetail = {
982
986
  code: 'spawn-light-invalid-bounds',
983
987
  field: 'range',
984
988
  got: -1,
985
989
  };
986
- const innerOuter = {
990
+ const innerOuter: InvalidLightBoundsDetail = {
987
991
  code: 'spawn-light-invalid-bounds',
988
992
  field: 'innerOuter',
989
993
  got: 25,
990
994
  };
991
- const outerNinety = {
995
+ const outerNinety: InvalidLightBoundsDetail = {
992
996
  code: 'spawn-light-invalid-bounds',
993
997
  field: 'outerNinety',
994
998
  got: 91,
995
999
  };
1000
+ const direction: InvalidLightBoundsDetail = {
1001
+ code: 'spawn-light-invalid-bounds',
1002
+ field: 'direction',
1003
+ got: [Number.NaN, 0, 0],
1004
+ };
1005
+ const irradiance: InvalidLightBoundsDetail = {
1006
+ code: 'spawn-light-invalid-bounds',
1007
+ field: 'irradiance',
1008
+ got: Array.from(new Float32Array(27)),
1009
+ };
996
1010
  expect(range.code).toBe('spawn-light-invalid-bounds');
997
1011
  expect(innerOuter.code).toBe('spawn-light-invalid-bounds');
998
1012
  expect(outerNinety.code).toBe('spawn-light-invalid-bounds');
1013
+ expect(direction.field).toBe('direction');
1014
+ expect(irradiance.field).toBe('irradiance');
999
1015
  });
1000
1016
 
1001
1017
  it("type-level: switch over EcsErrorCode includes 'spawn-light-invalid-bounds' branch", () => {
@@ -13,7 +13,7 @@ const lease = createRenderReadLease(world);
13
13
  const request: RenderProjectionRequest = {
14
14
  components: [{ component: Position, fields: ['x'] }],
15
15
  };
16
- const batch = lease.readChanges(lease.inspectCursor());
16
+ const batch = lease.readChanges(lease.captureVersion());
17
17
  const projection = lease.querySpans(request);
18
18
 
19
19
  expectTypeOf(lease.worldIdentity).toEqualTypeOf<string>();
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { defineComponent } from '../component';
2
+ import { componentId, defineComponent } from '../component';
3
3
  import { createRenderReadLease, type RenderProjectionRequest } from '../projection/index';
4
4
  import { World } from '../world';
5
5
 
@@ -27,36 +27,36 @@ describe('RenderReadLease contract', () => {
27
27
  expect(projection.spans[0]?.fields.y).toEqual(new Float32Array([2]));
28
28
  });
29
29
 
30
- it('publishes world and shared-ref changes through one cursor contract', () => {
30
+ it('publishes world and shared-ref changes from an explicit consumer version', () => {
31
31
  const world = new World();
32
- const lease = createRenderReadLease(world);
33
- const cursor = lease.inspectCursor();
34
32
  const entity = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
33
+ const lease = createRenderReadLease(world);
34
+ const version = lease.captureVersion();
35
35
  world.set(entity, Position, { x: 3 }).unwrap();
36
- const batch = lease.readChanges(cursor);
36
+ const batch = lease.readChanges(version);
37
37
 
38
38
  expect(batch.status).toBe('ok');
39
39
  if (batch.status !== 'ok') return;
40
- expect(batch.cursor).toBeGreaterThan(cursor);
41
- expect(batch.world.records.some((record) => record.kind === 'component-changed')).toBe(true);
42
- expect(batch.sharedRefs.status).toBe('ok');
40
+ expect(batch.version.mutationEpoch).toBeGreaterThan(version.mutationEpoch);
41
+ expect(batch.world.changedComponentIds).toContain(componentId(Position));
42
+ expect(batch.sharedRefs.records).toEqual([]);
43
43
  });
44
44
 
45
- it('reports overflow and requires an explicit resync', () => {
45
+ it('requires an explicit rebuild after structure changes', () => {
46
46
  const world = new World();
47
47
  const lease = createRenderReadLease(world);
48
- const staleCursor = 0;
48
+ const version = lease.captureVersion();
49
49
 
50
50
  for (let index = 0; index < 65537; index += 1) {
51
51
  const entity = world.spawn({ component: Position, data: { x: index, y: 0 } }).unwrap();
52
52
  world.despawn(entity).unwrap();
53
53
  }
54
54
 
55
- const batch = lease.readChanges(staleCursor);
56
- expect(batch.status).toBe('overflow');
57
- if (batch.status !== 'overflow') return;
55
+ const batch = lease.readChanges(version);
56
+ expect(batch.status).toBe('rebuild');
57
+ if (batch.status !== 'rebuild') return;
58
58
  expect(batch.resync).toBe(true);
59
- expect(batch.oldestAvailable).toBeGreaterThan(staleCursor);
59
+ expect(batch.reason).toBe('structure-changed');
60
60
  });
61
61
 
62
62
  it('detaches idempotently and rejects reads after dispose', () => {
@@ -64,7 +64,7 @@ describe('RenderReadLease contract', () => {
64
64
  lease.dispose();
65
65
  lease.dispose();
66
66
 
67
- expect(() => lease.inspectCursor()).toThrow(/disposed/i);
67
+ expect(() => lease.captureVersion()).toThrow(/disposed/i);
68
68
  expect(() => lease.querySpans(request)).toThrow(/disposed/i);
69
69
  });
70
70
 
@@ -4,6 +4,7 @@ import { World } from '../world';
4
4
 
5
5
  const ENTITY_COUNT = 2_000;
6
6
  const ROUNDS = 3;
7
+ const SCALE_CHARACTERIZATION_COUNTS = [10_000, 100_000] as const;
7
8
 
8
9
  const TrendValue = defineComponent('StorageTrendValue', { value: 'f32' });
9
10
  const TrendSelected = defineComponent('StorageTrendSelected', {}, { storage: 'sparse' });
@@ -20,6 +21,24 @@ function minimumDuration(run: () => number): { duration: number; sum: number } {
20
21
  }
21
22
 
22
23
  describe('table, sparse, span, and change-distribution trends', () => {
24
+ it('characterizes fixed-size dense traversal without treating it as a performance verdict', () => {
25
+ const observations = SCALE_CHARACTERIZATION_COUNTS.map((entityCount) => {
26
+ const values = new Float32Array(entityCount);
27
+ for (let index = 0; index < entityCount; index++) values[index] = index + 1;
28
+ const start = performance.now();
29
+ let sum = 0;
30
+ for (const value of values) sum += value;
31
+ return { entityCount, elapsedMs: performance.now() - start, sum };
32
+ });
33
+
34
+ expect(observations.map(({ sum }) => sum)).toEqual(
35
+ SCALE_CHARACTERIZATION_COUNTS.map((entityCount) => (entityCount * (entityCount + 1)) / 2),
36
+ );
37
+
38
+ // biome-ignore lint/suspicious/noConsole: characterization output is evidence, not a gate
39
+ console.info(`[scale characterization] ${JSON.stringify(observations)}`);
40
+ });
41
+
23
42
  it('reports equivalent table-row, sparse-row, and dense-span traversal', () => {
24
43
  const world = new World();
25
44
  for (let index = 0; index < ENTITY_COUNT; index++) {
@@ -138,7 +138,7 @@ describe('executable Query', () => {
138
138
  expect(spans[0]?.get(Position).x[0]).toBe(1);
139
139
  });
140
140
 
141
- it('returns structured span capability failures', () => {
141
+ it('returns structured span capability failures and coalesces changed rows', () => {
142
142
  const world = new World();
143
143
  const optional = world
144
144
  .query({ read: [Position], optional: [Velocity] })
@@ -146,11 +146,15 @@ describe('executable Query', () => {
146
146
  .spans();
147
147
  expect(!optional.ok && optional.error.detail.reason).toBe('optional-data');
148
148
 
149
- const changed = world
150
- .query({ read: [Position], changed: [Position] })
151
- .unwrap()
152
- .spans();
153
- expect(!changed.ok && changed.error.detail.reason).toBe('row-change-filter');
149
+ const { world: changedWorld, first, second } = createWorld();
150
+ const changedQuery = changedWorld.query({ read: [Position], changed: [Position] }).unwrap();
151
+ expect([...changedQuery.spans().unwrap()].flatMap((span) => Array.from(span.entities))).toEqual(
152
+ [first, second],
153
+ );
154
+ changedWorld.set(second, Position, { x: 9 }).unwrap();
155
+ const changedSpans = [...changedQuery.spans().unwrap()];
156
+ expect(changedSpans).toHaveLength(1);
157
+ expect(Array.from(changedSpans[0]?.entities ?? [])).toEqual([second]);
154
158
  });
155
159
 
156
160
  it('does not commit observation after partial iteration', () => {
@@ -70,7 +70,6 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
70
70
  expect(store.markChanged(handle).ok).toBe(true);
71
71
  expect(store.getMutationEpoch()).toBe(1);
72
72
  expect(store.readChangesSince(0)).toEqual({
73
- status: 'ok',
74
73
  cursor: 1,
75
74
  records: [{ epoch: 1, handle: unwrapHandle(handle) }],
76
75
  });
@@ -80,21 +79,17 @@ describe('w6 SharedRefStore: alloc + resolve', () => {
80
79
  expect(store.getMutationEpoch()).toBe(1);
81
80
  });
82
81
 
83
- it('reports bounded-journal overflow instead of hiding changed handles', () => {
82
+ it('keeps one latest mutation epoch per handle without a bounded journal', () => {
84
83
  const store = new SharedRefStore();
85
84
  const handle = store.alloc('MaterialAsset', { value: 1 });
86
85
  for (let index = 0; index < 4097; index += 1) store.markChanged(handle).unwrap();
87
86
 
88
87
  expect(store.readChangesSince(0)).toEqual({
89
- status: 'overflow',
90
88
  cursor: 4097,
91
- oldestAvailable: 2,
89
+ records: [{ epoch: 4097, handle: unwrapHandle(handle) }],
92
90
  });
93
91
  const latest = store.readChangesSince(4096);
94
- expect(latest.status).toBe('ok');
95
- if (latest.status === 'ok') {
96
- expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
97
- }
92
+ expect(latest.records).toEqual([{ epoch: 4097, handle: unwrapHandle(handle) }]);
98
93
  });
99
94
 
100
95
  it('resolve returns SharedRefStaleError after rc drops to 0 (gen incremented on release)', () => {
@@ -0,0 +1,6 @@
1
+ import type { StructuralEvidence, StructuralEvidenceKind } from '../storage/structural-evidence';
2
+
3
+ const kind: StructuralEvidenceKind = 'component-added';
4
+ const evidence: StructuralEvidence = { sequence: 1, kind, entity: 0 as never, componentId: 4 };
5
+ // @ts-expect-error evidence sequence is producer-owned.
6
+ evidence.sequence = 2;
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { encodeEntity } from '../entity-handle';
3
+ import { StructuralEvidenceRing } from '../storage/structural-evidence';
4
+
5
+ describe('typed structural evidence ring', () => {
6
+ it('records lifecycle, component membership, packed identity, and sequence facts', () => {
7
+ const ring = new StructuralEvidenceRing(8);
8
+ const entity = encodeEntity(4, 2);
9
+ const cursor = ring.cursor;
10
+ ring.append({ kind: 'spawn', entity });
11
+ ring.append({ kind: 'component-added', entity, componentId: 7 });
12
+ ring.append({ kind: 'component-removed', entity, componentId: 7 });
13
+ ring.append({ kind: 'despawn', entity });
14
+ const read = ring.readAfter(cursor);
15
+ expect(read.status).toBe('ok');
16
+ if (read.status !== 'ok') return;
17
+ expect(
18
+ read.events.map(({ kind, entity: packed, sequence }) => ({ kind, packed, sequence })),
19
+ ).toEqual([
20
+ { kind: 'spawn', packed: entity, sequence: 1 },
21
+ { kind: 'component-added', packed: entity, sequence: 2 },
22
+ { kind: 'component-removed', packed: entity, sequence: 3 },
23
+ { kind: 'despawn', packed: entity, sequence: 4 },
24
+ ]);
25
+ });
26
+
27
+ it('fails closed on overflow and exposes the cursor for consumer reconciliation', () => {
28
+ const ring = new StructuralEvidenceRing(2);
29
+ const cursor = ring.cursor;
30
+ const entity = encodeEntity(1, 4);
31
+ const reused = encodeEntity(1, 5);
32
+ ring.append({ kind: 'spawn', entity });
33
+ ring.append({ kind: 'despawn', entity });
34
+ ring.append({ kind: 'spawn', entity: reused });
35
+ expect(ring.readAfter(cursor)).toMatchObject({
36
+ status: 'overflow',
37
+ cursor: 3,
38
+ oldestAvailable: 2,
39
+ });
40
+ expect(ring.cursor).toBe(3);
41
+ });
42
+
43
+ it('rejects cursors outside the producer-owned sequence range', () => {
44
+ const ring = new StructuralEvidenceRing(4);
45
+ const entity = encodeEntity(3, 1);
46
+ ring.append({ kind: 'spawn', entity });
47
+ expect(() => ring.readAfter(2)).toThrow(RangeError);
48
+ });
49
+ });