Store Views and Lone Packing: Product vs. Sum in Koru Stores
The previous post left a door deliberately propped open: protos describe relationships between concepts without touching layout or memory. In std/list, popped values flow as pure scalars from synthesized shapes. But a program that needs persistent, queryable data in a Structure-of-Arrays (SoA) layout turns to std/store.
When a store takes a proto, what happens to memory?
If a store declares two placements of a proto—like left: Limb and right: Limb—does it fold identical shapes into a single column? If two distinct stores share a concept like Strength, when do they share an array, and when do they stay separate?
The answer rests on an algebraic boundary: a lone store is a product type; a store view is a sum-type projection. A single store packs independent placements into flattened, path-named leaves. A store view joins distinct stores across a sum boundary, reading shared leaves through a polymorphic query vocabulary — without claiming a byte of storage itself.
Lone stores pack: placements dissolve into path leaves
When you instantiate a store with std/store:new, every field in its seed block is a member of a product type. All fields exist simultaneously.
If a compound contains another compound, the nested proto does not allocate a secondary struct or store a pointer. It dissolves. The compiler recursively expands the compound into flat, scalar leaves named by their DAG path from the store root:
import std/io
import std/proto
import std/store
std/proto:int(Health)
std/proto(Limb) {
health: Health
}
std/store:new(body, capacity: 8) { left: Limb, right: Limb }
std/store:insert(body) { left.health: 10, right.health: 20 }
std/store:query(body)
! query e |> std/io:print.ln("left {{ e.left.health:d }}")
std/store:query(body)
! query e |> std/io:print.ln("right {{ e.right.health:d }}") The body store does not contain two Limb records. In emitted code, body is an SoA container with two parallel scalar arrays: left.health and right.health.
When querying the store, e.left.health is a direct read from a contiguous column array. There is no pointer chasing, no indirection, and no intermediate record. Most importantly, left and right do not fold into a single health column with an occupancy mask. They are two distinct placements in a product type, so they pack side-by-side into two distinct column arrays.
Shared names in one store do not fold
What if two completely different protos happen to have a field with the same name and scalar type?
Suppose Foo has x: i64, and Bar has x: i64. If a lone store declares { foo: Foo, bar: Bar }, does it fold x into one column?
No. In a product type, two concepts are two concepts. Coincidental naming does not trigger layout sharing:
import std/io
import std/proto
import std/store
std/proto(Foo) {
x: i64
}
std/proto(Bar) {
x: i64
}
std/store:new(pair, capacity: 8) { foo: Foo, bar: Bar }
std/store:insert(pair) { foo.x: 3, bar.x: 7 }
std/store:query(pair)
! query e |> std/io:print.ln("foo {{ e.foo.x:d }}")
std/store:query(pair)
! query e |> std/io:print.ln("bar {{ e.bar.x:d }}") The store allocates foo.x and bar.x. Even though both fields are named x and both hold an i64, they occupy distinct positions in the product. Coincidental naming is not a join — sharing has to be declared.
Store views: sum types and projection
If lone stores pack product types, where does the sum surface?
It happens in store views (std/store:view). A store view is the projection join over multiple stores — a read surface that registers a shared query vocabulary without claiming any storage of its own.
Each store packs its own root proto: std/store:new(Player) packs Player, and std/store:new(Enemy) packs Enemy. When the seed names the proto bare ({ Player }), the leaves sit directly at the store root (str, not Player.str).
A store view brings these stores together:
import std/io
import std/proto
import std/store
std/proto:int(Strength)
std/proto:int(Mana)
std/proto:int(Armor)
std/proto(Player) {
str: Strength
mana: Mana
}
std/proto(Enemy) {
str: Strength
armor: Armor
}
std/store:new(Player, capacity: 8) { Player }
std/store:new(Enemy, capacity: 8) { Enemy }
std/store:view(Entities) {
Player
Enemy
}
std/store:insert(Player) { str: 18, mana: 12 }
std/store:insert(Enemy) { str: 10, armor: 5 }
std/store:query(Entities)
! query e |> std/io:print.ln("entity {{ e.str:d }}")
std/store:query(Entities)
! query e when e is Player |> std/io:print.ln("player {{ e.str:d }} {{ e.mana:d }}")
std/store:query(Entities)
! query e when e is Enemy |> std/io:print.ln("enemy {{ e.str:d }} {{ e.armor:d }}") Here, std/store:view(Entities) creates the sum-type projection:
- Polymorphism without type discrimination: Both
PlayerandEnemydeclarestr: Strength. Because every member of the view shares this leaf,! query e |> ... e.str ...requires nowhenguard at all. The query sweeps across all member stores in succession, executing the identical body overPlayerandEnemyrows alike. - Shared leaves meet in one projection: Because both protos share the same path from root (
str) and the exact same registered terminal (Strength),Entitiesreadsstrthrough a single unified query projection — one body, fed from each member’s array in turn. - Private leaves stay accessible:
Playerhasmana: Mana, andEnemyhasarmor: Armor. When you need a private leaf, narrowing the arm withwhen e is Playerproves access at compile-time while executing exclusively againstPlayer’s backing store. - The kind vocabulary is never stored: The order of member stores forms a 1-based vocabulary (
1 = Player, 2 = Enemy). Theisguard lowers to an integer tag comparison the compiler resolves to a constant per member loop — no tag column, no discrimination cost (the emitted shape below shows it).
The emitted shape
“Two parallel scalar arrays” is not a figure of speech. The lone store compiles to a Zig struct whose fields are the leaves, named by their DAG path from the store root:
const __KoruStoreT_body = struct {
@"left.health": [8]Health = undefined,
@"right.health": [8]Health = undefined,
len: usize = 0,
// …row-handle bookkeeping (slot maps, generations) elided…
};
var __koru_store_body: __KoruStoreT_body = .{}; There is no Limb in that struct and no pointer anywhere in it — just two arrays side by side, one per placement. e.left.health is a direct index into the first.
The sum-type join is just as literal. The unguarded view query compiles to two loops that feed the same body handler from the same projection:
for (0..main_module.__koru_store_Player.len) |__koru_si| {
const __koru_srf_e_L41_str = main_module.__koru_store_Player.str[__koru_si];
main_module.__store_sweepbody_Entities_L41_event.handler(.{
.__koru_srf_e_L41_str = __koru_srf_e_L41_str,
.__koru_sdix_e_L41 = @as(i64, @intCast(__koru_si))
});
}
for (0..main_module.__koru_store_Enemy.len) |__koru_si| {
const __koru_srf_e_L41_str = main_module.__koru_store_Enemy.str[__koru_si];
main_module.__store_sweepbody_Entities_L41_event.handler(.{
.__koru_srf_e_L41_str = __koru_srf_e_L41_str,
.__koru_sdix_e_L41 = @as(i64, @intCast(__koru_si))
});
} Player.str first, then Enemy.str, threaded into the identical handler input: one projection, two stores. And the is guard is not runtime discrimination over a merged array — the compiler knows which loop it is in:
for (0..main_module.__koru_store_Player.len) |__koru_si| {
const __koru_srf_e_L44_str = main_module.__koru_store_Player.str[__koru_si];
const __koru_srf_e_L44_mana = main_module.__koru_store_Player.mana[__koru_si];
const __koru_srf_e_L44_kind = 1;
if (!(__koru_srf_e_L44_kind == 1)) continue;
main_module.__store_sweepbody_Entities_L44_event.handler(.{
.__koru_srf_e_L44_str = __koru_srf_e_L44_str,
.__koru_srf_e_L44_mana = __koru_srf_e_L44_mana,
.__koru_srf_e_L44_kind = __koru_srf_e_L44_kind,
.__koru_sdix_e_L44 = @as(i64, @intCast(__koru_si))
});
} when e is Player becomes const kind = 1; if (!(kind == 1)) continue; — a constant and a compare the optimizer sees through. The guard is a proof the loop already satisfies; the emitted tag check is its receipt.
(Identifiers are as emitted; whitespace is prettified; _ = &…; no-ops and row-handle bookkeeping are elided. Source: 690_285/690_287 output_emitted.zig, extracted this session.)
Type consistency across the view
Because shared leaves meet in one projection, the compiler enforces strict type identity across all member stores:
- Two members cannot declare the same leaf with different types.
690_290pinsEnemydeclaringstr: Wobble(afloatterminal) againstPlayer’sstr: Strength— the view declaration fails at compile time:error[KORU161]: std/store:view(Entities): member 'Enemy': leaf 'str' as 'Wobble' collides with 'Strength' — one bare name, one identity; type-divergent same-name leaves across a view are refused - A private leaf is reachable only under its kind. Reading
e.manaacrossEntitieswithout narrowing the guard is refused — the compiler rejects the access as an unproven member read:error[KORU161]: std/store:query(Entities): 'e.mana' is Player-only — narrow the guard (`when e is Player`) first
Product vs. Sum: The Rule in Full
The complete ruleset for Koru’s store surfaces is now simple and symmetric:
| Context | Type Category | Mechanism | Behavior |
|---|---|---|---|
std/store:new | Product Type | Lone Store Packing | Dissolves compounds into flat, path-named leaves (left.health, right.health). Placements never fold. |
std/store:view | Sum Projection | Store View Joining | Reads shared leaves (str) across stores through one query vocabulary. Kind never materializes — a per-member constant. |
Protos name relationships between concepts without layout. Lone stores pack those concepts into flat SoA arrays. And store views read them across sum boundaries for polymorphic iteration—without pointers, without boxing, and without a single byte of shared storage.
How std/store differs from an ECS framework
The obvious comparison is an ECS — Bevy in Rust, EnTT in C++. The differences are not “ours is faster.” They are what the layout question is allowed to be.
In Bevy, the storage layout is a decision the library made once, and every program inherits it. Kinds are marker components, so narrowing to Player is a With<PlayerMarker> filter and a runtime archetype match — the guard is a query, not a tag. Shared leaves never fold: Str lives in both the Player and Enemy archetypes, and no Rust program can say “put these in one extent.” Private leaves are Option<&Mana> everywhere, unwrapped by hand. Two systems that want to write overlapping sets fight the borrow checker — or panic at runtime. And a union — one active kind sharing memory — is not a feature of the framework at all; it is something you hand-write, differently, every time.
In Koru, the layout is a declaration the program makes, and the language’s ordinary library machinery materializes exactly what was declared. A view is a projection. A set is a pool. A union is an overlap in time. The juggling is not cleverness a framework saved you — it is relocated into a surface that is typed and checked.
And the last difference is the one that changes what the comparison means: std/store is not a compiler feature. It is a user-space library — koru_std/store.kz, written in Koru, using the same comptime transform mechanism any Koru library can use. std/store:view is ordinary library code, not a keyword the compiler was taught. Bevy is a framework that had to build its own world inside Rust’s type system; std/store is a library the language made writable. That is the difference to say out loud: not “Koru wins,” but — in Koru, the ECS is something you could have written.