Skip to content

Invariants and Known Gaps

What the runtime actually guarantees today, and where the API promises more than the implementation delivers. Every behaviour on this page was verified against the code at a6925a2, not inferred from docstrings.


Guaranteed invariants

These hold, and code may rely on them.

Identity

  • An EntityId is immutable and self-describing. It holds no reference to a world.
  • A recycled index always carries a higher generation. A stale handle fails EntityAllocator.is_alive() rather than addressing its successor.
  • User entities start at index 1000. Indices below that are reserved for singletons.
  • Provisional spawn IDs are negative and therefore cannot collide with allocated ones.

Components

  • A component type's ID is sha256(module.qualname) truncated to 64 bits — identical in every process running the same code. Collisions raise at registration.
  • @component requires a dataclass or Pydantic model, and must be applied after @dataclass.
  • __combine__ and __split__ are always reached through combine_protocol_or_fallback / split_protocol_or_fallback, so the fallback (LWW, and deep-copy respectively) always applies when the protocol is absent.

Reads

  • Every read returns a deep copy. ScopedAccess.get(), query(), World.get_copy(), and World.query_copies() all copy. Mutating what you read changes nothing; you must write the value back.
  • A system can only read types its descriptor allows. Dev-mode systems bypass this.
  • A system sees its own buffered writes. It never sees a peer's writes from the same execution group.

Writes

  • Writes from a system are buffered as ordered MutationOps and never touch storage during execution.
  • op_seq is authoritative. When the ordered op log and a convenience projection (result.updates, result.inserts, …) disagree, the op log wins.
  • Within one applied result, repeated writes to the same (entity, type) fold through __combine__ when the component is Combinable, and overwrite otherwise.
  • merge_entities and split_entity always create new entities and destroy their inputs. Neither mutates in place.

Execution

  • Groups run sequentially; systems within a group run concurrently.
  • Results are concatenated in registration order before being applied.
  • Each retry attempt gets a fresh buffer — a failed attempt's partial writes are discarded, not replayed.

Sharp edges

Real behaviour that surprises people. All verified.

Singleton entities appear in query results

WORLD, CLOCK and SCHEDULER are registered generation-0 alive by EntityAllocator at construction, so they are ordinary live entities. A singleton component is therefore returned by a query for its type, paired with the WORLD entity:

world._storage.entity_exists(SystemEntity.WORLD)   # True
world.set_singleton(Cfg(5))
world.singleton_copy(Cfg)                          # Cfg(n=5)
list(world.query_copies(Cfg))                      # [(SystemEntity.WORLD, Cfg(n=5))]
list(world._storage.all_entities())                # the three reserved entities

A query for a component type you only ever set as a singleton will match WORLD. Systems that iterate every entity carrying some component see the reserved entities alongside user entities; exclude them explicitly if that matters.

Exactly three entities are registered, not the 1000 reserved indices. _RESERVED_COUNT is the allocation floor only — it bounds where allocate() starts and is never iterated.

ScopedAccess.update_singleton() is a thin alias for update(SystemEntity.WORLD, ...); both enforce the declared write contract and the entity existence check. Earlier versions had the two diverge, with update_singleton() skipping the existence check because the reserved entities were not allocator-alive.

Reads fail differently inside and outside systems

Call Component missing
World.get_copy(e, T) returns None
ScopedAccess.get(e, T) raises KeyError

The type annotations do not reflect this. ScopedAccess.get is annotated Copy[T] but raises; EntityHandle.__getitem__ and the ReadOnlyAccess protocol both advertise T | None. The if result is None branch in ScopedAccess.singleton() (services/world/access.py:474) is consequently unreachable — get() raises first.

Same-tick reads diverge from what gets committed

For a Combinable component written twice by one system, the buffer overlay returns the latest value while apply time folds both:

@system(reads=(Log,), writes=(Log,))
def s(world):
    world[e, Log] = Log(["s1"])
    world[e, Log] = Log(["s1b"])
    world[e, Log]          # ['s1b']        ← what you read
# after tick:              # ['s1', 's1b']  ← what was committed

Tracked as REQ-062. Until it lands, do not read back a Combinable you just wrote and expect the committed value.

Provisional spawn IDs are not remapped

An entity spawned inside a system gets a negative placeholder. apply_result_async allocates the real ID and returns it, but does not rewrite later ops that reference the placeholder. Writes addressed to the provisional ID are lost.

Pass everything the new entity needs into the spawn() call. Tracked as REQ-039.

The subscript key is ignored on write

world[entity, Position] = value infers the component type from value. The Position in the key is never read. Assigning a Velocity under a Position key silently records a Velocity update.

Returned mutations skip existence checks

Imperative mutations go through _check_writable and _check_entity_exists. Mutations returned from a system go through normalize_resultmergevalidate_result_access, which checks the write contract only. A returned write to an unknown or destroyed entity still reaches apply_result_async.

validate_result_access also only inspects updates and inserts — returned removes, spawns, and destroys are unchecked — and returns immediately for AllAccess systems. This is the open review item from PR #89.

Parallel systems combine independently computed values

Two systems in a group both start from the same state. If both write a Combinable component computed from that starting value, __combine__ receives two full values, not a base and a delta. Implementations must be additive or idempotent; treating other as an increment on self will double-count.

World.tick() is asyncio.run

Both tick() and apply_result() call asyncio.run and will fail inside an existing event loop. Use tick_async() / apply_result_async() from async code.


Declared but not wired

These exist in the API surface and have no effect on runtime behaviour. They are design intent, useful as documentation, and safe to use — but do not expect them to do anything yet.

Surface Where Status
SystemDescriptor.frequency models/system.py:29 Stored, never read by any scheduler. Every system runs every tick.
SystemDescriptor.phase models/system.py:29 Stored, never read anywhere.
SystemMode.PURE models/system.py:13 Accepted by @system. _check_writable only special-cases READONLY, so a PURE system can still mutate through ScopedAccess.
queries_disjoint() functions/query.py:17 Implemented and property-tested. No scheduler calls it.
Query.excluding() models/query.py:43 Enforcement is type-level: QueryAccess is flattened via .types() before checks, so exclusions never restrict which entities you may write.
Conflict detection build_single_group_plan puts every non-dev system in one parallel group. No write-conflict analysis exists. Conflicts resolve at apply time by __combine__ or LWW.
Tick history protocols/history.py HistoryStore and TickRecord are protocol and model only. World neither counts ticks nor emits records.
ConflictError models/errors.py:16 Defined, never raised.
Access enum models/system.py:21 Defined, unused.
_rust/ _rust/__init__.py Empty placeholder for future PyO3 bindings.
standard_library/ Empty package skeleton.

Storage-side strictness is a partial case. REQ-040 tightened write policy at the ScopedAccess layer, but LocalStorage.set_component (services/storage/local.py:148) still creates a component bucket for an unknown entity rather than raising. The guard lives in the access layer, not the storage layer.


Performance characteristics

Not optimized, and deliberately so at this stage.

Operation Cost Note
LocalStorage.query O(n) over all entities Archetypal storage would make it O(matched).
Every read two deep copies Once out of storage, once through the buffer overlay.
result.updates and siblings O(ops) per access Materialized on every property access. _query_raw_async caches them against _next_op_seq; other callers do not.
ScopedAccess.get from sync code thread hop Marshalled onto the SyncRunner loop via run_coroutine_threadsafe.
snapshot() / restore() pickle Marked in-source as prototype-only.

The async-everywhere internal design costs a thread hop per synchronous read today. It buys the ability to put a genuinely remote storage backend behind the same Storage protocol without changing a single system.

[ECS]: Entity Component System [LWW]: Last-Writer-Wins [REPL]: Read-Eval-Print Loop [PyO3]: Rust bindings for the Python interpreter