The Shape of a Tick
NPC substrate · architecture

The shape of a tick

Every decision made so far, written as one pass of the simulation clock — from the world mutating to a mind discovering it was wrong. Read top to bottom; each level is called by the one above it.

Level 0

The world tick

The spine. Order is the schedule — and the ordering is what makes the whole thing deterministic, because both boundaries between world and mind are queues drained at fixed points.

function worldTick(W):
  W.tick += 1

  // 1. apply what minds decided last tick
  drainIntents(W)                       // ECS performs every mutation

  // 2. what changed is what can be perceived
  changes = collectChanges(W)           // ECS change detection, not polling

  // 3. daemons turn world changes into percepts
  percepts = []
  for d in W.daemons:
    for (sub, ch) in d.match(changes, W.subscribersByNode):
      percepts += d.sense(W, sub, ch)

  // 4. percept filters transform the queue — fear, illusion, a fae realm
  for f in W.perceptFilters:
    percepts = f(percepts, W)

  // 5. deliver into minds
  for p in percepts:
    kbAssert(W.minds[p.subject].kb, p)

  // 6. the floor: every mind, every tick, no search
  stalled = []
  for m in W.minds:
    if reflexArbiter(m, W) == STALL: stalled.push(m)

  // 7. the only expensive thing, and it is rationed
  scheduleDeliberation(W, stalled)
Invariant 4 · ECS owns the write pathMinds never touch W. They read their own KB and push intents, drained at step 1 of the next tick. Perception is the mirror image: the world pushes percepts, drained at step 5. Two queues, no back-channel, and rollback stays possible.
Level 1

The reflex arbiter

Fixed native code, not a per-agent program — at a million agents, interpreting a script per agent per tick is the floor cost we cannot afford. What varies per agent is the table: thresholds, goal weights, and which compiled reactions are installed. Personality lives in the installed set.

function reflexArbiter(m, W) -> CONTINUE | SWITCH | STALL:

  // a. did the world already contradict us? cheap check only
  if m.plan and nextStepInvalidated(m):
      m.plan = nil

  // b. a percept fired a compiled reaction that outranks us
  r = matchReaction(m.table.reactions, m.freshPercepts)
  if r and priority(r) > priority(m.activity):
      m.activity = r; m.plan = r.plan
      return SWITCH

  // c. is this still the right goal?
  g = topGoal(m)
  if shouldSwitch(m.table.commitment, m.goal, g):
      m.goal = g; m.plan = nil
      return SWITCH

  // d. keep going
  if m.plan and holds(m.kb, preconditions(nextStep(m.plan))):
      emitIntent(m, nextStep(m.plan))
      advance(m.plan)
      return CONTINUE

  // e. unmet goal, nothing applicable. A STATE, not an act.
  return STALL

Nothing here requests deliberation. An agent that knows it needs to think is doing implausible self-inspection. It runs out of reflexes; the scheduler notices.

Level 2

Insistence and commitment

insistence(m, goal) = drive(m, goal) × urgency(goal) × m.table.weight[goal.kind]

topGoal(m) = argmax insistence(m, g) over g in m.goals where not satisfied(m.kb, g)

function shouldSwitch(strategy, current, candidate):
  if current == nil:                       return true
  if satisfied(current) or impossible(current): return true
  switch strategy:                        // Kinny & Georgeff: match the
    BLIND:         return false            // strategy to the world's
    SINGLE_MINDED: return false            // rate of change
    OPEN_MINDED:   return insistence(candidate)
                          > insistence(current) × (1 + m.table.hysteresis)
Undecideddrive, urgency and the default commitment strategy are unpinned. Insistence is called from four places — goal choice, switching, the leak threshold, and the deliberation queue's priority — so it is the single undefined function with the widest blast radius.
Level 3

Rationing thought

The budget is global, not per-agent. Most agents never deliberate on a given tick and live on reflexes and compiled frames. "You have to wait until Sunday" is not flavour — it is this queue.

function scheduleDeliberation(W, stalled):
  budget = W.deliberationBudget

  // minds already mid-thought resume exactly where they stopped
  for m in W.thinking:
    budget -= runMachine(m.machine, W.envs, min(m.share, budget))
    if m.machine.status == DONE: install(m, m.machine.result)

  // newly stuck minds compete for what is left
  for m in sortDesc(stalled, m => insistence(m, m.goal) × staleness(m)):
    if budget <= 0: break            // the rest wait, and leak while waiting
    m.machine = makeMachine(deliberate(m.goal), fork(m.kb))
    budget -= runMachine(m.machine, W.envs, min(SLICE, budget))
Invariant 3 · one evaluator, many environmentsfork(m.kb) is a child frame over the mind's own beliefs, which are themselves forked from truth. Imagining eating the fish allocates one frame and cannot reach the world — enforced by data layout, not discipline.
Level 4

Two ways to think

deliberate(goal) =                    // a Lisp form, run on the CEK machine
  (or (goap-plan goal)                // fast: bitwise, over a compiled frame
      (fol-plan  goal))               // slow: backward chaining over clauses

function goapPlan(m, goal):
  frame = frameFor(m, situation(m))   // cached, or compiled on the spot
  if frame == nil: return NO_FRAME    // too big for working memory → go slow
  return aStarBackward(project(goal, frame), frame)

Regression, which is the same operator used three ways — to search, to compute a macro's precondition, and to test relevance:

relevant(op, g) = (op.eff.mask & g.mask) != 0
               and (op.eff.val ^ g.val) & op.eff.mask & g.mask == 0

function regress(g, op):
  keep = g.mask & ~op.eff.mask                      // goal bits op leaves alone
  if (g.val ^ op.pre.val) & keep & op.pre.mask:
      return BOTTOM                               // preconditions fight the goal
  return ( keep | op.pre.mask,
           (g.val & keep) | (op.pre.val & op.pre.mask) )

reached(g, state) = (state.val & g.mask) == g.val
Three return values, not twoPLAN · EXHAUSTED — the space closed and no plan exists, so the goal is impossible and commitment drops it · OUT_OF_FUEL — still stuck, try again later, and leak in the meantime. Collapsing the last two makes an agent give up on solvable problems and keep grinding at impossible ones.
Level 5

Grounding, and the cap that is working memory

function compileFrame(m, situation):
  objects = entitiesWithin(m.kb, situation.node, K_HOPS)   // the graph bounds it
  lits = []
  for s in m.schemas:
    for b in groundings(s, objects):
      lits += s.pre[b] + s.add[b] + s.del[b]

  vocab = dedupe(lits)
  if len(vocab) > FRAME_BITS: return nil   // ← this is the whole boundary

  frame = intern(vocab)                       // literal ↔ bit position
  frame.ops = [ toOp(s, b, frame) for each grounding ]
  frame.byEffect = index(frame.ops)           // (bit,value) → ops. why it is fast
  return frame

Naive grounding explodes — n objects by a k-ary predicate is nk propositions. The graph solves it: locality of world gives locality of grounding. And FRAME_BITS is not a tuning knob. A task that does not fit cannot be compiled and must be reasoned about the slow way, which makes working-memory capacity a per-agent number with real consequences.

Level 6

Belief

KB      = { facts: PersistentSet<Assertion>, clauses: [Clause] }
Assertion = { term, source: DaemonId | AgentId | SELF, tick, confidence }
Clause    = { head: Term, body: [Term] }        // Horn

function prove(kb, goal, fuel) -> { bindings, support: [Assertion|Clause] }
  // backward chaining with unification, metered by the CEK machine.
  // SUPPORT is the composed lineage — not a source tag. Without it an agent
  // can notice it was wrong but cannot work out whose fault that was.

// decay is not a mechanism, it is a clause:
(percept ?sense ?t ?f) and (recent ?t) -> ?f
Derivation is belief(at fish cave) need not be asserted to be believed. And because inference is fuel-metered, knowing is not binary — an agent can fail to reach something it technically knows.
Level 7

Acting, expecting, being wrong

function emitIntent(m, step):
  m.intents.push({ actor: m.id, action: step })
  m.expectations.push({
     tick:      W.tick,
     predicted: applyEffects(m.kb, step.schema),
     support:   step.support           // WHY we believed this would work
  })

function checkExpectations(m):
  for e in m.expectations where matured(e):
    d = diff(e.predicted, observe(m.kb, e.predicted.terms))
    if d: attribute(m, e, d)
Actions do not failcatch at the empty cave does not return an error. It runs. The error is a diff computed in the mind between a remembered prediction and a later percept — which is why it can be delayed, partial, and misattributed.

Attribution is the personality program. Four candidates, all consistent with the same evidence, and nothing in the data picks a winner:

attribute(m, e, diff) = run(m.table.attributionPolicy, [e, diff])

  // the percept was wrong        → distrust that sense
  // my clause was wrong          → retract or weaken it (this is learning)
  // my informant was wrong       → update trust, and possibly call it betrayal
  // the world moved              → retract the fact, blame nobody
UndecidedWhen an expectation matures. Checking immediately is the obvious answer and probably the wrong one — an agent that only checks immediately can never discover a delayed error.
Level 8

Leakage — which nobody chooses

StressDaemon.sense(W, sub, _):
  m = W.minds[sub]
  if m.lastOutcome == STALL
     and insistence(m, m.goal) > m.table.leakThreshold:
       sym = m.table.leakSymbol[m.goal.kind]
       return [ Assertion((distressed m.id sym), source=STRESS, tick=W.tick)
                for each subscriber within N hops ]

Not a move in the arbiter and not an action in any schema. Being stuck makes stress observable; the agent has no say. That is what puts signals into the world before anyone has learned to read them — without an involuntary channel, no agent ever emits, so no agent ever learns meaning, and the culture cannot start.

Complaining is the learned trick built on top — deliberately producing that signal to cause help. A schema with abduced or transmitted provenance, plannable like anything else. And its mirror is equally learned: suppression, the skill of masking your own leak.

Level 9

Compilation

wp([], G)      = G
wp(a:rest, G)  = regress(wp(rest, G), a)          // the planner IS the compiler

function onPlanSucceeded(m, plan, goal):
  macro = { pre: wp(plan, goal), eff: goal, cost: sum(costs(plan)) }
  macro = generalize(macro, plan.support)         // lift only what the proof
                                                  // did not actually depend on
  if utility(macro) > m.table.compileThreshold:
      m.schemas.add(macro, provenance = DERIVED)

utility(macro) = savings(macro) × frequency(macro) - matchCost(macro)
  // Minton's utility problem: an agent that never forgets a trick seizes up.
Blocking

What still has no answer

fb1b

Insistence4 call sites

Goal choice, commitment switching, the leak threshold, and the deliberation queue's priority all call it. Nothing else has this blast radius.

73cd · 8a3a

Concrete term and assertion syntax

Everything above is written against (at fish river) without pinning how a fact is stored, how an assertion wraps a term, or how lineage attaches. The renderer also keys emoji off term heads.

fb1b

Default commitment strategy and hysteresis

Small, but it sits in the arbiter's hot path and decides whether agents thrash or stubbornly finish bad plans.

850a

When an expectation matures

Appears in level 7 as an undefined predicate. Delayed discovery is more interesting than immediate, which argues against the easy answer.

0c88

The look-testvisual only

Gates renderer work. Substrate work proceeds regardless.

Not blocking

Stubbed for the first demo

The smell-daemon shortcut means demo one is a single agent with no communication at all: a true percept, a bad clause, a confident plan, an empty cave. That leaves a lot legitimately stubbed — compilation (seed the schemas), attribution (one hardcoded policy; the paranoid-versus-humble split is the later twist), frame caching and eviction (one frame, no utility tracking), and every speech act.

One correction worth recording: tail calls matter less than previously claimed. The GOAP planner is propositional and runs as a primitive, so it never grows the continuation stack. Only the slow FOL layer loops inside the machine — still worth fixing, no longer urgent.