From 03460faf58a9bbff34292f5b3fd2e15b205b32ac Mon Sep 17 00:00:00 2001 From: Srikar Sunchu Date: Mon, 14 Sep 2026 21:48:58 -0700 Subject: [PATCH] Make count, isEmpty, first, entities and for-of honour tick filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Changed/Added/Removed query the scalar reads summed archetype rows and never consulted the RowFilter, so world.query(Changed(Position)).isEmpty answered the structural question while each() answered the filtered one. for-of and entities() had the same gap. Route them through a second RowFilter over the same terms, opened with peek() at the live filter's horizon: it accepts exactly the rows the next each() would visit, in the same order, and never advances lastSeen. That is the property that matters — `if (!q.isEmpty) q.each(…)` must not cost the run its events, and a read inside a running each() must not swap the bound columns from under it, which a shared filter would. Unfiltered queries keep their O(1) answers; a filtered read is the same scan each() already pays. Sorted views answer the reads through their own walk, which owns the filter their each() runs with; ordered views already delegate to the base. README and SPEC §8.3 say which operations consume the window. Fixes #2 Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +- specs/core.md | 2 +- src/core/materialized.ts | 75 +++++++++++++++++++++++ src/core/query.ts | 61 +++++++++++++++++++ src/core/sorted.ts | 19 +++++- src/core/walk.ts | 20 +++++++ tests/accessor.test.ts | 3 +- tests/tick-filters.test.ts | 119 +++++++++++++++++++++++++++++++++++++ 8 files changed, 298 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d99f2d0..6338fdd 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,8 @@ const q = world.query(Position, Velocity); q.count; // number of matching entities q.isEmpty; q.first; // Entity | undefined; world.queryFirst(...) is sugar for this +// On a Changed/Added/Removed query these answer for the filter, and reading them +// does not count as a run: each() afterwards still sees the same entities. for (const e of q) { } // Tier 1: handles, read values through the world q.each((p, v, e) => {}); @@ -512,7 +514,8 @@ world.on('exit', world.query(Position, IsActive), (entity) => {}); **Pull: change ticks.** The world holds a counter that `world.step()` advances. `Changed`, `Added` and `Removed` compare against it, which is a scan of a `Uint32Array` with no calls in it. Each such query remembers its own last-seen tick, so two systems watching the same trait do not consume each -other's events. +other's events. Only `each()` advances it: `count`, `isEmpty`, `first`, `entities()` and `for … of` +answer for the filter without consuming the window, so `if (!q.isEmpty) q.each(…)` sees everything. ```ts world.step(); diff --git a/specs/core.md b/specs/core.md index bab2f63..e820282 100644 --- a/specs/core.md +++ b/specs/core.md @@ -699,7 +699,7 @@ world.query(Added(Velocity)); world.query(Removed(Velocity)); // valid for one tick after removal ``` -Each `Changed`/`Added`/`Removed` query stores its own last-seen tick, so two systems observing the same trait do not steal each other's events. +Each `Changed`/`Added`/`Removed` query stores its own last-seen tick, so two systems observing the same trait do not steal each other's events. Only a run (`each`) advances it. The reads — `count`, `isEmpty`, `first`, `entities()` and `for … of` — answer for the filter as it stands, without consuming the window, so a read before a run never costs the run its events. Ticks are written by `world.set` and by cursor setters. **Direct chunk writes bypass them** — call `chunk.markChanged(trait)` or `world.markChanged(e, trait)`. Both forms update the per-row ticks _and_ the column's `lastWriteTick`. diff --git a/src/core/materialized.ts b/src/core/materialized.ts index ceccabb..06d33b8 100644 --- a/src/core/materialized.ts +++ b/src/core/materialized.ts @@ -32,8 +32,11 @@ export interface View { export class ListWalk { /** `null` when the list is known to hold only matching entities. */ readonly #plan: QueryPlan | null; + readonly #terms: readonly Term[]; readonly #binding: Binding; readonly #filter: RowFilter | null; + /** The non-consuming twin of `#filter` for reads (see `QueryResult`). */ + #probe: RowFilter | null = null; readonly #entities: EntityIndex; readonly #archetypes: readonly Archetype[]; readonly #ticks: Ticks; @@ -41,6 +44,7 @@ export class ListWalk { public constructor(terms: readonly Term[], plan: QueryPlan | null, cache: QueryCache) { this.#plan = plan; + this.#terms = terms; this.#binding = new Binding(terms); this.#filter = RowFilter.of(terms); this.#entities = cache.entities; @@ -53,6 +57,11 @@ export class ListWalk { this.#binding.retrack(trait); } + /** Whether the terms carry a tick filter, i.e. whether the reads must go through the walk. */ + public get filtered(): boolean { + return this.#filter !== null; + } + public each( fn: (...args: any[]) => unknown, entities: Float64Array, @@ -76,10 +85,19 @@ export class ListWalk { } public iterator(entities: Float64Array, n: number, step: number): Iterator { + const filter = this.#filter; + if (filter !== null) { + const hits = this.#scanFiltered(filter, entities, n, step, Infinity); + return (hits as unknown as Iterable)[Symbol.iterator](); + } return new ListIterator(entities, n, step, this.#entities, this.#plan, this.#archetypes); } public count(entities: Float64Array, n: number): number { + const filter = this.#filter; + if (filter !== null) { + return this.#scanFiltered(filter, entities, n, 1, Infinity).length; + } const { generations, archetypes: archetypeIds } = this.#entities; const plan = this.#plan; let total = 0; @@ -97,11 +115,20 @@ export class ListWalk { } public first(entities: Float64Array, n: number, step: number): Entity | undefined { + const filter = this.#filter; + if (filter !== null) { + const hits = this.#scanFiltered(filter, entities, n, step, 1); + return hits.length === 0 ? undefined : (hits[0] as Entity); + } const result = this.iterator(entities, n, step).next(); return result.done ? undefined : result.value; } public collect(entities: Float64Array, n: number, step: number): Float64Array { + const filter = this.#filter; + if (filter !== null) { + return this.#scanFiltered(filter, entities, n, step, Infinity); + } const out = new Float64Array(this.count(entities, n)); const iterator = this.iterator(entities, n, step); let at = 0; @@ -111,6 +138,54 @@ export class ListWalk { return out; } + /** + * The live entities of the list the tick filter accepts right now, in list + * order, stopping after `limit` hits. Reads through the probe, so `each` + * still sees every one of them afterwards (SPEC §8.3). + */ + #scanFiltered( + filter: RowFilter, + entities: Float64Array, + n: number, + step: number, + limit: number, + ): Float64Array { + const probe = (this.#probe ??= RowFilter.of(this.#terms)!); + const hits: number[] = []; + if (!probe.peek(this.#ticks, filter.lastSeen)) { + return new Float64Array(0); + } + const plan = this.#plan; + const { generations, archetypes: archetypeIds, rows } = this.#entities; + const archetypes = this.#archetypes; + let bound: Archetype | null = null; + let bindable = false; + + for (let k = step > 0 ? 0 : n - 1; k >= 0 && k < n && hits.length < limit; k += step) { + const entity = entities[k] as Entity; + const id = entityId(entity); + if (generations[id] !== entityGeneration(entity)) { + continue; + } + const archetype = archetypes[archetypeIds[id]]; + if (archetype !== bound) { + bound = archetype; + bindable = plan === null || plan.test(archetype.mask); + if (bindable) { + probe.bind(archetype); + } + } + if (!bindable) { + continue; + } + const row = rows[id]; + if (probe.accept(entity, row >>> archetype.pageShift, row & archetype.pageMask)) { + hits.push(entity); + } + } + return Float64Array.from(hits); + } + #walk(fn: (...args: any[]) => unknown, entities: Float64Array, n: number, step: number): void { const filter = this.#filter; const ticks = this.#ticks; diff --git a/src/core/query.ts b/src/core/query.ts index 45da9de..d2d9db2 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -273,6 +273,12 @@ export class QueryResult { readonly #key: string; readonly #binding: Binding; readonly #filter: RowFilter | null; + /** + * A second filter over the same terms for the reads that must not consume + * the change window (`count`, `first`, `entities()`, for-of): it is opened at + * the live filter's horizon and never advances it (SPEC §8.3). + */ + #probe: RowFilter | null = null; readonly #ticks: Ticks; readonly #iteration: Iteration; #caps: Uint32Array = new Uint32Array(0); @@ -297,6 +303,10 @@ export class QueryResult { } public get count(): number { + const filter = this.#filter; + if (filter !== null) { + return this.#scanFiltered(filter, Infinity).length; + } const archetypes = this[$archetypes]; let total = 0; for (let i = 0; i < archetypes.length; i++) { @@ -306,6 +316,10 @@ export class QueryResult { } public get isEmpty(): boolean { + const filter = this.#filter; + if (filter !== null) { + return this.#scanFiltered(filter, 1).length === 0; + } const archetypes = this[$archetypes]; for (let i = 0; i < archetypes.length; i++) { if (archetypes[i].rows !== 0) { @@ -316,6 +330,11 @@ export class QueryResult { } public get first(): Entity | undefined { + const filter = this.#filter; + if (filter !== null) { + const hits = this.#scanFiltered(filter, 1); + return hits.length === 0 ? undefined : (hits[0] as Entity); + } const archetypes = this[$archetypes]; for (let i = archetypes.length - 1; i >= 0; i--) { const archetype = archetypes[i]; @@ -327,11 +346,21 @@ export class QueryResult { } public [Symbol.iterator](): Iterator { + const filter = this.#filter; + if (filter !== null) { + return (this.#scanFiltered(filter, Infinity) as unknown as Iterable)[ + Symbol.iterator + ](); + } return new EntityIterator(this[$archetypes]); } /** A copy, so it survives the structural change it is being used to drive (SPEC §9). */ public entities(): Float64Array { + const filter = this.#filter; + if (filter !== null) { + return this.#scanFiltered(filter, Infinity); + } const archetypes = this[$archetypes]; const out = new Float64Array(this.count); let at = 0; @@ -344,6 +373,38 @@ export class QueryResult { return out; } + /** + * The entities a tick-filtered walk would visit right now, in walk order, + * stopping after `limit` hits. Reads through the probe, so `each` still sees + * every one of them afterwards. + */ + #scanFiltered(filter: RowFilter, limit: number): Float64Array { + const probe = (this.#probe ??= RowFilter.of(this[$terms])!); + const hits: number[] = []; + if (!probe.peek(this.#ticks, filter.lastSeen)) { + return new Float64Array(0); + } + const archetypes = this[$archetypes]; + for (let a = archetypes.length - 1; a >= 0 && hits.length < limit; a--) { + const archetype = archetypes[a]; + const rows = archetype.rows; + if (rows === 0) { + continue; + } + probe.bind(archetype); + const { pageShift, pageMask } = archetype; + for (let row = rows - 1; row >= 0 && hits.length < limit; row--) { + const page = row >>> pageShift; + const i = row & pageMask; + const entity = archetype.entities[page][i] as Entity; + if (probe.accept(entity, page, i)) { + hits.push(entity); + } + } + } + return Float64Array.from(hits); + } + public each(fn: EachFn): void { const iteration = this.#iteration; const frame = iteration.enter(); diff --git a/src/core/sorted.ts b/src/core/sorted.ts index 97ef1b7..3b83332 100644 --- a/src/core/sorted.ts +++ b/src/core/sorted.ts @@ -279,17 +279,26 @@ export class SortedQueryResult impl base.attach(this); } + // A tick-filtered view answers the reads through its own walk, which owns + // the filter its each() runs with; an unfiltered one keeps the O(1) answers. public get count(): number { - return this.#base.count; + if (!this.#walk.filtered) { + return this.#base.count; + } + const view = this[$view]; + return this.#walk.count(view.ensure(), view.length); } public get isEmpty(): boolean { - return this.#base.isEmpty; + return this.#walk.filtered ? this.first === undefined : this.#base.isEmpty; } public get first(): Entity | undefined { const view = this[$view]; const entities = view.ensure(); + if (this.#walk.filtered) { + return this.#walk.first(entities, view.length, 1); + } return view.length === 0 ? undefined : (entities[0] as Entity); } @@ -316,7 +325,11 @@ export class SortedQueryResult impl /** An ordered copy, safe to drive structural change with (SPEC §9). */ public entities(): Float64Array { const view = this[$view]; - return view.ensure().slice(0, view.length); + const entities = view.ensure(); + if (this.#walk.filtered) { + return this.#walk.collect(entities, view.length, 1); + } + return entities.slice(0, view.length); } /** diff --git a/src/core/walk.ts b/src/core/walk.ts index b236d5d..97561ff 100644 --- a/src/core/walk.ts +++ b/src/core/walk.ts @@ -348,6 +348,11 @@ export class RowFilter { return new RowFilter(changed ?? NO_TRAITS, added ?? NO_IDS, removed ?? NO_IDS); } + /** The tick this filter last consumed up to — the horizon its next `begin` will use. */ + public get lastSeen(): number { + return this.#lastSeen; + } + /** * Opens a run: fixes its horizon and resolves the removal records to handle * sets. Returns false when a set is empty, so the conjunction cannot match @@ -356,7 +361,22 @@ export class RowFilter { public begin(ticks: Ticks): boolean { this.#horizon = this.#lastSeen; this.#lastSeen = ticks.tick; + return this.#open(ticks); + } + + /** + * Opens a run that answers "what would `begin` accept right now?" without + * consuming anything: the horizon is taken as given and `lastSeen` is left + * alone, so `count` / `isEmpty` / `first` can be read ahead of `each` without + * eating its events. Use it on a filter nothing else walks with, so an + * in-flight `each` never finds its bound columns swapped from under it. + */ + public peek(ticks: Ticks, horizon: number): boolean { + this.#horizon = horizon; + return this.#open(ticks); + } + #open(ticks: Ticks): boolean { const removed = this.#removed; const removedSets = this.#removedSets; for (let r = 0; r < removed.length; r++) { diff --git a/tests/accessor.test.ts b/tests/accessor.test.ts index b11ba2a..4be531b 100644 --- a/tests/accessor.test.ts +++ b/tests/accessor.test.ts @@ -407,7 +407,8 @@ describe('set is a real write (§4.5, §8.1, §8.3)', () => { px.get(e); expect(calls).toBe(0); - expect(query.count).toBe(1); + expect(world.query(Position).count).toBe(1); // still there … + expect(query.isEmpty).toBe(true); // … and unchanged let seen = 0; query.each(() => seen++); expect(seen).toBe(0); diff --git a/tests/tick-filters.test.ts b/tests/tick-filters.test.ts index 0d0d25a..4e23123 100644 --- a/tests/tick-filters.test.ts +++ b/tests/tick-filters.test.ts @@ -129,3 +129,122 @@ describe('Removed (§6.1, §8.3)', () => { world.destroy(); }); }); + +describe('count, isEmpty, first, entities and for-of honour tick filters (§6.3, §8.3)', () => { + test('the reads answer for the filter, not the structural match set', () => { + const world = new World(); + world.spawn(Position); + world.spawn(Position); + world.spawn(Position); + const query = world.query(Changed(Position)); + collect(query); // consume the initial state + world.step(); + + expect(collect(query)).toEqual([]); + expect(query.count).toBe(0); + expect(query.isEmpty).toBe(true); + expect(query.first).toBeUndefined(); + expect([...query]).toEqual([]); + expect(query.entities().length).toBe(0); + + world.destroy(); + }); + + test('reading them does not consume the window each() is about to see', () => { + const world = new World(); + const a = world.spawn(Position); + const b = world.spawn(Position); + const query = world.query(Position, Changed(Position)); + collect(query); + world.step(); + world.set(a, Position.x, 1); + + expect(query.count).toBe(1); + expect(query.isEmpty).toBe(false); + expect(query.first).toBe(a); + expect([...query]).toEqual([a]); + expect(Array.from(query.entities())).toEqual([a]); + // …and none of that was a run: each() still gets the write. + expect(collect(query)).toEqual([a]); + // Now it has run, so the window is closed until the next tick's write. + expect(query.count).toBe(0); + world.step(); + world.set(b, Position.x, 2); + expect(query.first).toBe(b); + + world.destroy(); + }); + + test('first follows the order each() would visit', () => { + const world = new World(); + const entities = [world.spawn(Position), world.spawn(Position), world.spawn(Position)]; + const query = world.query(Position, Changed(Position)); + collect(query); + world.step(); + world.set(entities[0], Position.x, 1); + world.set(entities[2], Position.x, 1); + + expect(query.first).toBe(collect(query)[0]); + + world.destroy(); + }); + + test('Added and Removed answer the same way', () => { + const world = new World(); + const e = world.spawn(Position); + const added = world.query(Added(Velocity)); + const removed = world.query(Removed(Velocity)); + collect(added); + collect(removed); + world.step(); + expect(added.count).toBe(0); + expect(removed.isEmpty).toBe(true); + + world.add(e, Velocity); + expect(added.count).toBe(1); + expect(added.first).toBe(e); + expect(collect(added)).toEqual([e]); + + world.step(); + world.remove(e, Velocity); + expect(removed.first).toBe(e); + expect(removed.count).toBe(1); + expect(collect(removed)).toEqual([e]); + + world.destroy(); + }); + + test('sorted and ordered views over a filtered query agree with their each()', () => { + const world = new World(); + const a = world.spawn(Position({ x: 3 })); + const b = world.spawn(Position({ x: 1 })); + world.spawn(Position({ x: 2 })); + const sorted = world.query(Position, Changed(Position)).sortBy(Position.x); + sorted.each(() => {}); + world.step(); + expect(sorted.count).toBe(0); + expect(sorted.isEmpty).toBe(true); + expect(sorted.first).toBeUndefined(); + + world.set(a, Position.x, 4); + world.set(b, Position.x, 0); + expect(sorted.count).toBe(2); + expect(sorted.first).toBe(b); + expect([...sorted]).toEqual([b, a]); + expect(Array.from(sorted.entities())).toEqual([b, a]); + + world.destroy(); + }); + + test('unfiltered reads are untouched', () => { + const world = new World(); + const a = world.spawn(Position); + const query = world.query(Position); + world.step(); + expect(query.count).toBe(1); + expect(query.first).toBe(a); + expect([...query]).toEqual([a]); + + world.destroy(); + }); +});