Container systems¶
Status: M0 to M5 implemented. This design extends the implemented contiguous containers and respects memory ownership. It does not replace those contracts. Types marked planned below are architectural decisions, not available SDK APIs. The milestone roadmap tracks the remaining integration and implementation work.
Requirements and evidence¶
The engine needs predictable storage, explicit failure, fast traversal, and ownership that a debugger can explain. No design is universally fastest. Choose by access pattern, cardinality, mutation frequency, ordering, and address stability; benchmark the actual consumer before selecting a more complex algorithm. C++23, Clang 18, no exceptions, fixed-width Ludus types, standalone light public headers, and the existing native/browser SDK boundary are gates.
The initial audit at 52e29ce, refreshed through 595042b, finds an established
Array/StaticArray implementation,
std::span views, specialized concurrent logging/profiling queues, world-owned
entity/component storage, audio/UI-owned bounded slots, and one production
std::unordered_map<uint64, uint64> in GameHost's resource bindings. The
remaining unordered set is a reload-test observation set. Current main also
provides FoundationMemory allocation domains, FoundationHash, and Strings with
an exact intern table; reuse those APIs rather than invent competing hashing,
string ownership, or allocator systems. Array still uses its original byte seam.
Resource bindings are
written during setup and read through the host callback. That lookup-only
callback should never allocate; setup needs a fallible insertion path.
The first increment supplies SortedMap, backed by one Array<Entry>, and
migrates that registry. It also adds fallible insertion to Array, keeping
object lifetime and relocation in the existing audited helpers. This is a
useful vertical slice without a new allocator, SIMD portability layer, or a
second lifetime implementation.
Caller-scratch stable ordering¶
StableSort in ludus/foundation/containers/stable_sort.hpp provides a nonallocating
stable merge sort for initialized caller-owned arrays. It takes separate scratch
storage, preserves equivalent input order and requires nonthrowing assignment and
comparison. Its declaration owns the exact input/failure contract. L0 Renderer uses
it during static view preparation; it is independently tested against a standard
stable-sort oracle on empty, duplicate-heavy and non-power-of-two sizes through 257.
Baseline architecture, before the Gems review¶
flowchart BT
Base[FoundationBase: types and assertions]
Memory[FoundationMemory: allocation domains]
Containers[FoundationContainers: owning storage and lookup]
Consumers[World / Content / Audio / GameHost]
Base --> Containers
Base --> Memory
Memory -. future Array integration .-> Containers
Containers --> Consumers
Each container owns storage and element lifetimes. It does not own scheduling,
resource residency, serialization, locks, or subsystem policy. Keep views as
std::span/std::string_view; keep algorithms separate. Do not add containers
to core.h. New public headers opt in to the facilities they use, and export
through FoundationContainers' CMake header file set.
| Storage/access requirement | Type | Status / tradeoff |
|---|---|---|
| Fixed count, contiguous | StaticArray<T, N> |
Implemented; all N objects always live |
| Growable contiguous sequence | Array<T> |
Implemented; reserve/reuse; pointers move on growth |
| Small or read-mostly ordered dictionary | SortedMap<K, V, Compare> |
First increment; binary search, contiguous iteration, O(n) insert/remove |
| Runtime size within a hard bound | FixedArray<T, N> |
Implemented (M3); live prefix, no heap, full is a status |
| FIFO with hard capacity | RingBuffer<T, N> |
Implemented (M4); inline storage, single owner, two spans for wrapped storage |
| Dense index membership/flags | StaticBitSet<N>; BitSet |
StaticBitSet implemented (M5); dynamic BitSet deferred for lack of a consumer |
| Larger mutable dictionary | HashMap<K, V, Hash, Equal> |
Deferred (M8); no consumer and no measured workload yet |
| Uniqueness without mapped values | SortedSet<K> / HashSet<K> |
Deferred (M7/M8); no consumer; shares the corresponding lookup policy |
| Dense traversal with durable identity | subsystem typed handle + SlotMap<T> |
Deferred (M9); gated on object-handles/FoundationIdentity |
| Dense components keyed by sparse IDs | SparseSet<T> |
Deferred (M10); gated on object-handles/FoundationIdentity and a concrete World extraction |
| Rare address-stable objects | subsystem owned pool | Conditional; handles normally preferred |
| Tiny common sequence that occasionally grows | SmallArray<T, N> |
Conditional; size/relocation cost must beat Array |
Use Array plus linear search for very small ephemeral tables; SortedMap when
ordered iteration or binary lookup is useful; HashMap for a measured large,
mutation-heavy table. These are workload choices, not a magic cardinality
threshold. Arrays remain the default; avoid pointer-linked lists and trees
unless ordering/range updates demonstrably justify their allocation/locality
costs. Spatial trees, render graphs, resource caches, and ECS layouts stay in
their own modules.
Shared contracts¶
- Ordinary containers have one mutable owner. Concurrent reads require no concurrent mutation; synchronization is the caller's responsibility.
GetSize,IsEmpty,EnsureCapacity,Clear,Reset,Find,Contains,Remove, andTryAddInPlacefollow the established verb naming.- Recoverable resource exhaustion is explicit.
Tryinsertion does not consume arguments, change live elements, or invalidate pointers on allocation failure. Unchecked insertion calls the existing out-of-line fatal OOM path. Cleardestroys live elements but retains capacity.Resetdestroys them and releases storage. Neither method allocates. No hidden shrinking on removal.- Element destruction/movement and comparison/hash callbacks must be nonthrowing for new lookup containers. Arbitrary user value construction must satisfy the same requirement. User element constructors that themselves allocate need their own explicit failure protocol; a container cannot recover that failure.
- Use the current aligned, nothrow byte seam for Array-based containers. FoundationMemory domains already exist for Strings; integrating Array and its dependent containers is a separate ABI/measurement change; do not embed an unrelated allocator framework in each new template or intercept global new in production.
- Pointers/references are temporary borrows. Expose invalidation in each API; durable identity uses a typed handle. A handle contains no owning pointer.
- No container stores debug strings or registers globally. Inspect normal fields in the debugger; assertions check contracts; sanitizer and lifetime tests verify raw storage. Optional expensive validators are explicit calls.
- In-memory container layout is not a file, network, or game-module ABI. Export logical records through explicit schemas; sort when deterministic order is required. Do not dump pointers, capacity, padding, buckets, or freelists.
SortedMap contract¶
Representation is Array<Entry> with immutable-through-public-access keys,
mutable mapped values, and a stateless ordering policy. Keys must be nonthrowing
copyable/movable; owning move-only String keys need a future explicit cloning/
heterogeneous-borrow design and are outside this initial map contract. Values
must be nonthrowing movable/assignable/destructible,
including move-only non-default-constructible types. Copy operations exist only
when mapped values are nonthrowing copyable. This prevents unsupported operations
from masquerading as valid type traits.
Compare is a default-constructible empty policy with a const, nonthrowing
strict weak ordering. Equivalence is !Compare(a,b) && !Compare(b,a), never a
second equality policy. Ordering must remain stable for the container's lifetime.
The default uses <; NaNs or mutable external ordering state do not satisfy the
contract. Heterogeneous lookup and stateful comparators can be added when a
consumer requires them, without charging every empty dictionary now.
The API intentionally exposes const entry spans for iteration, and Find returns
a mutable/const pointer to the mapped value. A writable entry span would let a
caller change keys and silently break lookup. There is no operator[]: reading
must not insert or require a default-constructible value. Insert returns
{Value, Inserted}: an existing pointer with false for duplicate, a new pointer
with true for insertion, null with false for OOM/overflow. Duplicate insertion
leaves both stored values and supplied arguments untouched.
Search uses overflow-safe binary lower bound. Insertion checks equivalence once,
then asks Array::TryInsertAtInPlace to construct the entry. On a growth path,
allocate new storage first, construct the new entry while aliases into the old
storage remain live, relocate prefix/suffix, and commit. On a reserved path,
materialize before shifting. This avoids a second sorted-map storage algorithm
and preserves failure semantics even for arguments referencing existing values.
Array::TryAddInPlace uses the shared growth helper, repairing its previous
consume-before-allocation behavior for an aliased rvalue on OOM.
| Operation | Complexity | Invalidation |
|---|---|---|
Find / Contains |
O(log n) comparisons | None; no allocation |
| Duplicate insertion | O(log n) | None; no argument consumption |
| Insert with growth | O(n) moves/copies | All entry/value pointers |
| Insert without growth | O(n) moves after insertion index | At/after index |
Remove |
O(log n) search + O(n) moves | At/after removed index |
EnsureCapacity |
O(n) only if growing | All on growth |
Failed Try operation |
No live-element changes | None |
Clear / Reset |
O(n) destruction | All live elements |
| Move/Swap | O(1) | Borrows follow transferred storage; old move-assignment destination dies |
Entry references can still refer to external memory whose contents change; key owners must prevent such mutation. Value-semantic keys such as integer IDs are the normal choice. A span is a borrow and must not outlive the owning map.
FixedArray contract¶
FixedArray<T, N> (in fixed_array.hpp) keeps its live elements as the prefix
of an uninitialized inline union, so constructing the array constructs no
element. Element types need only a nonthrowing destructor; copy, move,
insertion and removal are constrained on the operations they use. N == 0
selects an empty slot union, so the array holds only its size. GetData()
returns null, the array is always full, and every Try add fails. The array is
trivially destructible exactly when T is.
Additions report a full array through Try operations only: false or nullptr
with no element changed and no argument consumed. There is no OOM path.
Appending constructs in place without moving existing elements, so arguments may
alias the array. Ordered insertion materializes the value before shifting.
TryAddRange is all-or-nothing. Removal and ordered shifts reuse the
detail/contiguous_storage.hpp helpers shared with Array.
Borrows remain valid until their element is removed or shifted, because storage
never relocates. A copy or move transfers elements rather than storage, so
borrows refer to the source's slots. A moved-from array is empty, matching
Array. The M3 consumer is the Input reducer's binding table and per-step event
buffer. They replace raw arrays with hand-maintained counts, and a full
step-event buffer is asserted as unreachable by the reducer's capacity
invariant.
RingBuffer contract¶
RingBuffer<T, N> (in ring_buffer.hpp) is a single-owner FIFO. M4's storage
decision is that capacity is a compile-time constant and storage is inline, the
same uninitialized slot union FixedArray uses (detail/inline_slots.hpp).
It never allocates, which preserves the Input consumer's one creation-time
allocation. A heap-backed ring whose capacity is chosen at run time remains
gated on a consumer that needs it.
Live elements occupy a possibly wrapped run starting at the head. Only those
slots hold objects, so element types need only a nonthrowing destructor. A full
ring rejects TryAdd/TryAddInPlace with no element changed and no argument
consumed. Appending never moves a live element, so arguments may alias the
ring, and a borrow stays valid until its element is removed. RemoveFirst is
O(1); a drained ring restarts at the first slot so the next run is contiguous.
Index 0 is always the oldest element. GetLeadingSpan() then GetTrailingSpan()
visit the live elements in FIFO order without pretending wrapped storage is
contiguous. Copies and moves preserve FIFO order and restart at the first slot.
A moved-from ring is empty. N == 0 is always full.
The ring is not a concurrent queue: the audio, logging and profiling SPSC/MPSC
queues keep their own atomic publication and reclamation protocols. The M4
consumer is the Input reducer's pending-transition queue. Its overflow policy
is unchanged: a rejected TryAdd leaves the queue untouched, then the reducer
discards the batch and arms a reset.
StaticBitSet contract¶
StaticBitSet<N> (in static_bit_set.hpp) packs N bits into
(N + 63) / 64 uint64 words held in a StaticArray. It never allocates and
is a trivially copyable constexpr value type. Bits at or above N in the last
word are always zero. Every operation that could set them (SetAll, FlipAll,
operator~, operator<<=) masks them again, so counts and comparisons never see
stale tail bits. A zero-bit set stores one always-zero word, so every operation
compiles without a separate specialization.
Bits are read with IsSet and written with Set, Reset, Assign and Flip.
There is no bool& proxy. Index arguments are preconditions asserted in checked
builds. Whole-set operations are SetAll, ResetAll, FlipAll, CountSet,
IsAnySet/IsNoneSet/AreAllSet and FindNextSet, the word-wise scan used to
visit set bits. Set operations &, |, ^ and ~ combine word by word.
Shifts move bits toward higher (<<) or lower (>>) indices, discard bits
shifted past either end, and reset every bit for a shift of N or more.
GetWords() exposes read-only words, least significant bit of word 0 first.
That in-memory layout is not a wire format; persist bits through an explicit
schema.
Population count and trailing-zero count are portable constexpr
implementations of the classic algorithms in Henry S. Warren, Jr., Hacker's
Delight, 2nd edition, sections 5-1 and 5-4. Neither <bit> nor compiler
intrinsics are used, because ADR 0003 does not list them. An intrinsic path
needs that policy change and a measured benefit first.
The M5 consumers are the Input reducer's private live-down and reset-baseline
key masks, and the Resources manager's dependency-closure
Wanted set and catalog-duplicate seen set. The public KeyboardSnapshot
and FocusBaseline bool arrays are unchanged, because packing them would change
the SDK's API and ABI. A dynamically sized BitSet with fallible growth is
deferred: no consumer needs a run-time bit count.
Planned structures and decision gates¶
HashMap should compare a scalar flat open-addressed baseline against an
established grouped-control implementation before committing to SIMD. Grouped
control bytes/fingerprints reduce unnecessary full-key comparisons in Abseil's
Swiss table design, but tombstones,
probe termination, hash quality, and ISA paths add maintenance cost. Choose one
algorithm from mixed hit/miss, collision, deletion-churn, large-value, and native/
Wasm measurements. No blanket speed claim is warranted. Require bounded probing,
overflow-safe allocation, an occupied/deleted/empty invariant, key immutability,
transactional rehash, and failure injection at every backing allocation.
Hash and equality must agree and be nonthrowing; keys are trusted engine IDs by
default. Public/adversarial strings use FoundationHash
KeyedTableHash64 with a caller-supplied secret plus a bounded admission policy.
Reuse TableHash64 for trusted byte keys and exact key comparison. Do not use
a persisted fingerprint as collision-free dictionary identity. Deterministic
iteration must use a sorted output
view rather than rely on bucket order. Pointer stability is explicitly lost on
rehash; mutation/iteration rules will be documented for the selected algorithm.
SlotMap separates dense values from slot metadata and a dense-to-slot reverse
index. A handle carries a subsystem tag, index, and generation; stale lookup is
an ordinary null result. Removal repairs the reverse mapping after moving the
last value. Retire a slot on generation exhaustion instead of allowing an old
handle to become valid. Specify world/owner identity and cross-instance behavior
before extracting a generic primitive: index+generation alone does not identify
an owner. Clear, move, rollback of multi-array growth, and destruction must
preserve the stale-handle contract. The primary-source
Bitsquid ID table article
provides the dense lookup model; owner identity and exhaustion are Ludus's
additional requirements.
SparseSet must reject IDs outside its bound before indexing, verify dense IDs
to avoid uninitialized sparse entries, repair back-links after swap removal, and
account for sparse address-space memory. Paged sparse metadata is conditional on
a measured sparse ID range. Keep ECS-specific component lifetime and query
policy in World. Pooling, inline spills, node containers, lock-free generic
collections, persistent structures, and platform virtual-memory tricks all need
a concrete consumer and measurements before implementation.
Rollout and verification¶
Milestone roadmap¶
Planning baseline: 2026-10-06; deferral review 2026-11-03. M0 records implemented functionality, not a fresh certification of every review gate. M0–M5 are implemented (PRs #271, #299, #303, #319 and #332). Deferred means the milestone is not being implemented now: its row records the reason and the condition that reopens it. A deferral is deliberate, not a missing deliverable, so M11's baseline can be established with M6–M10 outstanding. Numbers express the recommended order; the dependencies below determine which increments can proceed independently once reopened.
| ID | Milestone | Status | Deliverable and exit criterion |
|---|---|---|---|
| M0 | Contiguous arrays and first ordered map | Implemented | StaticArray, Array, SortedMap, fallible insertion/append, GameHost resource bindings, model/lifetime/allocation tests, SDK probe and benchmark exist. Retain their contracts and scoped measurement evidence. |
| M1 | Restore migration and explicit allocation failure | Implemented | First-party std::vector uses were replaced with Array, including tests, tools and the exported Audio Gym API. Status-returning boundaries use fallible operations and preserve outputs on failure. tools/check_container_policy.py rejects new uses outside the benchmark/vendor exceptions; see M1 consumer migration. |
| M2 | Complete existing container API documentation | Implemented | Every public Array, StaticArray, SortedMap and relocation-trait declaration has a Doxygen contract, and their 158 baseline entries were removed from api-undocumented.json. The container guide covers selection, failure handling and borrow rules, and separates shipped types from planned ones. Strict wiki/API builds and coverage checks pass. |
| M3 | FixedArray<T, N> and one Input migration |
Implemented | FixedArray keeps a live prefix in inline raw storage, reports full explicitly and constructs only occupied slots (contract). The Input reducer's binding table and step-event buffer use it. Zero allocation, zero capacity, non-default/move-only values, aliasing, failure preservation and balanced lifetimes are tested, including against a seeded model. |
| M4 | Single-owner RingBuffer<T, N>; heap-backed run-time-capacity ring |
Implemented; heap-backed ring deferred (no consumer) | Inline storage with compile-time capacity was selected (contract). Wrapped leading/trailing spans and explicit full/empty behavior are implemented. The Input reducer's pending-transition queue uses it with its overflow/reset policy unchanged. Wraparound and lifetimes are model-tested. A heap-backed, run-time-capacity ring is deferred, reopened when a consumer needs run-time capacity, consistent with the RingBuffer contract. |
| M5 | StaticBitSet<N>; dynamic BitSet |
Implemented; dynamic BitSet deferred (no consumer) |
StaticBitSet packs bits into uint64 words with tail masking, single-bit and whole-set operations, FindNextSet, set operations and shifts (contract). The Input reducer's private key masks and the Resources manager's membership sets use it. 0/1/63/64/65/130-bit boundaries, tail masking, every shift amount and a seeded model are tested, with zero allocation. Dynamic BitSet with fallible growth is deferred, reopened when a consumer needs a run-time bit count, consistent with the StaticBitSet contract. |
| M6 | Array allocation-domain integration | Deferred (2026-11-03); Memory Phase 2 not complete | Reopen after a dedicated design review of the owning-layout change. This milestone changes Array's owning layout and the SDK ABI, and depends on Memory phases 1 and 2. Phase 1 (the fallible byte boundary and the Array seam redirected to the Containers domain) is implemented; Phase 2 (explicit per-owner container attribution, Array(domain) and the versioned owning-layout seam) is not. Scope when reopened: reuse FoundationMemory through the Array byte seam, then introduce domain ownership as a separately measured layout change. Test original-domain frees, copy/move/swap rules, nested owners, over-alignment and OOM preservation; version the SDK owning-layout boundary. |
| M7 | SortedSet<K> |
Deferred (2026-11-03); no consumer | Reopen when a named ordered-uniqueness consumer appears. No first-party production code needs ordered uniqueness without mapped values; a repository search finds no production std::set/std::map outside tests and tools/container-bench. Scope when reopened: reuse the ordering/equivalence contract and contiguous lifetime helpers; verify duplicate insertion consumes nothing, keys remain immutable through public access, and ordered output matches an independent model. |
| M8 | HashMap and consumer-required HashSet |
Deferred (2026-11-03); no consumer and no measured workload | Reopen when a named mutable-lookup consumer appears. No first-party production code needs a large mutable dictionary; a repository search finds no production std::unordered_map/std::unordered_set outside tests and tools/container-bench. The algorithm cannot be selected before a measured mutable-lookup workload exists. Scope when reopened: compare a scalar flat table with grouped-control alternatives on that named workload. Select and implement one algorithm, reuse FoundationHash, and migrate the consumer. Collision/deletion/rehash models, bounded probing and allocation-failure rollback pass; record native/Wasm results. |
| M9 | Typed handles and SlotMap<T> |
Deferred (2026-11-03); gating decision owned by object-handles/FoundationIdentity | Reopen after the owner and cross-instance identity decision settles in the object handles and persistent UUID architecture (object-handles stage 2, #306, and any later identity docs under docs/). Establishing owner/cross-instance identity, generation exhaustion and subsystem ABI semantics is that workstream's responsibility, not this one. Scope when reopened: extract a reusable dense-value primitive with reverse indices; verify stale/foreign handles, removal, clear, move and multi-allocation rollback against a model. |
| M10 | SparseSet<T> extraction |
Deferred (2026-11-03); gating decision owned by object-handles/FoundationIdentity | Reopen after the owner and cross-instance identity decision settles in the object handles and persistent UUID architecture (object-handles stage 2, #306, and any later identity docs under docs/), then demonstrate reusable storage logic in World or another bounded-ID consumer. Scope when reopened: extract membership/dense storage while retaining ECS policy in World; verify ID bounds, back-links, swap removal, failure preservation and sparse-address-space memory costs. |
| M11 | Research and measured improvements after the initial implementation | Baseline established (2026-11-03); ready to start | The M11 precondition is satisfied: M0–M5 are implemented and M6–M10 each carry an explicit deferral above with its reason and reopen condition. The baseline records the baseline revision, completed scope and deferred scope. Scope: review the queued conference/journal sources below, record testable hypotheses, and trial promising alternatives against the validated initial implementation. Record adopt/defer/reject decisions with correctness, allocation, memory, performance and portability evidence. |
M1 consumer migration¶
M1 landed as consumer increments in one change. On base revision 5117c8c, the
new gate reports 209 findings (std::vector uses and <vector> includes) in 42
first-party files. Migration repaired each
failure protocol rather than only renaming the container. Previously, Text's
LoadFont called std::vector::resize
before an OOM size check that the exception-free build could never reach.
- Text. Font bytes, glyph records, slot metadata and raster scratch are
Arrays.CreateFontSystem,LoadFont,ShapeRunandRasterizeGlyphreturnOutOfMemorywithout publishing a handle, changing counters or consuming a slot.RasterizeGlyphpublishes its bitmap and counter only after the scratch allocation succeeds.ludus_text_alloc_testsrejects the aligned nothrow seam to verify each path. - GameHost. Codec fields, record batches, frames and the frame-reader
backlog are
Arrays.Message::Parse,GetRecordsandEncodeFramestage their results and return false with outputs unchanged. Property snapshots and batches map allocation failure toHostFailedbefore anything is emitted. Reload checkpoint staging rejects the reload and keeps A running. Output and retained-result queues reserve capacity at construction. If a later append fails, the session latches a channel failure rather than silently dropping an outcome. - Audio Gym. The public
CaptureandScenariostorage isArray.Track,Advanceand theFilterBy*helpers returnStatus.Advanceallocates scratch and capture storage before rendering, soOutOfMemoryleaves the system, captures and tracked set unchanged. The filters write to a caller-owned output that is preserved on failure. Audio now links FoundationContainers publicly for this header. - Tests, tools and samples. These use the infallible operations where failure aborts the test. The smoke text demo reports read-buffer allocation failure explicitly.
Two compromises remain. GetRecords keeps its bool contract, so an edit
batch that cannot be allocated is reported as InvalidRequest. Outgoing
Message setters stay infallible, like the std::string values they own.
The container evidence
records the commands and results. ./scripts/check --format and the CI policy
step run the gate. Replacing std::string, filesystem facilities, std::array
or unrelated ownership types stays in their own workstreams.
M2 documented the containers that existed when it landed. Every subsequent
milestone includes Doxygen for its new/changed API and the relevant
guide update in the same change, including moving
a type from its planned table to its shipped table. M3 delivered FixedArray,
M4 delivered RingBuffer and M5 delivered StaticBitSet.
Generic rings retain single-owner semantics and do not replace the
audio/logging/profiling publication protocols.
M6 follows the byte-boundary and attribution decisions in
Memory phases 1 and 2. Existing
allocation domains do not by themselves establish that Array integration or
accounting/ABI prerequisites are complete. M6 is deferred as of 2026-11-03
and reopens after a dedicated design review of the owning-layout change. Two
facts drive the deferral: it changes Array's owning layout and the SDK ABI,
and it depends on Memory phases 1 and 2. Phase 1 is implemented — the existing
AllocationDomain was extended into the fallible byte boundary and the Array
seam now redirects to the Containers domain (see the Phase 1 implementation
note). Phase 2, which makes per-owner container attribution explicit
(Array(domain), propagation rules and the versioned owning-layout seam), is
not yet implemented, and its own risks row says to reopen the owner-size cost
question before that attribution spreads. When M6 reopens, split backend
redirection from the per-owner domain/layout change and retain the accepted
ownership rules when later containers are added; M6 can proceed independently of
bounded inline storage, and a heap-owning milestone must state which allocation
seam it uses.
M7 and M8 do not require each other, and both are deferred as of 2026-11-03
for lack of a production consumer. A repository search finds no first-party
production code that uses std::map, std::set, std::unordered_map or
std::unordered_set; the only matches are in tests and tools/container-bench,
which are permitted. M7 (ordered uniqueness) reopens when a named consumer needs
it. M8 is deferred for two reasons: no consumer, and no measured mutable-lookup
workload against which to select its algorithm; its benchmark/selection step
still precedes any public implementation, and HashSet shares the chosen policy
when a consumer needs it. Each reopens when a named consumer appears.
M9 and M10 are deferred as of 2026-11-03 because their gating decision —
owner and cross-instance identity — belongs to the object-handles /
FoundationIdentity workstream, not to container systems. That decision is owned
by the object handles and persistent UUID architecture
(object-handles stage 2, #306,
and any later identity docs under docs/). M9's identity decision precedes any
SlotMap extraction; M10 can reuse existing World identity without waiting for a
generic SlotMap if a separate extraction pays, but it inherits the same identity
gate. Both reopen once that workstream settles owner/cross-instance identity.
Owning move-only String keys, heterogeneous lookup and stateful policies remain
consumer-driven contract extensions; none is required to declare M0 implemented.
SmallArray, address-stable pools, dynamic trees, generic concurrent collections
and virtual-memory storage remain a conditional backlog. Each needs a named
consumer and its own lifetime/ownership contract plus measured benefit before
receiving an implementation milestone.
Research after the initial implementation¶
Recorded 2026-10-06. Implement and validate the initial architecture through the applicable M1-M10 delivery gates first, then begin M11's broader reading and improvement pass. Consumer-gated milestones require either a selected consumer and completed deliverable or an explicit deferral with its reason; the conditional backlog does not need implementation to establish the baseline. Record the baseline revision, completed/deferred scope, consumer workloads and measurements before comparing research prototypes with it.
M11 baseline¶
Established 2026-11-03. Every container milestone that is not implemented now carries an explicit deferral above, with its reason and the condition that reopens it, so the precondition for starting M11 is met.
- Baseline revision.
developatf8763a8e(the revision this record was written against). Later research prototypes compare against this baseline; use the then-current revision and record it with each trial in the container evidence. - Completed scope. M0–M5 are implemented (PRs
#271,
#299,
#303,
#319 and
#332):
StaticArray,ArrayandSortedMapwith fallible insertion/append and consumer migration (M0–M1), the public container API documentation (M2),FixedArray<T, N>(M3), the single-owner inlineRingBuffer<T, N>(M4) andStaticBitSet<N>(M5), each with its consumer, contract and measurement evidence recorded above. - Deferred scope. M6 (Array allocation-domain integration — reopens after a
dedicated design review, blocked on Memory Phase 2), M7 (
SortedSet<K>) and M8 (HashMap/HashSet) (reopen when a named production consumer appears, and M8 additionally needs a measured mutable-lookup workload), and M9 (typed handles/SlotMap<T>) and M10 (SparseSet<T>) (reopen once the object-handles / FoundationIdentity workstream settles owner and cross-instance identity). The deferred dynamicBitSet(M5) and heap-backed run-time-capacityRingBuffer(M4) remain consumer-gated. The conditional backlog (SmallArray, pools, trees, concurrent and virtual-memory storage) is unchanged. - Consumer workloads and measurements. The shipped consumers (GameHost resource bindings, Text, Audio Gym, the Input reducer and the Resources manager) and the first increment's measured lookup/rebuild/allocation results are the baseline workloads; see the container evidence and the completion and evidence gates. A deferred milestone has no consumer workload to measure until it reopens.
The algorithm-selection measurements and source checks already required by the initial milestones remain part of their implementation. In particular, M8 still compares scalar and grouped-control candidates before selecting its initial hash table. M11 schedules later optimization and reconsideration of those choices; it does not postpone existing correctness, ownership or measurement gates.
Prioritize CppCon for practical C++ implementation and benchmarking talks, ACM Journal of Experimental Algorithmics for measured data-structure comparisons, and Software: Practice and Experience for implementation studies. Screen FOCS selectively for hashing research that addresses a measured problem. The older sparse-set paper below is a specific foundational reading from ACM Letters on Programming Languages and Systems, rather than a recommendation about an upcoming event or current journal schedule.
These venue priorities and experiments are Ludus's research plan. Source metadata, talk descriptions and available abstracts were screened when making the shortlist; the full talks/papers remain queued. This list does not claim that their algorithms have been adopted or that their reported gains transfer to Ludus.
Read CR-01, CR-02 and CR-03 first, then select the remaining readings by the consumer's measured bottleneck. Milestone labels identify the affected initial design; they do not move these trials ahead of the baseline.
| ID | Source and attribution | Full-review status | Question and possible experiment after review |
|---|---|---|---|
| CR-01 | Chandler Carruth, Tuning C++: Benchmarks, and CPUs, and Compilers! Oh My!, CppCon 2015. Official recording | Queued; talk description screened | Across milestones: are compiler transformations or the harness obscuring the consumer's costs? Review benchmark setup, distinguish dependent lookup latency from independent throughput, and compare warm/cold access and retained/rebuilt storage with reproducible inputs. |
| CR-02 | Pablo Halpern, Allocators: The Good Parts, CppCon 2017. Official recording | Queued; talk description screened | M6: which allocator ownership and propagation ideas improve our domain integration? Compare domain retention and explicit fallible cross-domain copies, exercise copy/move/swap and nested owners, and measure allocation count, backing bytes and owner size. Retain Ludus's explicit failure protocol. |
| CR-03 | Matt Kulukundis, Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step, CppCon 2017. Official recording | Queued; talk description screened | M8: would a different probing/control layout improve the selected baseline? Compare scalar and grouped-control candidates using mixed hits/misses, deletion churn, collisions, different key/value sizes and native/Wasm targets; include memory and rehash costs. |
| CR-04 | Paul-Virak Khuong and Pat Morin, Array Layouts for Comparison-Based Searching, ACM Journal of Experimental Algorithmics 22(1), article 1.3, 2017. Author version; DOI | Queued; metadata/abstract screened | SortedMap/M7: does a different search layout help an immutable, read-heavy consumer? Compare binary search with Eytzinger or other reviewed layouts, including construction and cache behavior. Account for sorted contiguous iteration and mutation contracts before proposing a separate index or a representation change. |
| CR-05 | Preston Briggs and Linda Torczon, An Efficient Representation for Sparse Sets, ACM Letters on Programming Languages and Systems 2(1-4), pp.59-69, 1993. DOI | Queued; bibliographic metadata verified | M10: how do ID range, occupancy and clear/iteration frequency affect the initial dense/sparse representation? Compare membership, insertion, swap removal and reset costs across bounded dense and sparse ID distributions; measure sparse metadata bytes and preserve World ownership policy. |
| CR-06 | Samy Chambi, Daniel Lemire, Owen Kaser and Robert Godin, Better Bitmap Performance with Roaring Bitmaps, Software: Practice and Experience 46(5), pp.709-719, 2016. Author version; DOI | Queued; metadata/abstract screened | M5/M10: does a large sparse membership consumer justify a compressed representation? Compare dense BitSet storage with reviewed sparse/compressed alternatives across occupancy, clustering, intersection and mutation workloads. Small bounded flags retain their existing consumer contract. |
| CR-07 | Michael A. Bender, Bradley C. Kuszmaul and William Kuszmaul, Linear Probing Revisited: Tombstones Mark the Demise of Primary Clustering, FOCS 2021, pp.1171-1182. DOI; Author preprint, titled Linear Probing Revisited: Tombstones Mark the Death of Primary Clustering | Queued; metadata/abstract screened | M8: can a reviewed deletion/tombstone policy reduce churn costs without unacceptable memory or rebuild work? Trial load-factor and deletion sequences against the initial policy; measure probe lengths, insertion/lookup costs and rebuild frequency. Record the paper's hashing/model assumptions before applying any theoretical bound. |
For each completed review, record the source ID, date, exact pages/sections or talk timestamps, relevant idea and assumptions, and a testable consumer-specific hypothesis. Distinguish design inspiration from adapted code. A review may justify deferring or rejecting an idea without a prototype.
For each trial, use the existing verification gates and append a dated result
to the container evidence with
the baseline/prototype revisions, commands, seeds, toolchain, hardware/backend,
operation mix and source ID. Record lookup/mutation timings, allocations,
backing/metadata bytes and relevant consumer effects, including regressions and
negative results. Retain the first increment's historical evidence unchanged;
keep generated captures in ignored out/.
Advance entries through Queued, Reading, Reviewed, Trial planned, Trial complete, and Adopted, Deferred or Rejected. An adoption needs a measured consumer benefit and unchanged or explicitly revised failure, lifetime, ordering, portability and SDK contracts. Update this architecture and the owning API documentation with any accepted change, and acknowledge the consulted source near affected code. Native/Wasm validation, exception-free errors and public-header build budgets remain requirements. No source in this new queue has a completed trial or adoption record yet.
Completion and evidence¶
For each increment run warning-clean pinned builds, unit/model/lifetime tests, ASan/UBSan, format/tidy, standalone-header/include-boundary gates, SDK consumer, and native/browser compilation where supported. Use exhaustive small-state cases and seeded operation streams against a simple independent reference model. Allocation tests separately interpose nothrow allocation and verify failure leaves capacity, live content, and aliased/move-only arguments unchanged.
Performance reports must give compiler/build flavor, hardware, dataset/cardinality, operation mix, sample method, allocation count and memory, and include a baseline. Record wins and losses. Benchmarks are evidence, not noisy timing assertions in CI. Measure lookup and mutation separately; optimizing one does not prove the other improved. Build-budget checks remain unchanged. The first increment's evidence records measured lookup losses, small-table rebuild wins, and allocation tests. Record later results by milestone with the tested revision, commands, consumer, allocation/memory measurements and any skipped or incomplete checks. Preserve the first increment's historical measurements. Mark a milestone complete only when its deliverable, consumer migration where required, documentation and applicable validation gates are satisfied.
Gems review and resulting revisions¶
The initial architecture was written before reading the selected chapter bodies.
The source index is the local references/game-dev-gems-toc.md; the scanned
chapters were rendered and OCR-extracted, with title/code pages visually checked.
References are intentionally not required in a cloned SDK checkout. Printed
and PDF page numbers differ. These are design inputs, not copied implementations.
| Read source | Useful finding | Change from the baseline / rejected detail |
|---|---|---|
| Brian Hawkins, Handle-Based Smart Pointers, Game Programming Gems 3, §1.5, printed pp.44–48, PDF pp.46–50 | Handle checks are useful when multiple borrowers observe an independently destroyed object; cached pointers can escape validation | Add explicit resolve-per-borrow scopes below. Retain owner/index/generation and exhaustion checks. Reject a global manager and persistent cached raw pointers for relocating dense storage. |
| Pete Isensee, Custom STL Allocators, Game Programming Gems 3, §1.6, printed pp.49–58, PDF pp.51–60 | Allocating storage and constructing objects are separate operations; allocator equivalence means compatible deallocation | Make backing-domain compatibility explicit for any future swap/splice and require tests for it. Keep reserve separate from construction. Reject the sample's exception-based overflow and old allocator boilerplate. |
| Paul Glinker, Fight Memory Fragmentation with Templated Freelists, Game Programming Gems 4, §1.5, printed pp.43–49, PDF pp.59–65 | Fixed-size, owner-local blocks can remove per-object allocation; access order still determines locality | Add explicit owner-local metadata and capacity admission rules. Reject default-constructing all pool objects and overwriting bytes in live objects; construct only occupied slots. Retain measured-consumer gate rather than adopting pooling everywhere. |
| Bill Budge, A Generic Tree Container in C++, Game Programming Gems 4, §1.6, printed pp.51–59, PDF pp.66–74 | Traversal should be reusable; maintaining a cached last-descendant link can make repeated append updates quadratic on a chain | Add tree-build complexity and cached-link invariants as review gates. Keep static hierarchies as cooked arrays. Reject a universal tree and static allocator per template; a dynamic hierarchy needs an owner and workload. |
Resulting contract amendments:
- A durable handle is resolved through its owner for one operation or explicit borrow scope. The caller may not cache the resolved pointer across insertion, compaction, retirement, or reset. Validation is part of release lookup too, rather than solely an assertion. A future scoped access object must enforce this rule without hidden global lifetime management.
- Future domain-aware containers may steal/swap backing storage only while retaining the original domain identity, or under proven deallocation compatibility. A cross-domain deep copy is explicit and fallible. Test distinct fake domains to detect freeing through the wrong owner. Do not assume two allocator objects with the same template type are interchangeable.
- Pools belong to an owner/workload. Freelist metadata is outside live payload objects; repeated free, foreign-owner free, and exhausted admission need explicit validation. Capacity slots are raw storage, not pre-created objects. Test non-default-constructible values and destructor balance. Fixed capacity requires a full status and a documented backpressure policy.
- Any later hierarchy exposes traversal centrally rather than duplicating it in every consumer. Validate parent/child/sibling or packed-range invariants, prohibit cycles, and measure deep-chain construction as well as traversal. A cached traversal link is a cost/complexity decision, not an automatic win.
These revisions refine the planned structures and domain integration. They do not justify changing Array's measured growth policy, importing an old tree library, or claiming a speedup for the first SortedMap consumer. The implemented vertical slice follows the allocation/lifetime separation directly: allocate before construction, stage resource-registry copies before commit, and verify failed insertion leaves borrowed and move-only values intact.
In-place ordering¶
foundation::Sort(values, count, less) in containers/sort.hpp uses an in-place
heap sort: O(count log count) comparisons, O(1) auxiliary storage, no allocation
and no stability guarantee. Elements must support nonthrowing move construction
and assignment, and the comparator must provide a nonthrowing strict weak
ordering. A zero count permits a null pointer. Element addresses remain valid;
values can move between positions. Use the existing StableSort with separate
initialized scratch storage when equal keys must retain their input order.
The Perfetto exporter uses that stable operation and returns failure if scratch
allocation fails.
Borrowed contiguous views¶
FoundationContainers exports BorrowedView<T> in
ludus/foundation/containers/borrowed_view.hpp. It carries an address and element
count, allocates nothing, and neither owns storage nor extends its lifetime.
Built-in arrays preserve their extent on construction. For dynamic storage,
construct with {array.GetData(), array.GetSize()}. BorrowedView<const T>
prevents element writes through that view.
An empty view means absence. A null pointer with a nonzero count is invalid;
consumers check IsValid() and their required extent before indexing. This
validation cannot prove that the caller's declared storage is alive or large
enough. Indexing asserts validity and bounds. Renderer authored mesh channels
use this contract and copy the data before CreateMesh returns; callers may
release their storage after that call. Ownership-transfer APIs instead use a
Ludus-owned owner. Shared/weak pointer facilities remain unimplemented and are
not required for these synchronous borrowed inputs.