Summary
On a tick-filtered query, count, isEmpty and first report the structural match set and ignore the Changed() / Added() / Removed() terms. each and iteration apply the filter; the scalar accessors do not.
Repro
import { World, Trait, f32, Changed } from 'apecs';
const Position = new Trait({ x: f32(0) });
const w = new World();
w.spawn(Position); w.spawn(Position); w.spawn(Position);
const q = w.query(Changed(Position));
q.each(() => {}); // consume the initial state
w.step();
let n = 0; q.each(() => n++);
console.log(n); // 0
console.log(q.count); // 3
console.log(q.isEmpty); // false
console.log(q.first); // an entity
Cause
count, isEmpty and first in src/core/result.ts sum archetype.rows across the matched archetypes and never consult the RowFilter.
Why it matters
The README's Queries table lists query.count / .isEmpty / .first with no caveat, so world.query(Changed(Position)).isEmpty reads as a natural "anything changed?" check and silently answers the wrong question.
Options
- Route the three accessors through the filter when one is present.
count becomes a scan, which is the same cost each already pays for these queries.
- Keep them structural, document it in the README table and spec §8, and add a dev-build assertion that throws when they are read on a tick-filtered query.
Found by a post-publish smoke test of 0.1.0.
Summary
On a tick-filtered query,
count,isEmptyandfirstreport the structural match set and ignore theChanged()/Added()/Removed()terms.eachand iteration apply the filter; the scalar accessors do not.Repro
Cause
count,isEmptyandfirstinsrc/core/result.tssumarchetype.rowsacross the matched archetypes and never consult theRowFilter.Why it matters
The README's Queries table lists
query.count / .isEmpty / .firstwith no caveat, soworld.query(Changed(Position)).isEmptyreads as a natural "anything changed?" check and silently answers the wrong question.Options
countbecomes a scan, which is the same costeachalready pays for these queries.Found by a post-publish smoke test of 0.1.0.