@forgeax/engine-ecs 0.1.32 → 0.1.34

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 (62) hide show
  1. package/README.md +35 -18
  2. package/dist/__tests__/query-idle.unit.test.d.ts +2 -0
  3. package/dist/__tests__/query-idle.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/set-allocation.unit.test.d.ts +2 -0
  5. package/dist/__tests__/set-allocation.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/state-projection.unit.test.d.ts +2 -0
  7. package/dist/__tests__/state-projection.unit.test.d.ts.map +1 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +579 -623
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/projection/index.d.ts +2 -18
  13. package/dist/projection/index.d.ts.map +1 -1
  14. package/dist/projection/index.mjs +433 -23
  15. package/dist/projection/index.mjs.map +1 -1
  16. package/dist/projection/state-projection.d.ts +37 -0
  17. package/dist/projection/state-projection.d.ts.map +1 -0
  18. package/dist/query/query.d.ts.map +1 -1
  19. package/dist/shared-ref-store.d.ts +0 -24
  20. package/dist/shared-ref-store.d.ts.map +1 -1
  21. package/dist/shared.mjs.map +1 -1
  22. package/dist/storage/archetype-graph.d.ts +2 -0
  23. package/dist/storage/archetype-graph.d.ts.map +1 -1
  24. package/dist/storage/change-detection.d.ts +4 -0
  25. package/dist/storage/change-detection.d.ts.map +1 -1
  26. package/dist/storage/table.d.ts +6 -3
  27. package/dist/storage/table.d.ts.map +1 -1
  28. package/dist/world-internal.d.ts +0 -2
  29. package/dist/world-internal.d.ts.map +1 -1
  30. package/dist/world.d.ts +0 -2
  31. package/dist/world.d.ts.map +1 -1
  32. package/package.json +4 -4
  33. package/src/__tests__/component-version-surface.test.ts +1 -7
  34. package/src/__tests__/derived-range-writer.contract.test.ts +6 -0
  35. package/src/__tests__/ecs-core-reduction.characterization.test.ts +2 -3
  36. package/src/__tests__/execution-conflict-boundary.unit.test.ts +4 -0
  37. package/src/__tests__/externalization-render-read-lease.unit.test.ts +4 -10
  38. package/src/__tests__/query-idle.unit.test.ts +19 -0
  39. package/src/__tests__/set-allocation.unit.test.ts +30 -0
  40. package/src/__tests__/shared-ref-lifetime.unit.test.ts +1 -2
  41. package/src/__tests__/shared-ref-store.unit.test.ts +2 -34
  42. package/src/__tests__/state-projection.unit.test.ts +159 -0
  43. package/src/__tests__/world-health.contract.test.ts +0 -23
  44. package/src/index.ts +1 -1
  45. package/src/projection/index.ts +9 -39
  46. package/src/projection/state-projection.ts +250 -0
  47. package/src/query/query.ts +23 -16
  48. package/src/shared-ref-store.ts +0 -70
  49. package/src/storage/archetype-graph.ts +15 -1
  50. package/src/storage/change-detection.ts +36 -4
  51. package/src/storage/table.ts +20 -1
  52. package/src/world-internal.ts +0 -2
  53. package/src/world.ts +35 -38
  54. package/dist/__tests__/structural-evidence.contract.test-d.d.ts +0 -2
  55. package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +0 -1
  56. package/dist/__tests__/structural-evidence.contract.test.d.ts +0 -2
  57. package/dist/__tests__/structural-evidence.contract.test.d.ts.map +0 -1
  58. package/dist/storage/structural-evidence.d.ts +0 -30
  59. package/dist/storage/structural-evidence.d.ts.map +0 -1
  60. package/src/__tests__/structural-evidence.contract.test-d.ts +0 -6
  61. package/src/__tests__/structural-evidence.contract.test.ts +0 -49
  62. package/src/storage/structural-evidence.ts +0 -64
@@ -28,7 +28,6 @@ describe('World execution health', () => {
28
28
  const injected = new ComponentNotPresentError(target as number, 'InjectedRelationshipFailure');
29
29
  const mutationEpoch = world[worldInternal].getMutationEpoch();
30
30
  const structureEpoch = world.getStructureEpoch();
31
- const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
32
31
  const onInsert = vi.spyOn(
33
32
  world as unknown as {
34
33
  relationshipOnInsert: (...args: never[]) => unknown;
@@ -45,7 +44,6 @@ describe('World execution health', () => {
45
44
  expect(world.execution.fault?.partialWrite).toBe(true);
46
45
  expect(world[worldInternal].getMutationEpoch()).toBe(mutationEpoch);
47
46
  expect(world.getStructureEpoch()).toBe(structureEpoch);
48
- expect(world[worldInternal].getStructuralEvidence().cursor).toBe(evidenceCursor);
49
47
  const rejected = world.spawn();
50
48
  expect(rejected.ok).toBe(false);
51
49
  if (!rejected.ok) expect(rejected.error.code).toBe('world-poisoned');
@@ -89,7 +87,6 @@ describe('World execution health', () => {
89
87
  });
90
88
  const pending = buffer.spawn({ component: WorldHealthChildOf, data: { parent: target } });
91
89
  const mutationEpoch = world[worldInternal].getMutationEpoch();
92
- const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
93
90
 
94
91
  expect(() => flushCommands(buffer, world)).toThrow();
95
92
  expect(buffer.status).toBe('aborted');
@@ -97,7 +94,6 @@ describe('World execution health', () => {
97
94
  expect(world.execution.fault?.partialWrite).toBe(true);
98
95
  expect(world.hasComponent(pending, WorldHealthChildOf)).toBe(true);
99
96
  expect(world[worldInternal].getMutationEpoch()).toBe(mutationEpoch);
100
- expect(world[worldInternal].getStructuralEvidence().cursor).toBe(evidenceCursor);
101
97
  const rejected = world.spawn();
102
98
  expect(rejected.ok).toBe(false);
103
99
  if (!rejected.ok) expect(rejected.error.code).toBe('world-poisoned');
@@ -115,7 +111,6 @@ describe('World execution health', () => {
115
111
  .unwrap();
116
112
  const mutationEpoch = world[worldInternal].getMutationEpoch();
117
113
  const structureEpoch = world.getStructureEpoch();
118
- const evidenceCursor = world[worldInternal].getStructuralEvidence().cursor;
119
114
 
120
115
  world.addComponent(child, { component: WorldHealthChildOf, data: { parent } }).unwrap();
121
116
 
@@ -131,24 +126,6 @@ describe('World execution health', () => {
131
126
  expect(targetChange?.added).toBeGreaterThan(0);
132
127
  expect(world[worldInternal].getMutationEpoch()).toBeGreaterThan(mutationEpoch);
133
128
  expect(world.getStructureEpoch()).toBeGreaterThan(structureEpoch);
134
- const evidence = world[worldInternal].getStructuralEvidence().readAfter(evidenceCursor);
135
- expect(evidence.status).toBe('ok');
136
- if (evidence.status === 'ok') {
137
- expect(evidence.events).toEqual(
138
- expect.arrayContaining([
139
- expect.objectContaining({
140
- kind: 'component-added',
141
- entity: parent,
142
- componentId: componentId(WorldHealthChildren),
143
- }),
144
- expect.objectContaining({
145
- kind: 'component-added',
146
- entity: child,
147
- componentId: componentId(WorldHealthChildOf),
148
- }),
149
- ]),
150
- );
151
- }
152
129
  });
153
130
 
154
131
  it('publishes target mirror evidence through addChild', () => {
package/src/index.ts CHANGED
@@ -244,7 +244,7 @@ export type {
244
244
  * // the SharedRefStore publishes release evidence at rc=0.
245
245
  * ```
246
246
  */
247
- export type { SharedRefReleaseEvidence, SharedRefReleaseRecord } from './shared-ref-store.js';
247
+ export type { SharedRefReleaseEvidence } from './shared-ref-store.js';
248
248
 
249
249
  /**
250
250
  * ECS-managed handle store (M1). Owns the lifecycle of every
@@ -2,8 +2,6 @@ import type { Result } from '@forgeax/engine-types';
2
2
  import type { Component } from '../component';
3
3
  import { componentId } from '../component';
4
4
  import type { EntityHandle } from '../entity-handle';
5
- import type { SharedRefMutationRead } from '../shared-ref-store';
6
- import type { StructuralEvidenceRead } from '../storage/structural-evidence';
7
5
  import type { EcsError, World } from '../world';
8
6
  import { worldInternal } from '../world-internal';
9
7
 
@@ -24,11 +22,6 @@ export {
24
22
  StaleEntityError,
25
23
  } from '../errors';
26
24
 
27
- /** Read producer-owned typed structural facts without inferring from a World scan. */
28
- export function readStructuralEvidence(world: World, cursor: number): StructuralEvidenceRead {
29
- return world[worldInternal].getStructuralEvidence().readAfter(cursor) as StructuralEvidenceRead;
30
- }
31
-
32
25
  export interface RenderProjectionComponentRequest {
33
26
  readonly component: Component;
34
27
  readonly fields: readonly string[];
@@ -45,24 +38,12 @@ export interface RenderProjectionSpan {
45
38
 
46
39
  export interface RenderProjectionSpans {
47
40
  readonly generation: number;
48
- readonly sharedRefEpoch: number;
49
41
  readonly spans: readonly RenderProjectionSpan[];
50
42
  }
51
43
 
52
- export interface RenderChangeBatchOk {
53
- readonly status: 'ok';
54
- readonly version: RenderReadVersion;
55
- readonly world: RenderWorldChanges;
56
- readonly sharedRefs: SharedRefMutationRead;
57
- }
58
-
59
- export interface RenderChangeBatchRebuild {
60
- readonly status: 'rebuild';
44
+ export interface RenderChangeBatch {
61
45
  readonly version: RenderReadVersion;
62
- readonly resync: true;
63
- readonly reason: 'structure-changed';
64
46
  readonly world: RenderWorldChanges;
65
- readonly sharedRefs: SharedRefMutationRead;
66
47
  }
67
48
 
68
49
  export interface RenderWorldChanges {
@@ -71,8 +52,6 @@ export interface RenderWorldChanges {
71
52
  readonly changedComponentIds: readonly number[];
72
53
  }
73
54
 
74
- export type RenderChangeBatch = RenderChangeBatchOk | RenderChangeBatchRebuild;
75
-
76
55
  export interface RenderReadLease {
77
56
  readonly worldIdentity: string;
78
57
  readonly generation: number;
@@ -101,7 +80,6 @@ export function readRenderArrayView(
101
80
  export interface RenderReadVersion {
102
81
  readonly mutationEpoch: number;
103
82
  readonly structureEpoch: number;
104
- readonly sharedRefEpoch: number;
105
83
  }
106
84
 
107
85
  function readProjectionSpans(
@@ -133,7 +111,6 @@ function readProjectionSpans(
133
111
  }
134
112
  return {
135
113
  generation,
136
- sharedRefEpoch: world[worldInternal].getSharedRefs().getMutationEpoch(),
137
114
  spans: Object.freeze(spans),
138
115
  };
139
116
  }
@@ -141,7 +118,6 @@ function readProjectionSpans(
141
118
  /** Create the render-owned lease from the ECS projection boundary. */
142
119
  export function createRenderReadLease(world: World, token: object = {}): RenderReadLease {
143
120
  void token;
144
- const sharedRefs = world[worldInternal].getSharedRefs();
145
121
  let disposed = false;
146
122
 
147
123
  const assertLive = (): void => {
@@ -152,7 +128,6 @@ export function createRenderReadLease(world: World, token: object = {}): RenderR
152
128
  return {
153
129
  mutationEpoch: world[worldInternal].getMutationEpoch(),
154
130
  structureEpoch: world[worldInternal].getStructureEpoch(),
155
- sharedRefEpoch: sharedRefs.getMutationEpoch(),
156
131
  };
157
132
  };
158
133
 
@@ -181,20 +156,8 @@ export function createRenderReadLease(world: World, token: object = {}): RenderR
181
156
  toEpoch,
182
157
  changedComponentIds,
183
158
  };
184
- const sharedRead = sharedRefs.readChangesSince(start.sharedRefEpoch);
185
159
  const version = captureVersion();
186
- const structureChanged = start.structureEpoch !== world[worldInternal].getStructureEpoch();
187
- if (structureChanged) {
188
- return {
189
- status: 'rebuild',
190
- version,
191
- resync: true,
192
- reason: 'structure-changed',
193
- world: worldRead,
194
- sharedRefs: sharedRead,
195
- };
196
- }
197
- return { status: 'ok', version, world: worldRead, sharedRefs: sharedRead };
160
+ return { version, world: worldRead };
198
161
  },
199
162
  querySpans(request: RenderProjectionRequest): RenderProjectionSpans {
200
163
  assertLive();
@@ -235,3 +198,10 @@ export function routeWorldError(
235
198
  ): void {
236
199
  world[worldInternal].routeError(error, context);
237
200
  }
201
+
202
+ export {
203
+ createStateProjection,
204
+ type StateProjection,
205
+ type StateProjectionBatch,
206
+ StateProjectionExpiredError,
207
+ } from './state-projection';
@@ -0,0 +1,250 @@
1
+ import type { Component } from '../component';
2
+ import { componentId } from '../component';
3
+ import { Entity } from '../entity';
4
+ import { type EntityHandle, encodeEntity, entityIndex } from '../entity-handle';
5
+ import { WorldPoisonedError } from '../errors';
6
+ import { PROJECTION_BLOCK_SIZE } from '../storage/change-detection';
7
+ import type { Table } from '../storage/table';
8
+ import type { World } from '../world';
9
+ import { worldInternal } from '../world-internal';
10
+
11
+ interface BlockBaseline {
12
+ readonly ids: Uint32Array;
13
+ readonly membership: number;
14
+ }
15
+
16
+ export class StateProjectionExpiredError extends Error {
17
+ readonly code = 'state-projection-expired' as const;
18
+ readonly expected = 'an unmodified source and the latest valid projection candidate';
19
+ readonly hint = 'Read and apply the current state again before accepting the candidate.';
20
+
21
+ constructor() {
22
+ super('State projection candidate expired; read and apply the current state again.');
23
+ this.name = 'StateProjectionExpiredError';
24
+ }
25
+ }
26
+
27
+ export interface StateProjectionBatch {
28
+ /** Source indices, deduplicated across migration and generation replacement. */
29
+ readonly indices: readonly number[];
30
+ readonly epoch: number;
31
+ readonly scannedRows: number;
32
+ readonly checkedBlocks: number;
33
+ readonly membershipChanged: boolean;
34
+ readonly changedComponents: readonly Component[];
35
+ /** Commit only after the owning consumer has successfully applied its candidate. */
36
+ validate(): void;
37
+ accept(): void;
38
+ }
39
+
40
+ export interface StateProjection {
41
+ /** Whether the accepted source still matches the live World, without creating a candidate. */
42
+ isCurrent(): boolean;
43
+ read(): StateProjectionBatch;
44
+ /** Resolve the final live generation directly through the World record. */
45
+ entity(index: number): EntityHandle | undefined;
46
+ changed(entity: EntityHandle, component: Component): boolean;
47
+ invalidate(): void;
48
+ }
49
+
50
+ /**
51
+ * Current-state candidate discovery. Blocks remember accepted identities, never
52
+ * structural operations. Reads are synchronous and accepting a candidate does
53
+ * not consume evidence belonging to another projection.
54
+ */
55
+ export function createStateProjection(
56
+ world: World,
57
+ components: readonly Component[],
58
+ candidates: readonly Component[] = components,
59
+ ): StateProjection {
60
+ const owner = world[worldInternal];
61
+ const graph = owner.getGraph();
62
+ const ids = components.map(componentId);
63
+ const candidateIds = candidates.map(componentId);
64
+ const sparseCandidates = candidates.some((component) => component.storage === 'sparse');
65
+ const baseline = new Map<Table, Map<number, BlockBaseline>>();
66
+ let acceptedEpoch = -1;
67
+ let acceptedStructure = -1;
68
+ let invalid = true;
69
+ let readToken = 0;
70
+ let stamp = new Uint32Array(64);
71
+ let serial = 0;
72
+ const work: number[] = [];
73
+
74
+ function enqueue(index: number): void {
75
+ if (index >= stamp.length) {
76
+ let size = stamp.length;
77
+ while (size <= index) size *= 2;
78
+ const next = new Uint32Array(size);
79
+ next.set(stamp);
80
+ stamp = next;
81
+ }
82
+ if (stamp[index] === serial) return;
83
+ stamp[index] = serial;
84
+ work.push(index);
85
+ }
86
+
87
+ return {
88
+ isCurrent() {
89
+ return (
90
+ world.execution.health !== 'poisoned' &&
91
+ !invalid &&
92
+ acceptedEpoch === owner.getMutationEpoch() &&
93
+ acceptedStructure === owner.getStructureEpoch()
94
+ );
95
+ },
96
+ entity(index) {
97
+ const record = owner.getRecords()[index];
98
+ if (record === undefined || record.archetypeId < 0) return undefined;
99
+ return encodeEntity(index, record.generation);
100
+ },
101
+ changed(entity, component) {
102
+ const id = componentId(component);
103
+ if ((owner.getComponentMutationEpochs()[id] ?? 0) <= acceptedEpoch) return false;
104
+ return (owner.getComponentChange(entity, id)?.changed ?? -1) > acceptedEpoch;
105
+ },
106
+ invalidate() {
107
+ readToken++;
108
+ invalid = true;
109
+ },
110
+ read() {
111
+ if (world.execution.health === 'poisoned')
112
+ throw new WorldPoisonedError(world.identity, world.execution.fault);
113
+ const token = ++readToken;
114
+ const epoch = owner.getMutationEpoch();
115
+ const structure = owner.getStructureEpoch();
116
+ const changedComponents = components.filter(
117
+ (component) =>
118
+ (owner.getComponentMutationEpochs()[componentId(component)] ?? 0) > acceptedEpoch,
119
+ );
120
+ const membershipChanged = invalid || structure !== acceptedStructure;
121
+ const changedRoots =
122
+ invalid ||
123
+ structure !== acceptedStructure ||
124
+ ids.some((id) => (owner.getComponentMutationEpochs()[id] ?? 0) > acceptedEpoch);
125
+ work.length = 0;
126
+ serial = (serial + 1) >>> 0;
127
+ if (serial === 0) {
128
+ stamp.fill(0);
129
+ serial = 1;
130
+ }
131
+ let scannedRows = 0;
132
+ let checkedBlocks = 0;
133
+ const updates: { table: Table; block: number; value: BlockBaseline | undefined }[] = [];
134
+ const visited = new Set<Table>();
135
+ if (changedRoots) {
136
+ const tables = new Set<Table>();
137
+ if (sparseCandidates) {
138
+ for (const table of graph.activeTables) tables.add(table);
139
+ } else {
140
+ for (const id of candidateIds)
141
+ for (const table of graph.activeTablesByComponent.get(id) ?? []) tables.add(table);
142
+ }
143
+ for (const table of tables) {
144
+ visited.add(table);
145
+ const prior = baseline.get(table);
146
+ const entities = table.storage.get(componentId(Entity))?.fields.get('self')?.view;
147
+ if (entities === undefined) continue;
148
+ const columns = ids.flatMap((id) => {
149
+ const epochs = table.storage.get(id)?.epochs;
150
+ return epochs === undefined ? [] : [epochs];
151
+ });
152
+ const blocks = Math.ceil(table.size / PROJECTION_BLOCK_SIZE);
153
+ for (let block = 0; block < blocks; block++) {
154
+ checkedBlocks++;
155
+ const previous = prior?.get(block);
156
+ const membership = table.membership[block] ?? 0;
157
+ const start = block * PROJECTION_BLOCK_SIZE;
158
+ const end = Math.min(table.size, start + PROJECTION_BLOCK_SIZE);
159
+ if (invalid || previous === undefined || previous.membership !== membership) {
160
+ if (previous !== undefined) {
161
+ for (const index of previous.ids) enqueue(index);
162
+ scannedRows += previous.ids.length;
163
+ }
164
+ const current = new Uint32Array(end - start);
165
+ for (let row = start; row < end; row++) {
166
+ const index = entityIndex(entities[row] as EntityHandle);
167
+ current[row - start] = index;
168
+ enqueue(index);
169
+ }
170
+ scannedRows += end - start;
171
+ updates.push({ table, block, value: { ids: current, membership } });
172
+ } else {
173
+ let changedBlock = false;
174
+ for (const column of columns) {
175
+ if ((column.blocks[block] ?? 0) <= acceptedEpoch) continue;
176
+ changedBlock = true;
177
+ for (let row = start; row < end; row++) {
178
+ if ((column.changed[row] ?? 0) > acceptedEpoch)
179
+ enqueue(entityIndex(entities[row] as EntityHandle));
180
+ }
181
+ }
182
+ if (!changedBlock) continue;
183
+ scannedRows += end - start;
184
+ }
185
+ }
186
+ if (prior !== undefined) {
187
+ for (const [block, previous] of prior) {
188
+ if (block < blocks) continue;
189
+ checkedBlocks++;
190
+ for (const index of previous.ids) enqueue(index);
191
+ scannedRows += previous.ids.length;
192
+ updates.push({ table, block, value: undefined });
193
+ }
194
+ }
195
+ }
196
+ for (const [table, prior] of baseline) {
197
+ if (visited.has(table)) continue;
198
+ for (const [block, previous] of prior) {
199
+ checkedBlocks++;
200
+ for (const index of previous.ids) enqueue(index);
201
+ scannedRows += previous.ids.length;
202
+ updates.push({ table, block, value: undefined });
203
+ }
204
+ }
205
+ }
206
+ let accepted = false;
207
+ const validate = (): void => {
208
+ if (world.execution.health === 'poisoned')
209
+ throw new WorldPoisonedError(world.identity, world.execution.fault);
210
+ if (
211
+ token !== readToken ||
212
+ epoch !== owner.getMutationEpoch() ||
213
+ structure !== owner.getStructureEpoch()
214
+ ) {
215
+ throw new StateProjectionExpiredError();
216
+ }
217
+ };
218
+ return {
219
+ indices: work,
220
+ epoch,
221
+ scannedRows,
222
+ checkedBlocks,
223
+ membershipChanged,
224
+ changedComponents,
225
+ validate,
226
+ accept() {
227
+ if (accepted) return;
228
+ validate();
229
+ for (const update of updates) {
230
+ let blocks = baseline.get(update.table);
231
+ if (update.value === undefined) {
232
+ blocks?.delete(update.block);
233
+ if (blocks?.size === 0) baseline.delete(update.table);
234
+ } else {
235
+ if (blocks === undefined) {
236
+ blocks = new Map();
237
+ baseline.set(update.table, blocks);
238
+ }
239
+ blocks.set(update.block, update.value);
240
+ }
241
+ }
242
+ acceptedEpoch = epoch;
243
+ acceptedStructure = structure;
244
+ invalid = false;
245
+ accepted = true;
246
+ },
247
+ };
248
+ },
249
+ };
250
+ }
@@ -24,7 +24,7 @@ import { DERIVED_WRITER } from '../internal';
24
24
  import { isRelationshipTarget, relationshipRole } from '../relationship-index';
25
25
  import type { Archetype, ArchetypeId } from '../storage/archetype';
26
26
  import type { ArchetypeGraph } from '../storage/archetype-graph';
27
- import { sparseTagIndex } from '../storage/change-detection';
27
+ import { PROJECTION_BLOCK_SIZE, sparseTagIndex } from '../storage/change-detection';
28
28
  import type { FieldView, ManagedColumnReader } from '../storage/column';
29
29
  import type { Table } from '../storage/table';
30
30
  import { type WorldInternal, worldInternal } from '../world-internal';
@@ -206,21 +206,14 @@ class QueryRowFacade<
206
206
  }
207
207
 
208
208
  has(component: R[number] | W[number] | O[number]): boolean {
209
- return (
210
- this.archetype?.components.some(
211
- (candidate) => componentId(candidate) === componentId(component),
212
- ) === true
213
- );
209
+ const id = componentId(component);
210
+ return this.archetype?.components.some((candidate) => componentId(candidate) === id) === true;
214
211
  }
215
212
 
216
213
  get<C extends R[number]>(component: C): ReadonlyRowShape<C>;
217
214
  get<C extends O[number]>(component: C): ReadonlyRowShape<C> | undefined;
218
215
  get(component: Component): Record<string, unknown> | undefined {
219
- if (
220
- !this.archetype?.components.some(
221
- (candidate) => componentId(candidate) === componentId(component),
222
- )
223
- ) {
216
+ if (!this.has(component)) {
224
217
  return undefined;
225
218
  }
226
219
  const result = this.world[worldInternal].getQueryRow(this.entity, component);
@@ -370,8 +363,9 @@ class ExecutableQuery<
370
363
  const structureEpoch = this.world[worldInternal].getStructureEpoch();
371
364
  const upperBound = this.world[worldInternal].getMutationEpoch();
372
365
  const row = new QueryRowFacade<R, W, O>(this.world);
373
- let archetypeIndex = 0;
374
- let tableIndex = 0;
366
+ const unchanged = this.hasUnchangedInput();
367
+ let archetypeIndex = unchanged ? this.matchedArchetypes.length : 0;
368
+ let tableIndex = unchanged ? this.matchedTables.length : 0;
375
369
  let rowIndex = 0;
376
370
  let finished = false;
377
371
 
@@ -446,7 +440,7 @@ class ExecutableQuery<
446
440
  at(entity: EntityHandle): QueryRow<R, W, O> | undefined {
447
441
  const archetype = this.world[worldInternal].getEntityArchetype(entity);
448
442
  if (archetype === undefined || !this.archetypeMatches(archetype)) return undefined;
449
- return new QueryRowFacade<R, W, O>(this.world).bind(entity, archetype).snapshot();
443
+ return new QueryRowFacade<R, W, O>(this.world).bind(entity, archetype);
450
444
  }
451
445
 
452
446
  spans(): Result<Iterable<QuerySpan<R, W>>, QuerySpanUnavailableError> {
@@ -459,7 +453,8 @@ class ExecutableQuery<
459
453
  query.refreshMatches();
460
454
  const structureEpoch = query.world[worldInternal].getStructureEpoch();
461
455
  const upperBound = query.world[worldInternal].getMutationEpoch();
462
- let tableIndex = 0;
456
+ const filterIds = [...query.compiled.changedIds, ...query.compiled.addedIds];
457
+ let tableIndex = query.hasUnchangedInput() ? query.matchedTables.length : 0;
463
458
  let rowIndex = 0;
464
459
  let finished = false;
465
460
  const close = (commit: boolean): void => {
@@ -498,7 +493,12 @@ class ExecutableQuery<
498
493
  rowIndex < table.size &&
499
494
  !query.denseChangeMatches(table, rowIndex, upperBound)
500
495
  ) {
501
- rowIndex += 1;
496
+ const block = Math.floor(rowIndex / PROJECTION_BLOCK_SIZE);
497
+ const unchanged = filterIds.some(
498
+ (id) =>
499
+ (table.storage.get(id)?.epochs.blocks[block] ?? 0) <= query.lastObservedEpoch,
500
+ );
501
+ rowIndex = unchanged ? (block + 1) * PROJECTION_BLOCK_SIZE : rowIndex + 1;
502
502
  }
503
503
  if (rowIndex >= table.size) {
504
504
  tableIndex += 1;
@@ -594,6 +594,13 @@ class ExecutableQuery<
594
594
  };
595
595
  }
596
596
 
597
+ private hasUnchangedInput(): boolean {
598
+ const epochs = this.world[worldInternal].getComponentMutationEpochs();
599
+ // Changed predicates are conjunctive. One unchanged input proves the
600
+ // entire result empty without consulting any entity or table row.
601
+ return this.compiled.changedIds.some((id) => (epochs[id] ?? 0) <= this.lastObservedEpoch);
602
+ }
603
+
597
604
  private beginIteration(): void {
598
605
  if (this.active) throw new QueryIterationActiveError();
599
606
  this.active = true;
@@ -52,7 +52,6 @@
52
52
  //
53
53
  // Release path (D-1 codes):
54
54
  // - resolve(h): err(SharedRefReleasedError) if payload absent.
55
- // - markChanged(h): publish an in-place payload mutation to subscribers.
56
55
  // - retain(h): err(SharedRefReleasedError) if payload absent.
57
56
  // - release(h): err(SharedRefDoubleReleaseError, rc=0) on rc=0 input.
58
57
  // - any(builtin slot): err(BuiltinSlotNotOwnedError) (D-15).
@@ -84,13 +83,6 @@ import {
84
83
  SharedRefStaleError,
85
84
  } from './errors.js';
86
85
 
87
- const SHARED_REF_RELEASE_EVIDENCE_CAPACITY = 4096;
88
-
89
- export interface SharedRefMutation {
90
- readonly epoch: number;
91
- readonly handle: number;
92
- }
93
-
94
86
  export interface SharedRefReleaseEvidence {
95
87
  readonly payload: unknown;
96
88
  readonly refcount: 0;
@@ -98,16 +90,6 @@ export interface SharedRefReleaseEvidence {
98
90
  readonly evidence: 'released';
99
91
  }
100
92
 
101
- /** Diagnostic history identifies releases without retaining external payloads. */
102
- export type SharedRefReleaseRecord = Omit<SharedRefReleaseEvidence, 'payload'> & {
103
- readonly handle: number;
104
- };
105
-
106
- export interface SharedRefMutationRead {
107
- readonly cursor: number;
108
- readonly records: readonly SharedRefMutation[];
109
- }
110
-
111
93
  // MAX_SLOT is now imported from @forgeax/engine-types (codec SSOT, D-1).
112
94
  // The local constant is removed to avoid drift (AC-15).
113
95
 
@@ -127,8 +109,6 @@ export interface SharedRefMutationRead {
127
109
  * - alloc(target, payload) -> Handle<T, 'shared'> (rc=1)
128
110
  * - intern(target, payload) -> stable producer handle per target + object identity
129
111
  * - resolve(handle) -> Result<T, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
130
- * - markChanged(handle) -> Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
131
- * - getMutationEpoch() -> monotonic payload-mutation cursor
132
112
  * - retain(handle) -> Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError>
133
113
  * - release(handle) -> Result<release evidence | undefined, ...>
134
114
  * - refcount(handle) -> number (0 == released; debug + tests)
@@ -144,10 +124,7 @@ export class SharedRefStore {
144
124
  { readonly target: string; readonly payload: object }
145
125
  >();
146
126
  private nextSlot = BUILTIN_BASE;
147
- private mutationEpoch = 0;
148
127
  /** Latest published mutation epoch per live handle; not an event journal. */
149
- private readonly mutationEpochs = new Map<number, number>();
150
- private readonly releaseJournal: SharedRefReleaseRecord[] = [];
151
128
 
152
129
  /**
153
130
  * Generation table indexed by slot (D-6). Each entry tracks the current
@@ -261,36 +238,6 @@ export class SharedRefStore {
261
238
  * projections of shared payload data compare the monotonic epoch and
262
239
  * explicitly refresh instead of rescanning every payload each frame.
263
240
  */
264
- markChanged<Target extends string>(
265
- handle: Handle<Target, 'shared'>,
266
- ): Result<void, SharedRefReleasedError | SharedRefStaleError | BuiltinSlotNotOwnedError> {
267
- const resolved = this.resolve(handle);
268
- if (!resolved.ok) return resolved;
269
- if (this.mutationEpoch >= Number.MAX_SAFE_INTEGER) {
270
- throw new RangeError('SharedRefStore mutation epoch exhausted');
271
- }
272
- this.mutationEpoch += 1;
273
- this.mutationEpochs.set(unwrapHandle(handle), this.mutationEpoch);
274
- return ok(undefined);
275
- }
276
-
277
- /** Current upper bound for explicitly published payload mutations. */
278
- getMutationEpoch(): number {
279
- return this.mutationEpoch;
280
- }
281
-
282
- /** Read each live handle whose latest published mutation is after `cursor`. */
283
- readChangesSince(cursor: number): SharedRefMutationRead {
284
- const records: SharedRefMutation[] = [];
285
- for (const [handle, epoch] of this.mutationEpochs) {
286
- if (epoch > cursor) records.push({ epoch, handle });
287
- }
288
- records.sort((left, right) => left.epoch - right.epoch || left.handle - right.handle);
289
- return {
290
- cursor: this.mutationEpoch,
291
- records,
292
- };
293
- }
294
241
 
295
242
  /**
296
243
  * Increment the refcount of a live shared handle. Returns
@@ -368,7 +315,6 @@ export class SharedRefStore {
368
315
  }
369
316
  this.refcounts.delete(raw);
370
317
  this.payloads.delete(raw);
371
- this.mutationEpochs.delete(raw);
372
318
  // Gen increment + retire (AC-07): bump gen; once it would exceed MAX_GEN
373
319
  // (gen 255 is still usable; the bump to 256 triggers retire) the slot is
374
320
  // permanently retired — NOT pushed to freeSlots. This prevents handle
@@ -385,25 +331,9 @@ export class SharedRefStore {
385
331
  generation,
386
332
  evidence: 'released' as const,
387
333
  });
388
- this.releaseJournal.push(
389
- Object.freeze({
390
- handle: raw,
391
- refcount: 0 as const,
392
- generation,
393
- evidence: 'released' as const,
394
- }),
395
- );
396
- if (this.releaseJournal.length > SHARED_REF_RELEASE_EVIDENCE_CAPACITY) {
397
- this.releaseJournal.shift();
398
- }
399
334
  return ok(evidence);
400
335
  }
401
336
 
402
- /** Read bounded release metadata; payload ownership remains with the caller. */
403
- readReleaseEvidence(): readonly SharedRefReleaseRecord[] {
404
- return this.releaseJournal;
405
- }
406
-
407
337
  /**
408
338
  * Return the current refcount for `handle`. Returns 0 for a released
409
339
  * (or never-allocated) slot. Primarily a debug + tests entry point;