Skip to content

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.

Customer root · active Order spawned · active Line Item passive orders association line_items composition customer (back-ref) order (back-ref) order_count ← aggregate count(orders) order_total ← aggregate sum(line_items.line_value)
The two one-to-many edges differ in kind: Customer→Order is an association (Orders drive themselves), Order→Line Item is a composition (Line Items are passive and driven by their Order). Each child carries a back-reference that places it in the parent’s collection; the parent folds that collection into a field with the aggregate action.

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.

EdgeKindChild runs its own lifecycle?Modelled with
Customer → OrderAssociationYes — 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 ItemCompositionNo — 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 Order

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.

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: instant activates the spawned row the same turn, so it is immediately steppable, visible to for_each, and — crucially — visible to collection reads. (A deferred spawn stays pending and activates at the start of the next turn.)
  • bind: ord names the freshly-created Order so later actions in the same list can refer to its handle.
  • references: { order: { bound: ord } } wires each Line Item’s order back-reference to the bound Order. That back-reference is what files the Line Item into ord.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.

aggregate folds a collection of children into a single field on the parent. It is a map-reduce over the collection with four operators:

operatorreads child_field?writesempty-collection identity
countNo (type-agnostic)number of children0
sumYesΣ of each child’s child_field0
maxYeslargest child value0
minYessmallest child value0

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.

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.

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:

  1. Turn T — the Customer steps and runs place_order. The Order and its two Line Items are created instant, 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.
  2. 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 once
pending:
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 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 tally

Counting 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.

  1. Decide each one-to-many edge’s kind: association (active child) or composition (passive: true child).
  2. Declare a collections: slot on the parent for the child type.
  3. Give the child a reference back to the parent — that back-reference, set at spawn, is what files the child into the collection.
  4. To create a parent and its children together, spawn them in one action list with visibility: instant, bind the parent, and wire each child with references: { <ref>: { bound: <name> } }.
  5. Put the aggregate in an event or handler of the collection’s owner — never of a different entity.
  6. 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.
  7. Match types: sum/max/min need 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.