-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
The engine is a small control system. Each tick: read a frozen snapshot of state, map incoming events to per-channel inputs, run one synchronous update of all states, clamp, then let a selector read the post-update state to choose a visible action. Because the update reads a frozen snapshot, equation order never affects the result — the run is bit-for-bit deterministic.
Every state (anger, stress, frustration, boredom, hunger, fatigue, resentment, …) is one generic
integrator: it accumulates its inputs and decays toward rest with a per-state half-life. A "persona"
is just a preset of parameters over this shared element — not a separate type. A missing coupling or
filter is neutral (0 / identity); wiring is sparse and lives in config.
Events become inputs through per-channel filters, not per-event:
- Relational channels filter per source (who did it) — an insult from a respected superior lands differently than from a stranger.
- Affinity filters per object (what/where).
- Physiological inputs (hunger, fatigue) take no filter.
An insult feeds anger directly (a dedicated edge); anger and stress form a coupled 2-cycle, so the lingering anger slowly pulls stress up afterward (stress is the slow accumulator, not an instant jump). With fixed edge gains this loop is linear and always stable — anger spikes and decays.
An optional, off-by-default overlay turns the linear loop into a stability safety-valve:
-
Escalation nonlinearity — each loop edge becomes
g·(1 + k_esc·other_state), so the local gain grows with the operating point. A single provocation stays stable; only an un-certifiable coincidence of unrelated pressures (insult and hunger and fatigue and a failed search) lifts the operating point past the stability bound → a spiral to the clamp ceiling = a burst. - Burst latch — an integrate-and-fire flip-flop sets only when anger and stress hold above thresholds for several ticks (a plateau, not a spike), with hysteresis on exit.
- Extinction — while latched, a slow term drags anger/stress toward zero and dominates the escalated coupling, so the burst self-terminates (bounded).
- Displaced discharge — while latched and very angry, the reactive gate opens to the tick's event source (you can lash out at whoever is in front of you), with a refractory edge that softens repeat hits from the same provoker. Sourceless events (weather) never open it — "you cannot kick the rain."
See docs/diagrams/burst_saturation.md for the control + functional diagrams.
- Synchronous update from one frozen snapshot; clamps after every commit (
[0,1], signed[−1,1]). - Equations live in exactly one place. State mutates only in
update+ the selector's small post-effects. - Poles of the linearized loops inside the unit circle.
- Tick
dt = min(half-life)/10(engineering Nyquist). - No numeric literal in engine code — everything comes from config; defaults neutral.
An LLM is never in the loop and never mutates state. It wires in only at two seams — perception (text → events) and expression (events → text). Without an LLM there is a deterministic fallback. The believability judge is also an LLM, used only to evaluate runs blind, never to drive them.