Many-to-One Relationships
A purchasing domain is a natural one-to-many: a customer places many orders, and
an order contains many line items. This article shows how to model that hierarchy
in Mock Machines using collections, compositions, and the aggregate
action — and how to fold the children back into a parent (an order’s total, a
customer’s order count) correctly. Every snippet is drawn from the
OnlineShopping simulation.
The domain: Customer → Order → Line Item
Section titled “The domain: Customer → Order → Line Item”The purchasing model has three entity types in a strict hierarchy. A Customer
places Orders; each Order holds several Line Items. We want two derived
figures to be exact at the end of a run: each Order’s order_total (the sum of its
line items’ values) and each Customer’s order_count (how many orders it placed).
Both are computed by folding a collection of children into a parent field.
Associations vs. compositions
Section titled “Associations vs. compositions”Both edges are one-to-many collections, but they model different lifecycles. The distinction is whether the child runs its own state machine or is driven entirely by its parent.
| Edge | Kind | Child runs its own lifecycle? | Modelled with |
|---|---|---|---|
| Customer → Order | Association | Yes — an Order selects a warehouse, requests fulfilment, gets shipped, etc. It is stepped every turn. | Collection on Customer; Order is an ordinary active machine. |
| Order → Line Item | Composition | No — a Line Item is created, ref-wired, exported, and message-driven, but never selected/aged/stepped. Its Order drives it. | Collection on Order; Line Item declared passive: true. |
A passive entity is the engine’s representation of a composition child. It still lives in the columnar store, still receives messages, and still appears in its own exported CSV — but the turn loop never picks it for a probability roll. This is exactly the semantics you want for a line item: it has data (a value, a quantity) but no independent behaviour.
Line Item: passive: true # never stepped by the turn loop; driven by its Order init_state: active reference: - { name: order, entity: Order } # back-reference to the owning OrderDeclaring collections and wiring children
Section titled “Declaring collections and wiring children”A collection is declared on the parent with a collections: block — a named
slot that accepts a given child type. The Customer collects Orders; the Order
collects Line Items:
Customer: collections: - { name: orders, entity: Order } # ...
Order: reference: - { name: customer, entity: Customer } # back-ref into Customer.orders - { name: warehouse, entity: Warehouse } collections: - { name: line_items, entity: Line Item } # ...Membership is not assigned explicitly. When a child is spawned, the engine reads
the child’s back-reference and, if the referenced parent has a collection slot
that accepts this child type, appends the child to that collection automatically.
So an Order whose customer reference points at Customer C lands in C.orders; a
Line Item whose order reference points at Order O lands in O.line_items.
Building a composite atomically
Section titled “Building a composite atomically”An Order and its Line Items should come into existence together — an order with no
line items is meaningless. The Customer’s place_order event creates the whole
composite in a single action list, this turn, using three features:
visibility: instant, a bind name, and a bound reference.
place_order: actions: # 1. Create the Order now (instant), and remember its handle as `ord`. - { type: new_entity, entity: Order, visibility: instant, bind: ord, references: { customer: self } } # 2. Create two Line Items now, each pointing its `order` back-ref at `ord`. - { type: new_entity, entity: Line Item, visibility: instant, references: { order: { bound: ord } } } - { type: new_entity, entity: Line Item, visibility: instant, references: { order: { bound: ord } } }visibility: instantactivates the spawned row the same turn, so it is immediately steppable, visible tofor_each, and — crucially — visible to collection reads. (A deferred spawn stays pending and activates at the start of the next turn.)bind: ordnames the freshly-created Order so later actions in the same list can refer to its handle.references: { order: { bound: ord } }wires each Line Item’sorderback-reference to the bound Order. That back-reference is what files the Line Item intoord.line_items.
The Order’s own customer: self reference points back at the Customer doing the
placing, so the Order simultaneously lands in that Customer’s orders collection.
One event, one atomic composite, fully wired in both directions.
The aggregate action
Section titled “The aggregate action”aggregate folds a collection of children into a single field on the parent. It is
a map-reduce over the collection with four operators:
| operator | reads child_field? | writes | empty-collection identity |
|---|---|---|---|
count | No (type-agnostic) | number of children | 0 |
sum | Yes | Σ of each child’s child_field | 0 |
max | Yes | largest child value | 0 |
min | Yes | smallest child value | 0 |
The order total is an aggregate sum over the Order’s line items, reading each
item’s line_value into the Order’s order_total measure:
- { type: aggregate, collection: line_items, child_field: line_value, field: order_total, operator: sum }The four fields are: collection (the slot name on the parent), child_field (the
field read on each child — omit for count), field (the target field on the
parent), and operator. The result is written with set semantics — the same
primitive an apply: all family fold uses — so re-running the aggregate simply
overwrites the target with the current fold; it never accumulates.
Aggregation is owner-relative
Section titled “Aggregation is owner-relative”This is the single most important fact for placing an aggregate correctly: the
action always folds the collection of the entity currently being stepped into a
field of that same entity. There is no “target entity” parameter. The
collection and field names resolve against the stepping entity’s own machine.
A tempting mistake is to append the order-total fold to the Customer’s
place_order event — “recalculate while I’m creating the line items.” That cannot
work: in place_order the stepping entity is the Customer, which has neither a
line_items collection nor an order_total field. Those belong to the Order.
The fold must therefore run on the Order, in one of the Order’s own events or
handlers.
Timing: visibility and when to fold
Section titled “Timing: visibility and when to fold”A collection read only sees active children. The fold therefore has to run after the line items have activated. The instant-composite construction makes this easy to reason about:
- Turn T — the Customer steps and runs
place_order. The Order and its two Line Items are createdinstant, so they activate mid-turn. Because the Order was created mid-turn, it is not in turn T’s step snapshot — it will first be stepped next turn. - Turn T+1 — the Order steps for the first time. Its two Line Items have been
active since turn T, and they are in
line_items. Any aggregate the Order runs now sees both of them.
Because line_value is derived once at spawn (via func: from_catalog off the
sampled product’s price) and never mutated afterwards, the fold is
order-insensitive: folding on the Order’s first step gives the same answer as
folding later. That removes the usual reason to defer a recalc and lets us bundle
the fold into the Order’s first real action.
Pitfall: don’t fold in a probabilistic race
Section titled “Pitfall: don’t fold in a probabilistic race”Here is a real bug this scenario once shipped with. The order total was folded by a
dedicated recalculate event that competed with select_warehouse in the same
state’s probability roll:
# BROKEN: recalculate (0.35) competes with select_warehouse (0.6)pending: events: - name: select_warehouse # one-way door: leads to states with no recalc actions: [ ... ] - name: recalculate actions: - { type: aggregate, collection: line_items, child_field: line_value, field: order_total, operator: sum } probabilities: # [stay, select_warehouse, cancel, recalculate] - [0.0, 0.6, 0.05, 0.35]Each turn the Order rolls one event. select_warehouse is a one-way door — it
kicks off the message flow that moves the Order into states that have no
recalculate event at all. Since selecting (0.6) is far likelier than
recalculating (0.35) on any given turn, most Orders leave pending before they
ever roll the fold, and their order_total stays at its init: 0.0. In practice
~70% of orders ended with a zero total — the aggregate was correct wherever it
ran, but it rarely got the chance to run.
The fix is to stop modelling a required derived value as an optional
probabilistic event. Bundle the fold into the action that every order already
takes — select_warehouse — and delete the racing event:
# FIXED: the fold is part of placing the order, guaranteed to run oncepending: events: - name: select_warehouse actions: - { type: set_reference, reference: warehouse, entity: Warehouse, match_field: region } - { type: emit_message, target: warehouse, message: PlaceOrder, payload: { product: self.product } } # Fold line items into order_total as part of placing the order. - { type: aggregate, collection: line_items, child_field: line_value, field: order_total, operator: sum } - name: cancel actions: - { type: transition, state: cancelled } probabilities: # [stay, select_warehouse, cancel] - [0.0, 0.95, 0.05]After the change every placed order folds its line items exactly once; the only
zero-total orders left are the deliberately cancelled ones, for which a zero
total is correct.
The two-phase count pattern
Section titled “The two-phase count pattern”The Customer’s order_count needs the same care, but the timing concern is the
opposite: a Customer keeps placing orders for several turns, so a count taken
mid-stream would be a running snapshot, not the final total. The scenario solves
this with a two-phase lifecycle — place while shopping, count once closed:
Customer: states: shopping: events: - name: place_order # creates Order + Line Items (the composite above) actions: [ ... ] - name: close_account actions: - { type: transition, state: closed } # probability of close_account rises with Age
closed: # terminal: no more orders are placed events: - name: tally_orders actions: - { type: aggregate, collection: orders, field: order_count, operator: count } probabilities: - [0.0, 1.0] # always tallyCounting in a terminal state, after the last order is placed, makes order_count
equal to the true number of Orders the Customer owns. Re-tallying every turn in
closed is harmless: count is an idempotent set, so once every instant-created
Order has activated, the result is stable. Note count needs no child_field — it
is type-agnostic and just counts collection members.
Authoring checklist
Section titled “Authoring checklist”- Decide each one-to-many edge’s kind: association (active child) or
composition (
passive: truechild). - Declare a
collections:slot on the parent for the child type. - Give the child a
referenceback to the parent — that back-reference, set at spawn, is what files the child into the collection. - To create a parent and its children together, spawn them in one action list
with
visibility: instant,bindthe parent, and wire each child withreferences: { <ref>: { bound: <name> } }. - Put the
aggregatein an event or handler of the collection’s owner — never of a different entity. - Make the fold deterministic: bundle it into a guaranteed action, or run it in a terminal state. Never let it compete in a probability roll against a state exit.
- Match types:
sum/max/minneed numeric child and target; a float child into an int target is rejected at load.
Verify the result against the exported CSVs — join the child table to the parent
table and confirm the fold matches. For OnlineShopping, every non-cancelled
order_total should equal the sum of its line items’ line_value, with zero
mismatches.