Rileycity Economic Engine
Core design · v1

Rileycity — Core Economic Engine Design

Recipes, a double‑entry ledger, posted‑price markets and a deterministic tick pipeline — built so that every industry is data over one set of generic machinery.

13nouns
3verbs
17goods
12processes
9agents
4policies

Status: implementation-ready. This document is meant to be transcribed into code, not interpreted. Target: Python 3.13 (managed with uv), pure data-in/data-out core, no I/O in core/. Scope of this deliverable: the "toy" — recipe/supply-chain infrastructure, a ticking world in which agents produce, trade with one another and sell to a household sector, driven from a REPL, with run history dumped to a browser page. Logistics/geography, real estate, individual sims, hiring contracts, finance and government are later; the seams for them are named in §12 and nothing in this design has to be torn up to add them.


0. Reading guide

Section What it settles
§1 The Key Rule, the final noun/verb list, every collapse and every deliberate non-collapse
§2 The conceptual model: accounts, moves, what value means, where profit lives, what "double-entry" buys us
§3 Numeric conventions (Decimal, quanta, allocate)
§4 Module layout and dependency direction
§5 Every class, field, method, invariant
§6 The tick pipeline and a hard validation of the phase order (labor loop, tick-0 bootstrap)
§7 The reactive policies in precise pseudocode, including the single shared planning calculation
§8 Failure modes, the invariant assertions, world.audit()
§9 The seed economy as data, with the arithmetic that shows it balances
§10 REPL command surface and report/chart contents
§11 Day-one test plan
§12 Staged build order and the seams for later systems
§13 Notes for MMO scale (determinism, persistence, concurrency, tick cost)
§14 Appendix: tooling, Python 3.13 conventions, category glossary

Throughout, bold "Collapsed:" paragraphs mark where N concepts became 1 and bold "Kept separate:" paragraphs mark where two things were deliberately not merged. §1.4 and §1.5 collect them.


1. The Key Rule and the model that serves it

1.1 The rule

Minimal to no special functions and calculators. Everything unified, in the same setup. Industries are pure data over generic machinery.

The test applied to every part of this design: if implementing it would require if agent.kind == ..., if good.id == ..., a per-industry method, or a per-good calculator, the design is wrong and was changed. The remaining places where a rule branches on data are enumerated in §1.6 with justification, so nothing is hidden.

1.2 Nouns (final)

There are thirteen nouns in core/. Everything else is data.

Noun One sentence
Good A kind of thing that can sit in an account: id, unit, shelf_life, base_value, quantum, tags. Money is a Good. Labor is a Good. Land is a Good. A person is a Good.
Lot An immutable parcel of one good in one account: qty, cost (total cost basis), expires (tick or None), seq.
Account An ordered container of lots, keyed by good. FEFO removal, exact cost splitting, snapshot/restore. Agents have main and wip; the world has void and sink.
Move The one primitive that changes any account: (good, qty, src, dst, value?, expires?, category). Nothing else mutates an account.
Entry An applied Move with the facts the ledger discovered while applying it: cost (basis that left src) and value (basis that entered dst).
Transaction An atomic, ordered list of Entries, committed whole or not at all, tagged with tick, phase and kind.
Ledger The append-only log of Transactions and the only code path that applies Moves.
Line (good, qty, rate): a process input or output line. qty gates and scales the run; rate is the fraction destroyed per run.
Process Inputs, outputs, duration, step. Physics, not behavior. A process is not owned by a firm type.
Agent Two accounts + a list of process ids + a list of policies + params + memory + pending runs. There is exactly one Agent class.
Market The offer book, per-good trade statistics, the reference price, and buy execution.
Intent / Policy A Policy proposes Intents for a phase (Produce, PostOffer, Buy, Transfer); the World executes them in deterministic order.
World The registry of goods/processes/agents, the two world accounts, the ledger, the market, the tick loop, per-tick flows, history, audit.

(Run, Offer, Flows, History are small records that belong to Agent, Market and World respectively; they are listed under their owners in §5.)

1.3 Verbs (final)

There is one primitiveMove — and three transaction shapes built from it, living in one module (core/verbs.py):

Verb Moves Value semantics
transfer 1 Carry (basis travels with the goods) unless the builder gives an explicit value. Used for endowment (void → agent), expiry (agent → sink), dividends (agent → agent), and later taxes/gifts.
exchange 2 Goods seller → buyer re-based at qty × price; money buyer → seller carried. This is the only place value is created (seller's gross profit) in the system.
transform N Start: inputs main → wip (carry). Complete: consumed wip → sink (carry, releasing cost), returned wip → main (carry), outputs void → main with value = released cost allocated by relative base value. Net value change to the agent is exactly zero.

Correction to the sketch (B): the sketch said "exactly two verbs" while also listing spoilage and endowment as things that move goods. Those are moves; pretending they are not would have forced special-case code in the expiry sweep and in world seeding. transfer is not a third special case — it is the degenerate base case (one move), and exchange is literally two coupled transfers. Naming it makes expiry, endowment, dividends (which the model needs to close the money loop, §6.4) and later taxes a single code path instead of four. This is a unification, not an exception.

1.4 Collapsed: N concepts → 1

  1. Process modes (consume / catalyst / degrade / extract / labor) → Line.rate. rate=1 consumes, rate=0 is a catalyst returned intact, rate=0.002 is wear, and depletion of a forest is a rate on a land-like good. Extraction is not a mode; it is a rate=0 or slowly-depleting input that somebody holds.
  2. Service / storable / perishable flags → Good.shelf_life. None never expires; n expires at the settle of tick created + n. Nothing in core reads an is_service boolean; §6.2 explains why services and labor are shelf_life=1, not 0.
  3. Firm / Household / Farm / Retailer classes → one Agent. A sawmill, a poultry farm, a restaurant and the household sector differ in processes, params and endowments only.
  4. Labor market ↔ goods market → one Market. Labor is a good with shelf_life=1, sold by the household through PostAsks, bought by firms through BuyInputs.
  5. Consumer demand function → the household's living process. Demand for meals is the household's input requirement to produce labor; it is price-responsive through the ordinary BuyInputs max-price and cash budget, and it collapses to zero special code.
  6. Consumable vs capital procurement → one formula. need(line) = qty × scale × max(1, rate × cover). For rate=1 that is cover ticks of consumption; for rate=0 it is the amount that must be present; for wear it is the amount present (replacement emerges as the stock wears below it).
  7. Revenue / COGS / spoilage / depreciation accounts → derived from Entry (cost, value, category). No P&L account is stored; every P&L line is a filter over entries (§2.5).
  8. Creation / destruction APIs → void and sink accounts. Endowment, production output, spawning from the REPL, consumption and spoilage are all ordinary moves through the same FEFO code path. Per-good quantity summed over every account (void and sink included) is constant.
  9. Money class / cash field → the good money. Cash is main.qty("money"); a payment is a Move.
  10. Demand estimation per good/industry → one EMA over ledger flows with one censoring rule (§7.2).
  11. Production planning ↔ procurement planning → one Agent.plan() that computes desired scale and requirements once per tick; every policy reads the plan.
  12. Bootstrap seeding → transfer from void, whose lots are pre-funded at base value so a value-carrying move endows at base value with no extra code.
  13. Rounding everywhere → one allocate(total, weights) used for joint-cost allocation, partial lot splits, re-basing across lots, and budget scaling.
  14. Per-agent sink → world sink. Per-agent spoilage is a filter on flows; a per-agent account added nothing and made conservation a sum over 3N accounts instead of 2N+2.

1.5 Kept separate, deliberately

  1. transform vs exchange vs transfer. Their value semantics differ (conserve / re-base / carry). Merging them would put the difference into flags on Move, which is the same thing with worse names.
  2. main vs wip. WIP is what makes multi-tick processes and catalyst occupancy real (an oven inside a 3-tick run cannot run another batch) and what makes "a run nets to zero" checkable. A single account with a "committed" flag per lot is the same state with a special case in every removal.
  3. void vs sink. Conservation works with one "nature" account; two are kept because void.qty and sink.qty are the "ever created" and "ever destroyed" counters for free and it makes the audit report legible.
  4. Offer book vs Ledger. Offers are intentions, not value. They never touch accounts; only the resulting exchanges do.
  5. Quantity quantum vs money quantum. Money is quantized to 0.01 and other goods to 1e-6. This is data (Good.quantum), not a branch.
  6. Process (physics) vs Agent.params (behavior). A recipe is a fact about the world; a markup is a fact about a firm. Putting markups on processes would make two restaurants with different pricing need two recipes.
  7. Line for inputs and outputs, same class. Outputs carry rate=1 and validation rejects anything else. One class with a field that is constrained on one side beat two classes.
  8. plan() (once per tick, world-driven) vs policies (per phase). Policies are stateless functions of (agent, world); the plan is the one piece of per-tick derived state, and computing it once removes an otherwise-recursive dependency (§7.1).

1.6 Rules that branch on data (the complete list)

These are not special cases in the if firm.type == ... sense, but they are the places where behavior depends on a value, listed so nothing is hidden:

1.7 Special cases we refused to create, and what absorbed them

Refused Absorbed by
harvest(), layEggs(), cookMeal(), cull() Rows in the process table
if agent.is_household params["min_scale"] == params["max_scale"] == population and a person catalyst line
Firm.hire() / wage logic BuyInputs buying the good labor
Consumer.demand(price) The living process + BuyInputs max price + cash
depreciate() equipment input line with rate=0.002
spawn_goods() transfer from void
Inventory.write_off() transfer to sink in the settle sweep
A Money class or cash: Decimal on Agent Lots of the good money in main
Separate LaborMarket Market
is_capital, is_consumable flags in procurement max(1, rate × cover)
Per-recipe cost allocation allocate(released, [qty_i × base_value_i])
A Dividend transaction type transfer with category dividend
Joint-product "primary vs by-product" flags Relative base value weights
An Extraction process kind land/forest held as rate-0 / slow-rate inputs
A mint / money-creation function transfer from void of the good money (REPL/government seam)

2. Conceptual model

2.1 Accounts and lots

An Account holds lots. A Lot is (good, qty, cost, expires, seq): cost is the total cost basis of the lot (not per unit), expires is the tick at whose settle the lot is swept to sink (None = never), seq is a monotone insertion sequence used for ordering. Lots are immutable; splitting a lot creates new lots.

Within an account, the lots of one good are ordered by (expires is None, expires, seq): soonest-expiring first, durables last, insertion order among equals. Removal takes from the front. This is FEFO, which equals FIFO for everything an agent produces itself and is the correct behavior when it differs.

Every agent has: - main — everything it owns and can use, sell or consume, including its money. - wip — inputs committed to runs in progress. Part of the agent's net worth. Never offered, never swept.

The world has: - void — the source of everything. Pre-funded at init with VOID_QTY (10¹²) of every good, each lot at cost = qty × base_value, expires = None. - sink — where consumed and expired goods go. Grows forever; it is the "ever destroyed" counter.

Quantity conservation is structural: the only mutator of any account is Cursor.move, which removes exactly qty from src and adds exactly qty to dst. Therefore for every good g, Σ_all accounts qty(g) == VOID_QTY at every instant between transactions, with no code checking it. world.audit() checks it anyway, as a test of the inventory code, not as a guard.

2.2 Value semantics of a Move

A Move requests (good, qty, src, dst, value=None, expires=CARRY, category). When applied, the ledger discovers cost = the total basis of the lots that left src (FEFO), and sets value = the total basis of the lots that enter dst:

Both numbers are recorded on the Entry. value − cost is the value created (or destroyed) by the move from the point of view of the pair of owners, and it is nonzero in exactly two situations: the goods leg of an exchange (seller's gross profit), and a production output (which is always exactly matched by the consumed cost that left through sink, so the run nets to zero).

The dst lots' expires is carried from the src lots unless the builder overrides it. Builders override it exactly when the goods are born: production outputs and endowments from void, which get expires_for(good, tick).

2.3 Net worth and where profit appears

net_worth(agent) = Σ lot.cost over main ∪ wip (money lots have cost == qty, so cash is included at face value).

Per Entry, the change in total agent net worth is value·[dst is an agent account] − cost·[src is an agent account]. Summing over a transaction:

Transaction Σ ΔNW over all agents
transfer void → agent (endowment) +value (equity contribution)
transfer agent → sink (expiry) −cost (spoilage expense)
transfer agent → agent (dividend, money) 0 (moves between agents)
exchange +(amount − COGS): seller gains amount − cost, buyer nets 0
transform, start 0 (main → wip within one agent)
transform, complete 0 (released cost leaves via sink, identical total re-enters as outputs); −released if the process has no outputs

So profit is created at the moment of sale, as price × qty − FEFO cost of the lots delivered, and destroyed at expiry. That is the correct economics: goods are conserved as quantities, money is conserved as a quantity, but value is re-marked at every trade — that re-marking is profit. There is no global "value conservation" invariant and there should not be one; the invariant that does hold is the trial balance in §2.5.

2.4 Money

money is a Good with base_value = 1, quantum = 0.01, shelf_life = None. Its lots carry cost == qty always, because: void lots are funded at base_value = 1; every money move in the system is a carry (value=None); money is forbidden as a process output (validation rule in Process), so no allocated cost can ever land on a money lot. audit() asserts cost == qty for every money lot as a cheap check on that argument.

Money as an input to a process is allowed (fees, later taxes-as-process) — it flows to sink like anything else and conservation holds.

2.5 What "double-entry" means here, precisely

Each Entry is a journal line Dr dst / Cr src in the quantity dimension (always balanced) and in the value dimension (balanced by construction for carries; for a re-based move the gap value − cost is the recognized gain/loss and is recorded on the entry). Every agent's balance sheet is main + wip; every agent's income statement is derived by filtering entries by category and owner:

Revenue(a)     = Σ value  of entries {category=sale,    src.owner=a}
COGS(a)        = Σ cost   of entries {category=sale,    src.owner=a}
Spoilage(a)    = Σ cost   of entries {category=expire,  src.owner=a}
ProdVariance(a)= Σ cost   {category=consume, src.owner=a} − Σ value {category=output, dst.owner=a}   # 0 unless a zero-output process ran
DividendsIn(a) = Σ value  {category=dividend, dst.owner=a};  DividendsOut(a) = Σ cost {category=dividend, src.owner=a}
Endowment(a)   = Σ value  {category=endow, dst.owner=a}
NetIncome(a)   = Revenue − COGS − Spoilage − ProdVariance + DividendsIn − DividendsOut
Trial balance:   NW(a, t) − NW(a, 0) == Σ_{ticks ≤ t} (NetIncome(a) + Endowment(a))

The trial balance is the value invariant and audit() checks it for every agent every tick. It holds to the cent because every number on the right is an entry field and every number on the left is a lot field, and lots are only ever written by entries.

What this gives us: no leaks or duplication of goods or money; exact FEFO cost basis, COGS and gross margin; absorption costing (labor bought becomes inventory, then WIP, then product cost; idle labor that expires is a spoilage expense, which is the honest name for it); a complete, replayable audit trail; per-agent balance sheet and P&L at any tick as pure functions of the log.

What it does not give us (and is not pretending to): accrual accounting — there are no receivables, payables or liabilities because every trade settles instantly in cash. Liabilities arrive with credit (§12.3) as accounts with sign = −1; nothing here has to change for that. Also, no revaluation: inventory is carried at cost, never marked to market, which is the conservative and correct choice for a simulation whose prices are endogenous.

2.6 The economy in one paragraph

Every tick, each agent computes a plan (how much of each process it wants to run, and what it therefore needs to hold). It posts asks for what it makes, buys what it needs, runs its processes, and settles. The household is an agent whose one process turns meals, coffee, eggs and chair-wear into labor and whose catalyst is person; it sells labor and buys groceries through the same policies as every firm. Firms pay the household dividends from cash above a reserve, which closes the money loop. Prices are sticky and move on sell-through; demand estimates are EMAs of sales plus own consumption, corrected for stockouts. Nothing else exists.


3. Numeric conventions (core/numeric.py)

from decimal import Decimal, ROUND_DOWN, getcontext
getcontext().prec = 34            # set once at import of core.numeric; ample for 1e12 × 1e4 with 6 places

D = Decimal                       # the only constructor used in content; never construct Decimal from float
ZERO, ONE = D(0), D(1)
QTY_Q   = D("0.000001")           # default Good.quantum
MONEY_Q = D("0.01")               # money's quantum and the quantum of every `cost`/`value`
VOID_QTY = D("1000000000000")     # 1e12 of every good pre-funded in void

def q(x: Decimal, quantum: Decimal = QTY_Q) -> Decimal:
    """Round DOWN to a multiple of quantum. Used for every derived quantity (qty × scale, qty × rate)."""
    return x.quantize(quantum, rounding=ROUND_DOWN)

def is_exact(x: Decimal, quantum: Decimal) -> bool:
    return x == x.quantize(quantum)

def floor_to_step(x: Decimal, step: Decimal) -> Decimal:
    """Largest multiple of step that is <= x (x >= 0)."""
    return (x / step).to_integral_value(rounding=ROUND_DOWN) * step

def allocate(total: Decimal, weights: list[Decimal], quantum: Decimal = MONEY_Q) -> list[Decimal]:
    """Split `total` (>= 0, quantum-exact) into len(weights) shares that sum EXACTLY to total.
    Each share is floor(total * w / Σw) to `quantum`; the remainder goes to the largest weight
    (first among ties). If every weight is <= 0, weights are treated as all-equal."""
    assert total >= 0 and is_exact(total, quantum) and weights
    if all(w <= 0 for w in weights):
        weights = [ONE] * len(weights)
    s = sum(weights)
    shares = [q(total * w / s, quantum) for w in weights]
    rem = total - sum(shares)                       # >= 0 because every share rounded down
    i = max(range(len(weights)), key=lambda k: (weights[k], -k))
    shares[i] += rem
    return shares

Rules that follow from this and are enforced by assertions in Cursor.move: - Every qty handed to the ledger is exact to good.quantum; every value is exact to MONEY_Q. Callers quantize with q(); the ledger refuses inexact numbers rather than silently rounding, because silent rounding is exactly how leaks happen. - Quantities are always derived with ROUND_DOWN, so a run never consumes or emits more than the exact arithmetic allows. - allocate is the only way a value is ever split. Partial lot removal: take_cost = allocate(lot.cost, [take, lot.qty − take])[0]. Re-basing N lots to a total: allocate(value, [lot.qty for lot in lots]). Joint cost: allocate(released, [out_qty_i × base_value_i]). Budget scaling in BuyInputs: allocate(budget, [planned_spend_i]). - No float appears in core/. Content writes D("0.3"). Validation rejects any non-Decimal numeric in goods, processes and policy params.

Note on the Rust/TS port: with prec = 34 and fixed quanta, every stored number is an integer multiple of 1e-6 or 1e-2; the port uses i128 micro-units and cents, and allocate ports verbatim.


4. Module layout and dependency direction

pyproject.toml                 # uv-managed; package `rileycity`; deps: none for core; pytest (+ hypothesis) dev
rileycity/
  __init__.py
  core/                        # PURE. No I/O, no globals, no imports from content/cli/report.
    __init__.py
    numeric.py                 # §3: Decimal quanta, q(), floor_to_step(), allocate()
    errors.py                  # Insufficient, InvalidData, AuditError
    goods.py                   # Good, Line, Process, expires_for(), horizon(); validation
    inventory.py               # Lot, Account (FEFO, exact cost split, merge rule, expired(), snapshot/restore)
    ledger.py                  # Move, Entry, Transaction, Cursor, Ledger (atomic transact)
    verbs.py                   # transfer(), exchange(), transform_start(), transform_complete() — the only tx builders
    market.py                  # Offer, Market (book, visible(), reference(), execute_buy(), tick stats, EMA)
    agent.py                   # Run, Agent (accounts, plan(), desired_scale(), requirements(), update_estimates())
    intents.py                 # Phase, Intent base, Produce, PostOffer, Buy, Transfer + execute(world)
    policies.py                # Policy base, DEFAULTS, ProduceToTarget, PostAsks, BuyInputs, PayDividends
    world.py                   # World (registry, void/sink, ledger, market, flows, history, step(), audit())
  content/                     # DATA. Imports core only.
    __init__.py
    goods.py                   # GOODS: list[Good]
    processes.py               # PROCESSES: list[Process]
    seed.py                    # build_world(seed: int) -> World; endowments + agent params; content lint
  report/                      # Imports core only (reads World.history / ledger).
    __init__.py
    pnl.py                     # derived P&L / balance sheet from flows (pure functions)
    html.py                    # render(history, path): single-file HTML with embedded JSON + inline SVG charts
  cli/                         # Imports everything.
    __init__.py
    repl.py                    # cmd.Cmd-based REPL; `python -m rileycity` entry
    __main__.py
tests/
  test_numeric.py test_inventory.py test_ledger.py test_verbs.py test_market.py
  test_agent_plan.py test_policies.py test_world.py test_seed.py test_determinism.py
runs/                          # report output (gitignored)
docs/DESIGN.md                 # this file

Dependency direction (enforced by a test that imports each core module in isolation and inspects sys.modules): core ← content ← cli, core ← report ← cli. core.world imports the other core modules; nothing in core imports world except intents and policies, which receive a World as a parameter and import it only under TYPE_CHECKING.

uv setup: uv init --package rileycity, uv add --dev pytest hypothesis, uv run pytest, uv run python -m rileycity. requires-python = ">=3.13".


5. Class-by-class specification

Conventions: all dataclasses are @dataclass(slots=True); data records are frozen=True. Type GoodId = str, AgentId = str, ProcessId = str, AccountRef = tuple[str, str] (owner id, account name); the world's accounts have owner "world" and names "void", "sink". Every method that mutates state is on Account (called only by Cursor), Cursor, Ledger, Market, Agent.memory/runs, or World. Policies never mutate anything.

5.1 core/errors.py

class RileyError(Exception): ...
class InvalidData(RileyError): ...                       # content/validation failures
class Insufficient(RileyError):                           # the only runtime economic failure
    def __init__(self, good: GoodId, account: AccountRef, wanted: Decimal, available: Decimal): ...
class AuditError(RileyError): ...                         # raised by World.audit(raise_on_fail=True)

5.2 core/goods.py

@dataclass(frozen=True, slots=True)
class Good:
    id: GoodId
    unit: str                         # display only
    shelf_life: int | None            # ticks; None = never expires; n = expires at settle of tick created+n
    base_value: Decimal               # bootstrap reference price and joint-cost weight; >= 0
    quantum: Decimal = QTY_Q          # smallest representable quantity; money uses MONEY_Q
    tags: frozenset[str] = frozenset()  # reporting/grouping only; core never reads tags

@dataclass(frozen=True, slots=True)
class Line:
    good: GoodId
    qty: Decimal                      # > 0. Amount that must be PRESENT per unit of scale (input) or emitted (output)
    rate: Decimal = ONE               # in [0, 1]. Fraction of qty destroyed per run. Outputs must be 1.

@dataclass(frozen=True, slots=True)
class Process:
    id: ProcessId
    inputs: tuple[Line, ...]          # >= 1 line; goods unique within inputs
    outputs: tuple[Line, ...]         # >= 0 lines; goods unique within outputs; rate == 1; never `money`
    duration: int = 1                 # ticks from start to completion; >= 1
    step: Decimal = QTY_Q             # scale granularity; > 0. scale is always a multiple of step
    tags: frozenset[str] = frozenset()

def validate_process(p: Process, goods: Mapping[GoodId, Good]) -> None:
    """Raises InvalidData. Checks: all goods exist; >=1 input; unique goods per side; qty > 0;
    0 <= rate <= 1; output rate == 1; duration >= 1; step > 0 and exact to QTY_Q; every qty/rate/step
    is a Decimal; 'money' not in outputs.
    Quantities at run time are q(qty * scale) and q(n * rate), rounded DOWN to the good's quantum, so a
    run is always quantum-exact whatever the step; no static exactness rule is needed."""

def expires_for(good: Good, tick: int) -> int | None:
    return None if good.shelf_life is None else tick + good.shelf_life

def horizon(good: Good, ticks: Decimal) -> Decimal:
    """How many ticks of a good it is sensible to hold: min(ticks, shelf_life), at least 1.
    The ONE place shelf_life is read by planning."""
    return ticks if good.shelf_life is None else min(ticks, max(ONE, D(good.shelf_life)))

Semantics of a run at scale s (a multiple of step): for each input line, n = q(qty × s, good.quantum) must be present in main; of that, consumed = q(n × rate, good.quantum) is destroyed and n − consumed returns intact; for each output line q(qty × s, good.quantum) is emitted. Max feasible scale from inventory is floor_to_step(min_i main.qty(good_i) / qty_i, step). One calculation, used everywhere.

5.3 core/inventory.py

@dataclass(frozen=True, slots=True)
class Lot:
    good: GoodId
    qty: Decimal        # > 0, exact to good.quantum
    cost: Decimal       # >= 0, exact to MONEY_Q. TOTAL basis of the lot.
    expires: int | None
    seq: int            # monotone; assigned by Ledger.next_seq() when the lot is created

def lot_key(lot: Lot) -> tuple[int, int, int]:
    return (1, 0, lot.seq) if lot.expires is None else (0, lot.expires, lot.seq)

class Account:
    ref: AccountRef
    _lots: dict[GoodId, list[Lot]]                 # each list kept sorted by lot_key; empty lists removed

    def qty(self, good: GoodId) -> Decimal          # Σ qty, ZERO if absent
    def cost(self, good: GoodId) -> Decimal         # Σ cost
    def goods(self) -> list[GoodId]                 # goods with qty > 0, in first-insertion order (deterministic)
    def lots(self, good: GoodId) -> tuple[Lot, ...] # read-only view
    def total_cost(self) -> Decimal                 # Σ over all lots — net worth contribution

    def add(self, lot: Lot) -> None:
        """Insert in sorted position. MERGE RULE: if a lot `m` with the same good, same `expires`
        and equal unit cost (m.cost * lot.qty == lot.cost * m.qty, exact cross-multiplication) exists,
        replace m with Lot(qty=m.qty+lot.qty, cost=m.cost+lot.cost, expires, seq=min(m.seq, lot.seq)).
        Otherwise bisect-insert by lot_key. Linear scan for the merge partner is acceptable: lot lists
        are short (see bound below)."""

    def remove(self, good: GoodId, qty: Decimal) -> list[Lot]:
        """FEFO. Take from the front until qty is satisfied. A partially taken lot L is split:
        taken = Lot(qty=take, cost=allocate(L.cost, [take, L.qty-take])[0], expires=L.expires, seq=L.seq),
        remainder keeps L.expires/L.seq with the remaining cost. Returns the taken lots in order.
        Raises Insufficient(good, self.ref, qty, self.qty(good)) BEFORE mutating if qty > available.
        qty must be > 0."""

    def expired(self, tick: int) -> list[Lot]:      # lots with expires is not None and expires <= tick
    def snapshot(self) -> dict[GoodId, list[Lot]]   # {good: list(lots)} — shallow copies; lots are immutable
    def restore(self, snap) -> None                 # self._lots = {g: list(l) for g, l in snap.items()}

Invariants upheld by Account: no lot with qty <= 0 is ever stored; Σ cost is unchanged by any split (allocate is exact); a remove either takes exactly qty or raises without side effects; sortedness holds after every add.

Bound on lot count: perishable lots are swept; durable lots that pass through WIP as catalysts come back as the same lots (a carry move preserves each taken lot's own basis, §5.4) and re-merge with any equal-cost neighbor, so a flock, a set of chairs or a piece of equipment never fragments through use. Durable outputs with distinct unit costs (chairs, hatched chickens) add at most one lot per completed run and are removed FEFO, so counts stay O(cover × runs per tick).

5.4 core/ledger.py

CARRY = object()          # sentinel: "keep the source lots' expiry"

@dataclass(frozen=True, slots=True)
class Move:
    good: GoodId
    qty: Decimal
    src: AccountRef
    dst: AccountRef
    category: str                          # see glossary §14.3
    value: Decimal | None = None           # None = carry basis; else total basis entering dst
    expires: int | None | object = CARRY   # CARRY, None, or an int

@dataclass(frozen=True, slots=True)
class Entry:
    good: GoodId; qty: Decimal; src: AccountRef; dst: AccountRef; category: str
    cost: Decimal                          # basis that left src
    value: Decimal                         # basis that entered dst
    lots_in: tuple[Lot, ...]               # the lots created in dst (for expiry/audit legibility)

@dataclass(frozen=True, slots=True)
class Transaction:
    id: int; tick: int; phase: Phase; kind: str          # kind in {"transfer","exchange","start","complete"}
    entries: tuple[Entry, ...]
    meta: Mapping[str, object]                           # e.g. {"agent": id, "process": id, "scale": s} or {"buyer","seller","price"}

class Cursor:
    """Handed to a builder by Ledger.transact. Applies moves immediately with copy-on-first-touch snapshots."""
    def __init__(self, ledger: Ledger, resolve: Callable[[AccountRef], Account], goods: Mapping[GoodId, Good]): ...
    entries: list[Entry]
    _saved: dict[AccountRef, dict]        # account snapshots taken before first mutation

    def move(self, good, qty, src, dst, category, value=None, expires=CARRY) -> Entry:
        g = self.goods[good]                                  # KeyError -> InvalidData
        if qty == 0: return Entry(good, ZERO, src, dst, category, ZERO, ZERO, ())   # not recorded
        assert qty > 0 and is_exact(qty, g.quantum)
        assert value is None or (value >= 0 and is_exact(value, MONEY_Q))
        assert src != dst
        a, b = self.resolve(src), self.resolve(dst)
        self._touch(a); self._touch(b)                        # snapshot once per account per tx
        taken = a.remove(good, qty)                           # may raise Insufficient
        cost = sum(l.cost for l in taken)
        if value is None:                                     # CARRY: each lot keeps its own basis (FEFO identity preserved)
            total, shares = cost, [l.cost for l in taken]
        else:                                                 # RE-BASE: split the given value across lots by quantity, exactly
            total, shares = value, allocate(value, [l.qty for l in taken])
        lots_in = []
        for l, c in zip(taken, shares):
            exp = l.expires if expires is CARRY else expires
            nl = Lot(good, l.qty, c, exp, self.ledger.next_seq())
            b.add(nl); lots_in.append(nl)
        e = Entry(good, qty, src, dst, category, cost, total, tuple(lots_in))
        self.entries.append(e)
        return e

class Ledger:
    log: list[Transaction]                # append-only
    _seq: int; _tx_id: int
    on_commit: Callable[[Transaction], None] | None   # World installs its flows aggregator here

    def next_seq(self) -> int
    def transact(self, tick, phase, kind, build: Callable[[Cursor], None], meta=None) -> Transaction:
        cur = Cursor(self, self.resolve, self.goods)
        try:
            build(cur)
        except Exception:
            for ref, snap in cur._saved.items(): self.resolve(ref).restore(snap)
            raise
        tx = Transaction(self._next_tx_id(), tick, phase, kind, tuple(cur.entries), dict(meta or {}))
        self.log.append(tx)
        if self.on_commit: self.on_commit(tx)
        return tx

Atomicity: a transaction is a Python callable executed under a cursor. The cursor snapshots each account the first time it touches it (a dict of shallow list copies; lots are immutable so this is exact and cheap — a transaction touches 2–6 accounts). Any exception — Insufficient from a later move, an assertion, a bug — restores every touched account to its pre-transaction lists and re-raises; nothing is logged. There is no partial state to reason about. The only cost is one list copy per touched (account, good), which is negligible.

Why a closure and not a list of Moves: transform_complete needs the cost that left WIP in order to value the outputs, and that cost is only known by applying the earlier moves. A closure lets the builder read entry.cost from the moves it has already made; a pre-built list would need a "peek" API and would go stale. The closure is also the natural shape for the Rust port (a transaction is a scope).

5.5 core/verbs.py — the only transaction builders

Each returns a build(cur) closure; World wraps it in ledger.transact with the right tick/phase/kind/meta.

def transfer(good, qty, src, dst, category, value=None, expires=CARRY):
    def build(cur): cur.move(good, qty, src, dst, category, value, expires)
    return build

def exchange(buyer: Agent, seller: Agent, good: Good, qty: Decimal, price: Decimal):
    amount = q(qty * price, MONEY_Q)                       # rounded down: buyer never overpays a cent
    def build(cur):
        cur.move(good.id, qty, seller.main.ref, buyer.main.ref, "sale", value=amount)      # cost = seller COGS
        cur.move("money", amount, buyer.main.ref, seller.main.ref, "payment")               # carry
    return build
    # Market.execute_buy skips fills whose amount == 0 (sub-cent trades), so goods are never given away.

def transform_start(agent: Agent, p: Process, scale: Decimal, goods, tick: int) -> tuple[Callable, Run]:
    committed: dict[GoodId, Decimal] = {}
    for ln in p.inputs:
        committed[ln.good] = q(ln.qty * scale, goods[ln.good].quantum)
    def build(cur):
        for ln in p.inputs:
            cur.move(ln.good, committed[ln.good], agent.main.ref, agent.wip.ref, "input")   # carry
    run = Run(process=p.id, scale=scale, started=tick, due=tick + p.duration - 1, committed=committed)
    return build, run

def transform_complete(agent: Agent, p: Process, run: Run, goods, world_void, world_sink, tick):
    def build(cur):
        released = ZERO
        for ln in p.inputs:
            n = run.committed[ln.good]
            consumed = q(n * ln.rate, goods[ln.good].quantum)
            released += cur.move(ln.good, consumed, agent.wip.ref, world_sink, "consume").cost
            cur.move(ln.good, n - consumed, agent.wip.ref, agent.main.ref, "return")        # carry
        outs = [(o, q(o.qty * run.scale, goods[o.good].quantum)) for o in p.outputs]
        if outs:
            shares = allocate(released, [n * goods[o.good].base_value for o, n in outs])
            for (o, n), v in zip(outs, shares):
                cur.move(o.good, n, world_void, agent.main.ref, "output",
                         value=v, expires=expires_for(goods[o.good], tick))
    return build

Properties: transform_start fails atomically (Insufficient) if any input is short, so scale is re-derived at execution time from max_scale and never exceeds it. transform_complete cannot fail in practice (WIP holds exactly committed, void is effectively infinite); if it ever does, the run stays pending and audit surfaces it. After completion, WIP holds nothing from that run: Σ consumed + Σ returned == Σ committed per good by construction. Joint products with all-zero base values fall back to equal allocation (allocate rule). Zero-output processes push released into sink as a pure expense, with no special code.

5.6 core/market.py

@dataclass(slots=True)
class Offer:
    seq: int; seller: AgentId; good: GoodId; qty: Decimal; price: Decimal; tick: int   # qty is the REMAINING quantity

@dataclass(slots=True)
class GoodStats:      # per good, per tick; reset in Market.clear()
    offered: Decimal = ZERO; wanted: Decimal = ZERO; filled: Decimal = ZERO
    volume: Decimal = ZERO; value: Decimal = ZERO; best_ask: Decimal | None = None

class Market:
    goods: Mapping[GoodId, Good]                    # injected at construction (World passes its registry)
    book: dict[GoodId, list[Offer]]                 # per good, sorted by (price, seq); rebuilt every tick
    ema: dict[GoodId, Decimal]                      # trade-price EMA, only for goods that have ever traded
    stats: dict[GoodId, GoodStats]
    by_seller: dict[tuple[AgentId, GoodId], tuple[Decimal, Decimal]]   # (offered, sold) this tick
    by_buyer:  dict[tuple[AgentId, GoodId], tuple[Decimal, Decimal]]   # (wanted, filled) this tick
    alpha: Decimal                                   # EMA smoothing, default D("0.3")
    _seq: int

    def clear(self) -> None                          # start of OFFER phase: empty book and all per-tick stats
    def post(self, seller, good, qty, price, tick) -> None:
        """qty > 0, price >= 0 (quantized to MONEY_Q). One offer per (seller, good) per tick — a repost replaces.
        Records stats[good].offered and by_seller[(seller, good)]."""
    def visible(self, buyer: AgentId, good: GoodId) -> list[Offer]:
        """All offers for `good` with seller != buyer and qty > 0, sorted (price, seq).
        *** GEOGRAPHY SEAM: this is the one function a spatial world filters. ***"""
    def best_ask(self, good) -> Decimal | None
    def reference(self, good: GoodId) -> Decimal:              # Market is constructed with the goods mapping
        return self.ema.get(good, self.goods[good].base_value) # THE price-discovery source for all policies

    def execute_buy(self, world, buyer: Agent, good: GoodId, qty: Decimal, max_price: Decimal) -> Decimal:
        """Take cheapest-first while price <= max_price, cash allows, and qty remains. Each fill is one
        exchange transaction. Returns filled qty. Records wanted/filled/volume/value."""
        remaining = qty; filled = ZERO
        for offer in self.visible(buyer.id, good):
            if remaining <= 0 or offer.price > max_price: break
            g = world.goods[good]
            take = min(remaining, offer.qty)
            if offer.price > 0:
                affordable = q(buyer.cash() / offer.price, g.quantum)
                take = min(take, affordable)
            take = q(take, g.quantum)
            if take <= 0: break
            amount = q(take * offer.price, MONEY_Q)
            if amount == 0 and offer.price > 0: break      # sub-cent fill; skip rather than give goods away
            world.ledger.transact(world.tick, Phase.TRADE, "exchange",
                                  exchange(buyer, world.agents[offer.seller], g, take, offer.price),
                                  meta={"buyer": buyer.id, "seller": offer.seller, "price": offer.price})
            offer.qty -= take; remaining -= take; filled += take
            self._record_trade(good, offer.seller, buyer.id, take, amount)
        self._record_want(good, buyer.id, qty, filled)
        return filled

    def close_tick(self) -> None:
        """End of TRADE: for each good with volume > 0, vwap = value / volume (quantized MONEY_Q);
        ema[good] = vwap if absent else q(ema + alpha * (vwap - ema), MONEY_Q). best_ask recorded for history."""

Offers are posted from real inventory at OFFER and only decrease at TRADE via fills, and an agent's stock of a good changes during TRADE only by its own sales (production and expiry happen in later phases), so an offer can never exceed the seller's stock at fill time; Insufficient in execute_buy therefore indicates a bug and is allowed to propagate.

5.7 core/agent.py

@dataclass(slots=True)
class Run:
    process: ProcessId; scale: Decimal; started: int; due: int
    committed: dict[GoodId, Decimal]           # qty moved into wip per input good

@dataclass(slots=True)
class Agent:
    id: AgentId
    main: Account
    wip: Account
    processes: list[ProcessId]                 # order matters: executed in this order within PRODUCE
    policies: list[Policy]
    params: dict[str, object]                  # flat; per-good/per-process override via "key.good" / "key.process"
    memory: dict[str, object]                  # see below
    runs: list[Run] = field(default_factory=list)

    # ---- parameters ----
    def param(self, key: str, sub: str | None = None) -> object:
        """params[f"{key}.{sub}"] if sub and present, else params[key] if present, else DEFAULTS[key]."""

    # ---- balance sheet ----
    def cash(self) -> Decimal:            return self.main.qty("money")
    def net_worth(self) -> Decimal:       return self.main.total_cost() + self.wip.total_cost()
    def pending_output(self, good) -> Decimal   # Σ over runs of q(out.qty × run.scale) for outputs == good
    def position(self, good) -> Decimal:  return self.main.qty(good) + self.pending_output(good)

    # ---- the single planning calculation (§7.1) ----
    def max_scale(self, p: Process, world) -> Decimal
    def demand(self, good) -> Decimal            # memory["demand"][good], ZERO if absent
    def desired_scale(self, p: Process, world, prev_need: Mapping[GoodId, Decimal]) -> Decimal
    def requirements(self, desired: Mapping[ProcessId, Decimal], world) -> dict[GoodId, Decimal]
    def plan(self, world) -> None                # writes memory["desired"], memory["need"]
    def update_estimates(self, flows: Flows, market: Market, world) -> None   # §7.2

memory layout (all plain dict/Decimal so it serializes and diffs):

key type meaning
demand {good: Decimal} EMA of observed demand per tick (sold + own consumption, stockout-corrected)
price {good: Decimal} current sticky ask
desired {process: Decimal} this tick's planned scale
need {good: Decimal} this tick's requirement per good (present + cover ticks)
last_offered / last_sold {good: Decimal} previous tick's offer and sales (for sell-through)

params keys used by core (all have DEFAULTS, §7.5): cover, target_cover, catchup, demand_alpha, stockout_boost, min_margin, up_step, down_step, up_at, down_at, floor.<good>, tolerance, spend_fraction, min_scale.<process>, max_scale.<process>, prior_scale.<process>, owner, payout, reserve. No key is consulted anywhere except through agent.param.

5.8 core/intents.py

class Phase(StrEnum): OFFER = "offer"; TRADE = "trade"; PRODUCE = "produce"; SETTLE = "settle"

@dataclass(frozen=True, slots=True)
class Intent:
    agent: AgentId
    def execute(self, world: World) -> None: ...

@dataclass(frozen=True, slots=True)
class PostOffer(Intent):   good: GoodId; qty: Decimal; price: Decimal
    # execute: world.market.post(agent, good, min(qty, main.qty(good)), price, tick); skip if qty <= 0
@dataclass(frozen=True, slots=True)
class Buy(Intent):         good: GoodId; qty: Decimal; max_price: Decimal
    # execute: world.market.execute_buy(world, agent, good, qty, max_price)
@dataclass(frozen=True, slots=True)
class Produce(Intent):     process: ProcessId; scale: Decimal
    # execute: s = min(scale, agent.max_scale(p)) floored to step; if s <= 0 return;
    #          build, run = transform_start(...); ledger.transact(tick, PRODUCE, "start", build, meta); agent.runs.append(run)
@dataclass(frozen=True, slots=True)
class Transfer(Intent):    to: AgentId; good: GoodId; qty: Decimal; category: str
    # execute: n = min(qty, main.qty(good)); if n <= 0 return; ledger.transact(..., "transfer", transfer(good, n, main, to.main, category))

Four intents. The dispatch is a method on each intent, so World contains no isinstance chain. Intents are re-validated against current state at execution (the plan was made from the pre-phase snapshot; other agents' actions may have changed availability), which is why Produce re-caps scale and Transfer re-caps quantity.

5.9 core/policies.py

class Policy:
    phase: Phase
    def propose(self, agent: Agent, world: World) -> list[Intent]: ...

class PostAsks(Policy):        phase = OFFER;   goods: tuple[GoodId, ...] | None = None
class BuyInputs(Policy):       phase = TRADE
class ProduceToTarget(Policy): phase = PRODUCE
class PayDividends(Policy):    phase = SETTLE

Policies are stateless configuration objects; all state they need lives in agent.memory and agent.params, so an agent's behavior is fully described by data. Pseudocode in §7.

5.10 core/world.py

@dataclass(slots=True)
class Flow: qty: Decimal = ZERO; cost: Decimal = ZERO; value: Decimal = ZERO

class Flows:
    """Per-tick aggregation of entries, attributed to both owners. Reset each tick. The ONLY thing
    policies and reports observe about the ledger."""
    out: dict[tuple[AgentId, GoodId, str], Flow]    # by (src owner, good, category): qty, cost
    inn: dict[tuple[AgentId, GoodId, str], Flow]    # by (dst owner, good, category): qty, value
    errors: list[tuple[AgentId, Intent, str]]       # intents whose execution raised; must be empty (§8.1, §8.3 check 7)
    def add(self, tx: Transaction) -> None
    def get_out(self, agent, good, category) -> Flow; def get_in(...)

class History:
    rows: list[dict]           # one dict per tick: {"tick", "goods": {...}, "agents": {...}} — §10.2 lists the series

class World:
    goods: dict[GoodId, Good]; processes: dict[ProcessId, Process]; agents: dict[AgentId, Agent]   # insertion-ordered
    void: Account; sink: Account
    ledger: Ledger; market: Market; flows: Flows; history: History
    tick: int; rng: random.Random; seed: int
    params: dict                         # {"audit_every_tick": True, "void_qty": VOID_QTY}
    _accounts: dict[AccountRef, Account]

    # ---- construction (content calls these; nothing else creates accounts) ----
    def add_good(self, g: Good) -> None            # validates; funds void with VOID_QTY at cost qty*base_value
    def add_process(self, p: Process) -> None      # validate_process
    def add_agent(self, id, processes, policies, params) -> Agent   # creates main/wip accounts, registers refs
    def endow(self, agent: AgentId, good: GoodId, qty: Decimal, unit_value: Decimal | None = None) -> Transaction:
        """transfer(void -> agent.main, category='endow', value = None (carry base value) or q(qty*unit_value),
        expires = expires_for(good, self.tick))"""
    def account(self, ref: AccountRef) -> Account

    # ---- the tick ----
    def step(self) -> None:                       # §6.1
    def run(self, n: int) -> None
    def _gather(self, phase) -> list[Intent]:
        intents = [i for a in self.agents.values() for pol in a.policies if pol.phase == phase for i in pol.propose(a, self)]
        self.rng.shuffle(intents); return intents
    def _execute(self, intents) -> None:          # for i in intents: try i.execute(self) except RileyError as e: flows.errors.append((i.agent, i, str(e)))  — §8.1
    def _complete_runs(self) -> None              # end of PRODUCE: for agent in order, for run in list(agent.runs) if run.due <= tick: transact complete; remove
    def _expire(self) -> None                     # SETTLE: for agent in order, for lot in agent.main.expired(tick): transfer(lot.good, lot.qty, main, sink, "expire")
    def audit(self, raise_on_fail: bool = True) -> list[str]   # §8.3

World.step() is the whole simulation. rng is seeded once; the only consumer is _gather's shuffle, so a given seed and content produce a bit-identical history (test in §11).


6. The tick pipeline

6.1 World.step() — exact order

tick T:
  flows.reset(); market.clear()
  for a in agents (insertion order): a.plan(world)          # §7.1 — one derived plan per agent per tick
  OFFER   : execute(gather(OFFER))                          # PostAsks -> market.post
  TRADE   : execute(gather(TRADE)); market.close_tick()     # BuyInputs -> execute_buy -> exchanges; then EMA update
  PRODUCE : execute(gather(PRODUCE)); _complete_runs()      # ProduceToTarget -> start runs; then complete every run with due <= T
  SETTLE  : execute(gather(SETTLE)); _expire();             # PayDividends -> transfers; then sweep main accounts
            for a: a.update_estimates(flows, market, world) # §7.2
            history.capture(world); if params.audit_every_tick: audit()
  tick = T + 1

Determinism: agents iterate in insertion order; intents within a phase are shuffled by the seeded RNG (fair, reproducible); processes within an agent execute in list order; runs complete in agent order then run order; expiry sweeps in agent order then lot order. No set iteration anywhere in core.

6.2 Validating the phase order (offer → trade → produce → settle)

The labor loop. Labor must exist in the household's main at OFFER of tick T for firms to buy it at TRADE(T) and use it at PRODUCE(T). The household makes labor at PRODUCE; with a run of duration 1, that labor completes at the end of PRODUCE(T−1). It must therefore survive the settle of T−1 and be swept at the settle of T if unsold. Hence labor has shelf_life = 1, not 0. With the definition expires = created_tick + shelf_life and the sweep removing expires <= T, a lot made at T−1 with shelf_life 1 expires at settle(T) — exactly one selling window. The same applies to meals and coffee: a service is shelf_life = 1 in this pipeline. shelf_life = 0 is legal and means "exists only for the tick it was made in" — usable for waste by-products that should show up as a spoilage expense, and for nothing else. The sketch's "0 = services" was wrong by one; the rule is unchanged, the data is corrected.

Trace, steady state: - PRODUCE(T−1): household completes living at scale 100 → 100 labor, expires T. - OFFER(T): household reserves nothing (labor is not one of its inputs) and posts 100 labor at its sticky wage. - TRADE(T): firms' BuyInputs want ≈80 labor (their plan's need, cover clipped to 1 by horizon because shelf_life is 1); shuffled buy order; ~80 fill. Household cash rises; also in this phase the household buys meals/coffee/eggs made at PRODUCE(T−1). - PRODUCE(T): firms start runs consuming labor bought this tick; household starts living consuming the groceries it bought this tick. - SETTLE(T): 20 unsold labor and any unsold meals/coffee are swept to sink (spoilage for their holder). Household demand EMA for labor observes 80 sold with 100 offered → no stockout bump, sell-through 80% → wage nudged down toward its floor (§7.3).

The one-tick pipeline delay is exactly one, everywhere: what you make at T is on the market at T+1. Switching the tick to hourly changes no code — durations, shelf lives and cover are in ticks.

Could produce precede trade? Then a firm would run with yesterday's labor and today's labor would be sold after it was needed — no gain, and the household would buy food after making labor, so food would also need shelf_life ≥ 1. The chosen order is the one where every perishable needs the minimum shelf life (1) and inventory posted is inventory that actually exists.

Multi-tick runs (duration = d): started at PRODUCE(T), due = T + d − 1, completed at the end of PRODUCE(due). d = 1 completes in the same phase it started. Inputs — including catalysts — sit in wip for the whole run, so an oven in a 3-tick bake is unavailable for 3 ticks. Because completion happens at the end of the phase, even a duration-1 catalyst is occupied for the whole PRODUCE phase: an agent running two processes that both need equipment needs the sum of both requirements. §7.1's requirements() sums over processes for exactly this reason, so plan and physics agree.

6.3 Expiry rule

Sweep visits each agent's main only. A lot in wip is committed to a run and does not rot — the alternative (letting committed inputs expire) would make transform_complete fail on a good that was legitimately present at start, which is worse in every way. Because duration-1 runs empty their WIP in the same phase, this only matters for multi-tick runs, and there it is the right rule.

6.4 Tick-0 bootstrap and the money loop

At tick 0 nobody has produced anything, so OFFER(0) can only post endowments. The seed rule (§9.4) is therefore: every agent is endowed with one tick of every output of every process it runs (at prior_scale), plus cover ticks of every consumable input, plus every catalyst at the required quantity, plus enough cash for ~3 ticks of purchases. content/seed.py has a lint that checks each agent can run each process at its prior scale from its own endowment at tick 0 without buying anything (can_bootstrap). With that, TRADE(0) is a normal trade phase, PRODUCE(0) runs at planned scale, and tick 1 is steady state.

The money loop closes through dividends. Without them, firms accumulate profit as cash, the household's cash drains, its living scale falls through the cash constraint in BuyInputs, demand falls, and the economy deflates to zero. PayDividends transfers (cash − reserve) × payout to params["owner"] (the household in the seed) at SETTLE, so the household's income is wages + profits = firms' revenue, which is the household's spending. Money is conserved, so this is not a stabilizer bolted on; it is what makes the closed economy's flow of funds sum to zero. Later, when ownership becomes a share registry, PayDividends becomes allocate(amount, share_weights) → N transfers, and nothing else changes.


7. The reactive policies

All arithmetic below is Decimal; q() is applied where a quantity is produced; every parameter is agent.param(key, sub) with the defaults in §7.5. Nothing in this section knows what industry it is running in.

7.1 The single planning calculation: Agent.plan(world)

This is the one place in the system where "how much do I want to make" and "what do I therefore need to hold" are computed. ProduceToTarget, BuyInputs and PostAsks all read its result from memory["desired"] and memory["need"].

def desired_scale(agent, p, world, prev_need):
    wants = []
    for out in p.outputs:
        g = world.goods[out.good]
        d = agent.demand(out.good)                                    # EMA, units/tick
        target = d * horizon(g, param("target_cover")) + prev_need.get(out.good, 0)
        #        ^ stock for `target_cover` ticks of demand (clipped by shelf life)
        #          plus what my own processes need present (catalyst flocks, feed stocks)
        gap = target - agent.position(out.good)                       # position includes pending outputs of runs in progress
        want_units = max(0, d + gap / param("catchup"))               # replace demand, close the gap over `catchup` ticks
        wants.append(want_units / out.qty)
    s = max(wants) if wants else 0                                    # joint products: make enough of the most-wanted output
    s = max(s, param("min_scale", p.id))                              # data-driven clamps (household: both = population)
    cap = param("max_scale", p.id)
    if cap is not None: s = min(s, cap)
    return floor_to_step(s, p.step)

def requirements(agent, desired, world):
    need = {}
    for pid, s in desired.items():
        p = world.processes[pid]
        for ln in p.inputs:
            g = world.goods[ln.good]
            cov = horizon(g, param("cover"))                          # never stock beyond shelf life
            need[ln.good] = need.get(ln.good, 0) + q(ln.qty * s * max(1, ln.rate * cov), g.quantum)
    return need

def plan(agent, world):
    prev_need = memory.get("need", {})
    desired = {pid: desired_scale(agent, world.processes[pid], world, prev_need) for pid in agent.processes}
    memory["desired"] = desired
    memory["need"] = requirements(agent, desired, world)

Why prev_need and not a fresh computation: the target for a good that is both an output and an input of the same agent (chickens: hatched by hatch, needed by lay_eggs and cull) depends on the desired scale of the processes that need it, and their desired scale can depend on the demand for goods that hatch needs (eggs). Using last tick's need breaks the cycle with a one-tick lag, which is immaterial and deterministic.

need formula, checked against the three cases with cover = 2: - rate = 1 (labor, shelf_life 1cov = 1): need = qty × s × 1 — exactly today's use. - rate = 1 (grain, durable-ish → cov = 2): need = qty × s × 2 — two ticks of consumption. - rate = 0 (land, flock): need = qty × s — what must be present. - rate = 0.002 (equipment): max(1, 0.004) = 1qty × s — what must be present; wear reduces stock below it over time and the ordinary shortfall rule buys a replacement if anyone sells one.

max_scale(agent, p, world) = floor_to_step(min over inputs of main.qty(good) / qty, p.step) — the feasibility calculation from §5.2, used by Produce.execute and by PostAsks reserve (indirectly through need).

7.2 Demand estimation: Agent.update_estimates(flows, market, world) (SETTLE)

for good in (goods I sold this tick ∪ goods I consumed this tick ∪ goods in memory["demand"]):
    sold     = flows.get_out(agent, good, "sale").qty
    consumed = flows.get_out(agent, good, "consume").qty
    offered, sold_m = market.by_seller.get((agent.id, good), (0, 0))
    observed = sold + consumed
    if offered > 0 and sold_m >= offered:                 # I sold out: demand is censored from above
        observed = observed * (1 + param("stockout_boost"))
    if offered == 0 and sold == 0 and consumed == 0:      # nothing to learn from this tick
        continue
    prev = memory["demand"].get(good, observed)
    memory["demand"][good] = q(prev + param("demand_alpha") * (observed - prev), g.quantum)
memory["last_offered"], memory["last_sold"] = per-good copies of market.by_seller for this agent

Why "sold + own consumption": the poultry farm never sells chickens but consumes ten a day in cull; without own consumption its demand for chickens would be zero and hatch would never run. One rule for every good.

Why the censoring bump: a seller that sells out learns only that demand ≥ supply. Bumping the observation by stockout_boost makes the estimate climb until stock is no longer exhausted, at which point sales are an unbiased observation and the EMA settles. This is what prevents the "no stock → zero sales → zero demand → no production" death spiral; the rule "skip ticks with nothing offered and nothing consumed" prevents the same spiral from the other side.

Bootstrap: at seeding, memory["demand"][good] = Σ_p out.qty × params["prior_scale.p"] for every output good of the agent's processes. That is the only prior; it is data.

7.3 PostAsks (OFFER)

goods = self.goods or (outputs of all my processes, in process order, deduplicated)   # computed once
for good in goods:
    stock = agent.main.qty(good)
    offer_qty = stock - memory["need"].get(good, 0)            # keep what my own processes need
    if offer_qty <= 0: continue
    g = world.goods[good]
    unit_cost = agent.main.cost(good) / stock                   # my average basis for this good
    floor = max(q(unit_cost * (1 + param("min_margin")), MONEY_Q), param("floor", good) or 0)
    price = memory["price"].get(good)
    if price is None:
        price = max(floor, market.reference(good))              # first ever ask: reference price (EMA or base value)
    else:
        offered, sold = memory["last_offered"].get(good, 0), memory["last_sold"].get(good, 0)
        if offered > 0:
            st = sold / offered                                  # sell-through
            if st >= param("up_at"):    price = price * (1 + param("up_step"))
            elif st < param("down_at"): price = price * (1 - param("down_step"))
        price = max(price, floor)
    price = q(price, MONEY_Q)
    memory["price"][good] = price
    yield PostOffer(agent.id, good, offer_qty, price)

Sticky, cost-floored, sell-through-adjusted. Sell-through is the one signal because it works identically for perishables (offered = today's batch) and durables (offered = accumulating stock): piling up lowers sell-through and cuts price; selling out raises it. The household's labor ask is the same policy with a lower down_step (sticky wage) and an absolute floor.labor (reservation wage), both data.

7.4 BuyInputs (TRADE)

need = memory["need"]                                           # from plan()
short = {good: need[good] - agent.main.qty(good) for good in need if need[good] > agent.main.qty(good)}
if not short: return []
plan = []
for good, n in short.items():
    g = world.goods[good]
    ref = market.reference(good)
    max_price = q(ref * (1 + param("tolerance")), MONEY_Q)
    plan.append((good, q(n, g.quantum), max_price, q(n * ref, MONEY_Q)))   # (good, qty, max_price, expected spend)
budget = q(agent.cash() * param("spend_fraction"), MONEY_Q)
total = Σ expected spend
if total > budget:                                              # scale every line down proportionally — allocate is exact
    shares = allocate(budget, [spend for *_, spend in plan])
    plan = [(good, q(qty * share / spend, g.quantum) if spend > 0 else qty, max_price, share) for (good, qty, max_price, spend), share in zip(plan, shares)]
for good, qty, max_price, _ in plan:
    if qty > 0: yield Buy(agent.id, good, qty, max_price)

The buyer's willingness to pay is the market reference plus a tolerance; that, plus the cash budget, is the entire demand curve. execute_buy (§5.6) takes cheapest-first and stops at max_price, so a seller who raises price above the tolerance band sells nothing and gets pushed back by its own sell-through rule. Intents for goods nobody offers are harmless and are the raw material for "unmet demand" reporting (§8.1, §10.2).

7.5 ProduceToTarget (PRODUCE)

for pid in agent.processes:
    s = min(memory["desired"][pid], agent.max_scale(world.processes[pid], world))
    if s > 0: yield Produce(agent.id, pid, s)

Trivial by design: all the thinking happened in plan(). Produce.execute re-caps against max_scale at execution because an earlier process of the same agent may have consumed a shared input.

7.6 PayDividends (SETTLE)

excess = agent.cash() - param("reserve")
if excess <= 0: return []
amount = q(excess * param("payout"), MONEY_Q)
if amount > 0: yield Transfer(agent.id, param("owner"), "money", amount, "dividend")

7.7 Parameter defaults (policies.DEFAULTS)

key default used by
cover 2 requirements: ticks of consumables to hold
target_cover 3 desired_scale: ticks of demand to hold as output stock (clipped by shelf life)
catchup 3 desired_scale: ticks over which to close a stock gap
demand_alpha 0.2 update_estimates
stockout_boost 0.10 update_estimates
min_margin 0.10 PostAsks floor over unit cost
up_step / down_step 0.03 / 0.03 PostAsks
up_at / down_at 0.95 / 0.80 PostAsks sell-through thresholds
floor.<good> none PostAsks absolute floor
tolerance 0.25 BuyInputs max price over reference
spend_fraction 0.90 BuyInputs cash budget
min_scale.<p> / max_scale.<p> 0 / None (no cap) desired_scale clamps
prior_scale.<p> 0 seeding of demand EMA
owner / payout / reserve none / 1.0 / 0 PayDividends

Market-level: alpha = 0.3 (price EMA).


8. Failure modes, invariants, world.audit()

8.1 Failure modes and what handles them

Failure Prevented / surfaced by
Deadlock at tick 0 (nothing to offer, nothing to buy, nothing to produce) Seed rule §9.4 + content lint can_bootstrap (every agent can run every process at prior scale from its own endowment at tick 0) and supply_chain_closed (every good consumed at rate > 0 by some process is produced by some process or is endowed in sufficient quantity for the run length).
Price collapse below cost PostAsks floor = unit cost × (1 + min_margin), plus absolute floor.<good>.
Runaway inflation Buyers' max_price = reference × (1 + tolerance); sellers raising faster than the EMA adapts sell nothing and are pushed back by sell-through; nominal spending is bounded by conserved money; the stockout bump expands supply. Surfaced: report series reference / base_value.
Deflation to the wage floor with persistent unemployment This is a real property of a closed economy with fixed real consumption, not a bug. Bounded by floor.labor; visible as labor sell-through and sink flow. The seed's labor demand (~80 of 100) is chosen so it is visible but mild; §12 notes where elastic labor supply plugs in.
A firm starves because an input is never offered Buy intents record wanted − filled in market.stats → history unmet per good, REPL market <good>. The firm's max_scale falls and its outputs' stockout bump raises its price; the content lint catches the static case.
Negative inventory Structural: Account.remove raises Insufficient before mutating; the transaction rolls back.
Money or goods created / lost Structural (only Cursor.move mutates, always src → dst) + audit check 1.
Rounding leaks Every split goes through allocate; every qty/value handed to the ledger is asserted quantum-exact; audit checks 2–3 and the trial balance.
Household starving min_scale.living = population keeps consumption at population when affordable; BuyInputs cash budget and max_scale reduce it smoothly when not; dividends return profits so steady-state income = spending. Surfaced: history agents.household.scale.living, cash.
Oscillation / bullwhip Demand EMA (demand_alpha), gap closed over catchup ticks not one, position() counts pending outputs so multi-tick runs are not double-ordered, small price steps, target_cover clipped by shelf life so perishables are never over-stocked. Surfaced by the charts; tuned by data.
Self-dealing Market.visible excludes the buyer's own offers.
Sub-cent trades giving goods away execute_buy skips fills with amount == 0.
A run that cannot complete Cannot happen for duration-1 runs (start and complete are in the same phase, WIP holds exactly what was committed); for longer runs the run remains in agent.runs and audit check 4 reports a WIP mismatch if anything is ever off.
An Insufficient during intent execution Produce and Transfer re-cap at execution, execute_buy checks cash, so it should not happen; if it does, World._execute catches it, records (agent, intent, error) in flows.errors, and continues — the tick completes, the report shows it, and the test suite treats any entry in flows.errors as a failure.

8.2 Invariant assertions (in code, always on)

  1. Cursor.move: qty > 0, qty exact to good.quantum, value exact to MONEY_Q, src != dst.
  2. Account.add: lot.qty > 0, lot.cost >= 0; after insertion the good's list is sorted by lot_key.
  3. Account.remove: raises before mutating; Σ taken.qty == qty; Σ taken.cost + Σ remaining.cost == previous cost.
  4. allocate: Σ shares == total.
  5. transform_complete: after the run, wip.qty(g) decreased by exactly committed[g] for each input good.
  6. validate_process at load (§5.2).

8.3 World.audit(raise_on_fail=True) -> list[str]

Runs at the end of every tick when params["audit_every_tick"] (default on in tests and the REPL; off for long batch runs). Each check appends a message on failure; the list is returned and, if non-empty and raise_on_fail, AuditError is raised.

1. Conservation of every good:   for g in goods: Σ_{all accounts incl. void, sink} qty(g) == VOID_QTY
2. Lot sanity:                   every lot: qty > 0, cost >= 0, qty exact to quantum, cost exact to MONEY_Q; lists sorted
3. Money is at face value:       every lot of "money": cost == qty
4. WIP matches pending runs:     for each agent, for each good: wip.qty(g) == Σ_{runs} committed.get(g, 0)
5. Trial balance (value):        for each agent: net_worth == NW_at_seed + Σ_{entries so far} ΔNW(entry, agent)
                                 where ΔNW = value·[dst owner == a] − cost·[src owner == a]; computed incrementally per tick from flows
6. Offer sanity (end of TRADE, cheap): every remaining offer qty <= seller's main.qty(good)
7. No errors:                    flows.errors is empty

Check 1 is structural and exists to catch bugs in Account; check 5 is the one that proves the accounting deserves its name. Both are O(total lots) and cheap at toy scale; §13 discusses making them incremental.


9. The seed economy as data

Everything in this section lives in content/ and is the only thing an industry designer edits. Numbers are D("...") in code; shown plain here.

9.1 Goods (content/goods.py)

id unit shelf_life base_value quantum tags
money ¤ None 1 0.01 money
labor person-day 1 12 1e-6 service, labor
person head None 0 1e-6 population
land acre None 1000 1e-6 capital, land
forest acre None 500 1e-6 capital, land
equipment unit None 2000 1e-6 capital
grain kg 60 0.5 1e-6 food, raw
flour kg 90 1.5 1e-6 food
coffee_beans kg 180 4.5 1e-6 raw
coffee cup 1 2.3 1e-6 food, service
chicken bird None 3 1e-6 livestock
eggs dozen 14 3.5 1e-6 food
meat kg 5 4 1e-6 food
logs 365 15 1e-6 raw
lumber None 45 1e-6 material
chair unit None 18 1e-6 durable
meal plate 1 7 1e-6 food, service

base_value is a bootstrap reference and a joint-cost weight only; it needs to be within a factor of ~2 of the eventual market price, and the table above sets each at roughly cost × 1.1–1.2 given the inputs' base values (arithmetic in §9.3). person has value 0: it is never traded, and it contributes nothing to household net worth.

9.2 Processes (content/processes.py)

Line is written good qty @rate. Duration 1 and default step unless stated.

id inputs outputs
grow_grain land 1 @0, labor 1 @1 grain 30
grow_coffee land 1 @0, labor 1 @1 coffee_beans 3
fell_trees forest 1 @0.002, labor 1 @1 logs 1
saw logs 1 @1, labor 1 @1, equipment 1 @0.002 lumber 0.8
mill grain 30 @1, labor 1 @1, equipment 1 @0.002 flour 24
lay_eggs chicken 50 @0, grain 6 @1, labor 0.5 @1 eggs 3
cull chicken 1 @1, labor 0.1 @1 meat 1.5
hatch (duration 10) eggs 1 @1, grain 4 @1, labor 0.5 @1 chicken 8
make_chair lumber 0.1 @1, labor 0.5 @1, equipment 1 @0.002 chair 1
cook_meal flour 0.2 @1, eggs 0.1 @1, meat 0.15 @1, labor 0.4 @1, equipment 0.02 @0.002 meal 1
brew_coffee coffee_beans 0.02 @1, labor 0.15 @1, equipment 0.01 @0.002 coffee 1
living person 1 @0, meal 1 @1, coffee 2 @1, eggs 0.1 @1, chair 1 @0.01 labor 1

Each required pattern from the brief is present with no code: primary production with a land catalyst (grain, coffee); extraction with slow depletion (forest @0.002); stock-yield production (a 50-hen flock @0 lays 3 dozen a day; cull consumes birds; hatch is a 10-tick run whose output replenishes the flock); intermediate processing with equipment wear (mill, saw); a durable final good (chair); a multi-recipe service firm (restaurant runs two processes on shared equipment); and the household as a producer of labor whose chair line is a catalyst with 1% daily wear.

9.3 The arithmetic: unit costs at base input prices, and daily flows for population 100

Unit cost of each output at the inputs' base values (labor 12):

process cost per run unit cost of output base_value set
grow_grain 12 0.40 /kg 0.5
grow_coffee 12 4.00 /kg 4.5
fell_trees 12 + 0.002×500 = 13 13 /m³ 15
saw 15 + 12 + 4 = 31 38.75 /m³ 45
mill 15 + 12 + 4 = 31 1.29 /kg 1.5
lay_eggs 3 + 6 = 9 3.00 /dozen 3.5
cull 3 + 1.2 = 4.2 2.80 /kg 4
hatch 3.5 + 2 + 6 = 11.5 1.44 /bird 3 (endowed birds carry 3; hatched carry ~1.44 — both are honest bases)
make_chair 4.5 + 6 + 4 = 14.5 14.5 18
cook_meal 0.30 + 0.35 + 0.60 + 4.80 + 0.08 = 6.13 6.13 7
brew_coffee 0.09 + 1.80 + 0.04 = 1.93 1.93 2.3
living 7 + 4.6 + 0.35 + 0.18 = 12.13 12.13 /labor 12 (wage; household floor 8)

Daily requirements implied by 100 people running living at scale 100: meals 100, coffee 200, eggs 10, chair wear 1.

process runs/day labor/day other inputs/day
cook_meal 100 40 flour 20, eggs 10, meat 15, equipment 2 present (wear 0.004)
brew_coffee 200 30 beans 4, equipment 2 present
lay_eggs 6.67 (20 dz: 10 household + 10 restaurant) 3.3 grain 40, 333 hens present
cull 10 (15 kg meat) 1.0 10 birds
hatch 1.25 (10 birds/day replacement) 0.6 eggs 1.25, grain 5
mill 0.83 (20 kg flour) 0.8 grain 25, 1 equipment present
grow_grain 2.33 (70 kg: 40 + 25 + 5) 2.3 3 acres present
grow_coffee 1.33 (4 kg) 1.3 2 acres present
make_chair 1 0.5 lumber 0.1
saw 0.125 0.1 logs 0.125
fell_trees 0.125 0.1 forest 1 present
total labor ≈ 80 of 100 supplied → ~80% employment

Money check (per day): household spending = 100×7 + 200×2.3 + 10×3.5 + 1×18 ≈ 1213. Firm sector cash costs = wages 80×12 = 960; cash profit ≈ 253 → paid as dividends (payout 1.0 above reserve). Household income ≈ 960 + 253 = 1213 = spending. The accounting profit is lower by ~25/day of equipment and forest depreciation, which correctly shows as declining firm net worth (nobody makes equipment in the seed; §12 lists the build_equipment process as the first content extension). Twenty unsold labor/day expire at the household as a spoilage expense of ~243, offset by dividends — the household's net income is ≈ 0 and its net worth is stable, which is what a stationary economy should show.

Prices at tick 0: every endowed lot carries base_value, so the first ask is max(base × 1.1, reference = base) = base × 1.1 across the board; first-tick buyers accept up to base × 1.25. Nothing is rejected at tick 0.

9.4 Agents, endowments, parameters (content/seed.py)

Seeding rule (the lint checks it): for each process at prior_scale s — every catalyst at qty × s (+ slack), every consumable at qty × s × cover, one tick of every output; plus cash for ~3 ticks of purchases.

agent processes (prior_scale) endowment params beyond defaults
household living (100) person 100, chair 100, labor 100, eggs 10, money 3000 min_scale.living=100, max_scale.living=100, cover=1, floor.labor=8, down_step.labor=0.01
restaurant cook_meal (100), brew_coffee (200) equipment 5, flour 40, eggs 20, meat 30, coffee_beans 8, meal 100, coffee 200, money 3000 owner=household, reserve=2000
poultry lay_eggs (6.7), cull (10), hatch (1.25) chicken 450, grain 100, eggs 6, meat 15, money 800 owner=household, reserve=400
grain_farm grow_grain (2.4) land 4, grain 70, money 300 owner=household, reserve=100
coffee_farm grow_coffee (1.4) land 3, coffee_beans 4, money 300 owner=household, reserve=100
logging fell_trees (0.2) forest 2, logs 1, money 300 owner=household, reserve=100
sawmill saw (0.2) equipment 1, logs 2, lumber 1, money 300 owner=household, reserve=100
furniture make_chair (1) equipment 1, lumber 1, chair 2, money 300 owner=household, reserve=100
mill mill (0.9) equipment 2, grain 60, flour 24, money 500 owner=household, reserve=150

Every agent has the same four policies: PostAsks(), BuyInputs(), ProduceToTarget(), and (all but the household) PayDividends(). Total money in circulation: 8,500. The flock is endowed above its 383-bird target so hatch idles for the first days and starts as culling erodes the stock; the 10-tick lag then produces a visible (and instructive) small oscillation in eggs that the EMA damps.

build_world(seed) does: create World(seed); add_good for each good (funds void); add_process for each; add_agent with policies and params; endow each line above; seed memory["demand"] from prior_scale; run the lints (can_bootstrap, supply_chain_closed, all_decimal); return the world at tick 0.


10. Interface

10.1 REPL (cli/repl.py, python -m rileycity [--seed N] [--ticks N])

Built on cmd.Cmd. Every command is a thin call into World, report.pnl, or report.html; no economics in the CLI.

command effect
tick / run N advance 1 / N ticks; prints one summary line per tick (tick, employment %, avg price index, total spoilage, audit ok)
agents table: id, cash, inventory value, net worth, revenue/COGS/profit this tick
agent ID plan (desired scales, needs), stock per good with avg cost and expiry buckets, current asks, pending runs, last-tick P&L
goods / good ID per good: reference price, best ask, volume, offered, unmet, total stock by holder, sink total
market [GOOD] the offer book (seller, qty, price) and this tick's fills / unmet
ledger [N] [--agent A] [--good G] [--kind K] last N transactions with entries (cost, value)
pnl AGENT [FROM TO] derived P&L and balance sheet over a tick range
audit run world.audit(raise_on_fail=False) and print the list
set AGENT KEY VALUE set agent.params[KEY] = D(VALUE) (e.g. set restaurant markup 0.2, set household floor.labor 6)
endow AGENT GOOD QTY [UNIT_VALUE] god-mode world.endow (from void; conservation holds)
reset [SEED] rebuild the world
report [PATH] write the HTML report (default runs/<seed>-<tick>.html) and print the path
help, quit

10.2 History series (captured every tick by History.capture)

Per good g: reference, best_ask, vwap, volume, offered, wanted, unmet = wanted − filled, stock (Σ agents' main), produced (Σ output flows), consumed, expired (sink inflow this tick).

Per agent a: cash, inventory_value = net_worth − cash, net_worth, revenue, cogs, spoilage, dividends_in, dividends_out, profit = revenue − cogs − spoilage − prod_variance, and per process scale actually started; per (agent, good): stock, price, offered, sold, bought.

Derived headline series: employment = labor sold / labor offered, price_index = Σ_g reference_g × base_qty_g / Σ_g base_value_g × base_qty_g over household consumption goods, total_money (must be flat), total_spoilage_value.

10.3 Report page (report/html.py)

One self-contained HTML file: the history rows embedded as JSON, a ~150-line inline SVG line-chart renderer (no CDN, no build step), and a good/agent selector. Panels: (1) prices — reference and best ask per good; (2) volumes and unmet demand per good; (3) production scale per process; (4) net worth and profit per agent; (5) household: employment, cash, living scale; (6) spoilage by good; (7) an audit strip (green per tick). Dark/light follows prefers-color-scheme.


11. Day-one test plan

tests/ runs with uv run pytest. Property tests use hypothesis where noted; all others are plain.

numericallocate sums exactly to total for random Decimals and weights (property); remainder lands on the largest weight; all-zero weights → equal split; q rounds down; floor_to_step exact.

inventory — FEFO order across expiring/None lots; partial removal splits cost exactly and leaves the remainder lot's expiry/seq; merge rule merges equal unit cost + expiry and does not merge unequal; remove raises Insufficient without mutating; snapshot/restore round-trips; expired(tick) boundary (<=).

ledger — a transaction whose third move fails leaves every touched account bit-identical to before and logs nothing; value=None carries cost exactly; explicit value re-bases across multiple taken lots and sums exactly; expires=CARRY vs override; zero-qty move is a no-op and not recorded; assertions reject inexact qty/value.

verbsexchange: seller COGS equals FEFO cost, buyer basis equals amount, money moved equals amount, sub-cent case; transform_start/complete: WIP nets to zero for rate 1, rate 0 (catalyst returned with its basis intact), wear 0.002 (consumed exactly q(n × rate)), joint outputs allocated by base value and summing to released cost, zero-output process expenses released cost, multi-tick run holds WIP across ticks, void/sink totals move by exactly the consumed/produced quantities.

market — cheapest-first; ties by seq; max_price respected; partial fills across offers; own offers invisible; cash cap; reference falls back to base value then tracks VWAP EMA; per-tick stats.

agent plandesired_scale bootstraps from prior_scale; the need formula for rate 1 / 0 / 0.002 with and without shelf-life clipping; position includes pending outputs; clamps; step flooring; update_estimates censoring bump and skip rule; own consumption counted.

policiesPostAsks: first price = max(floor, reference), sell-through up/down, floor binds, reserve withheld, default goods = process outputs; BuyInputs: shortfall, budget scaling via allocate, max price; PayDividends: reserve and payout.

world — 200 ticks of the seed economy: audit() passes every tick; flows.errors empty every tick; total money == 8,500 every tick; household labor sold > 0 every tick; every firm produces at least once in every 10-tick window; no agent net worth < 0; labor sell-through between 0.5 and 1.0 after tick 5; every good's reference price within [0.25×, 4×] its base value after tick 20 (loose bands that catch spirals, not tuning).

determinism — two worlds with the same seed produce identical history.rows and identical ledger hashes after 100 ticks; different seeds differ.

seed — lints pass; every agent can bootstrap at tick 0; every process is used by some agent; every consumed good is produced by some process or endowed as a catalyst.

layering — importing any rileycity.core.* module in a fresh interpreter never imports content, cli or report.


12. Staged implementation order and the seams for later systems

12.1 Build order

  1. numeric, errors, inventory, ledger + their tests. Runnable milestone: push moves between hand-made accounts, break one mid-transaction, watch it roll back; conservation holds.
  2. goods, verbs + tests. Milestone: run a process by hand on an agent-less pair of accounts; joint cost, catalyst return and wear all exact.
  3. market + tests. Milestone: post offers, execute a buy, inspect the two entries.
  4. agent, intents, policies, world + content/ + tests. Milestone: build_world(1).run(200) passes audit; this is the first thing worth looking at.
  5. report/pnl, report/html, cli/repl. Milestone: uv run python -m rileycity, run 100, report, open the page.
  6. Tune the seed with the charts (steps, alphas, covers) and freeze the world-level tests' bands.

Each stage is independently testable; nothing in a later stage changes an earlier one's interface.

12.2 Seams — where later systems plug in without redesign

Later system Where it plugs in What changes What does not
Logistics / geography Market.visible(buyer, good) (filter by location/reach); Agent.params["location"]; a carrier is an ordinary Agent with policies that buy at A and sell at B, and a transport process consuming fuel/labor with duration = travel ticks one filter function, new content ledger, verbs, accounts, policies
Real estate Land and buildings are already goods held as catalysts; rent is a shelter service output of a landlord process with a building catalyst, bought by households through BuyInputs content everything
Individual sims The household shatters into N agents each holding person 1; living is unchanged; ownership becomes params["owner"] → a share table and PayDividends uses allocate seed + one policy detail living process, market, ledger
Hiring / labor contracts Offer gains term (ticks) and Market gains a contracts list executed by the ordinary exchange at the start of every TRADE before spot fills; a contract is a standing offer with a fixed price Market only everything else
Finance / credit Account.sign (+1 asset / −1 liability) and a liabilities account per agent; a loan is money in and a debt good created from void in liabilities; interest is a process consuming money at rate; net_worth sums signed inventory/agent, content ledger, verbs
Government / taxes A government Agent; a Tax policy emitting Transfer intents based on flows (sales tax) or holdings (property tax); spending through BuyInputs; money creation = transfer from void of money one policy + content everything
Elastic labor supply / leisure min_scale.living < population plus a "leisure" output line valued in the household's own params; or a second household process data code
Capital goods production build_equipment: lumber 5 @1, labor 20 @1 → equipment 1 at the furniture maker; the existing need formula already replaces worn equipment content code
Technology / quality New process rows with better ratios; a firm's processes list changes data code

12.3 Things that will not survive contact with an MMO, noted, not built


13. Places where the Key Rule pushes back, and what was decided


14. Appendix

14.1 Tooling

uv init --package rileycitypyproject.toml with requires-python = ">=3.13", [project.scripts] rileycity = "rileycity.cli.repl:main", dev deps pytest, hypothesis. uv run pytest -q; uv run rileycity --seed 1. No runtime dependencies in core/; report/ and cli/ use only the standard library (json, cmd, html).

14.2 Python 3.13 conventions used

@dataclass(slots=True, frozen=True) for records, slots=True for mutable holders; X | None; StrEnum for Phase; match is permitted in cli/ for command parsing and is not needed in core/ (intents dispatch via a method); typing.Self for fluent builders in content; TYPE_CHECKING guards for World imports in intents/policies. decimal context is configured once in core/numeric.py at import.

14.3 Entry categories (the complete vocabulary)

category src → dst value meaning
endow void → agent.main carry (base value) or explicit initial endowment / god-mode spawn
sale seller.main → buyer.main qty × price goods leg of an exchange; cost is COGS
payment buyer.main → seller.main carry money leg of an exchange
input agent.main → agent.wip carry committing inputs to a run
consume agent.wip → sink carry inputs destroyed at completion; Σ cost = released
return agent.wip → agent.main carry catalysts and unworn remainder returned
output void → agent.main allocated share of released products born
expire agent.main → sink carry spoilage / idle labor
dividend agent.main → agent.main carry (money) profit distribution

Anything new (tax, rent, gift, theft) is a transfer with a new category string; the P&L functions in report/pnl.py treat unknown categories as "other" until given a line.

14.4 Glossary

Scale — the number of runs of a process, a multiple of step. Position — on-hand plus pending outputs. Need — what must be held now for the planned scale over cover ticks. Reference price — the market's trade-price EMA, or base value before any trade. Sell-through — sold ÷ offered last tick. Released cost — the cost basis that left WIP at completion, equal by construction to the total basis of the outputs.