system-sim
Temporal simulation runtime for wity-system graphs. Layers frequency-based parameter emission, topologically-ordered influence propagation, event detection, and ring-buffer history onto an authored SystemGraph.
Peer dependency: wity-system (system-core)
Import
import { SimulationRuntime, FeatureStore, SimClock, InfluenceEngine, HistoryBuffer } from 'system-sim';Or via the monorepo root export:
import { SimulationRuntime } from 'wity-system/sim';Declarative dynamics
A simulation's behaviour — how features evolve — is authored and serialized as data, not code. A Dynamics spec is a plain object; compileDynamics applies it onto a runtime through the runtime's own setGenerator/defineInfluence/defineEvent (the tick loop is untouched). Raw functions remain available as an escape hatch for behaviour no archetype expresses.
This separation is deliberate:
- State space — the features/parameters a type can bear — lives on the system's types (
features, see Type Registry). - Behaviour — generators, influence, events — lives in the Dynamics spec.
One system (one state space) can therefore bear many independent Dynamics — normal, peak, failure — each referencing it.
import { compileDynamics, validateDynamics } from 'wity-system/sim';
const dynamics = {
generators: {
'router:cpu': { kind: 'randomWalk', step: 6, bias: 0.5 },
'datacenter:load': { kind: 'sum', terms: [
{ kind: 'sine', offset: 40, amplitude: 20, period: 188495 },
{ kind: 'noise', base: 0, amplitude: 5 },
] },
},
influence: {
fiber: [{ source: 'cpu', target: 'cpu', kind: 'proportional', gain: 0.2 }],
},
events: {
'high-cpu': { kind: 'threshold', feature: 'cpu', op: '>', value: 85, cooldown: 5000 },
},
};
const errors = validateDynamics(dynamics); // [] when valid; never throws
compileDynamics(runtime, dynamics); // applies it onto the runtimeSpec shape
{
generators: { "type:feature": { kind, ...params } },
influence: { connType: [ { source, target, kind, ...params } ] },
modes: { name: <predicate> }, // see Modes & composite events
events: { eventId: { kind, ...params, cooldown? }
| { kind: 'whenMode', mode, cooldown? } },
inputs: { "type:input": [ { feature, set? | add? | scale? } ] } // what an input does — see Inputs
}From a spec to a running simulation
buildRuntime is the canonical "spec → running sim" path. It performs the fixed setup sequence — construct, pull the state space from the system's types, compile the behaviour, bind — and returns a bound runtime. The caller owns the lifecycle (on / start / pause).
import { buildRuntime } from 'wity-system/sim';
const sim = buildRuntime(system, dynamics, { speed: 1, tickRate: 100, historyDepth: 360 });
sim.on('event', ({ id, uid, simTime }) => { /* … */ });
sim.start();buildRuntime(system, spec, opts?, registries?) → SimulationRuntime // bound; not startedFeatures are read from the system's types (defineFeaturesFromSystem), so the types must carry their feature schemas. It plays the input streams the system is run with (its run's streams; every one when it names none), or opts.streams — stream ids, [] for none — when given (Inputs). For imperative, function-based configuration use SimulationRuntime directly.
Built-in archetypes
Archetypes live in extensible registries (generatorArchetypes, influenceArchetypes, eventArchetypes) — the single source of truth shared by the compiler, authoring UIs (.list() → pickers, params → forms) and validateDynamics. Register new ones per domain; the runtime never changes. defaultRegistries is the built-in set, { generators, influence, events, sources } (sources: the kinds of input stream); pass your own set to buildRuntime, compileDynamics, validateDynamics or simulate to extend it.
new ArchetypeRegistry(family) // 'generator' | 'influence' | 'event' — named in its errors
register(kind, { params?, compile }) → this // compile: a { kind, ...params } spec → the runtime fn
has(kind) → boolean
list() → [{ kind, params }] // for pickers and forms
compile(spec) → fn // throws on an unknown kind| Family | kind | Params |
|---|---|---|
| generator | constant | value |
| generator | sine | offset, amplitude, period, phase |
| generator | randomWalk | step, bias |
| generator | noise | base, amplitude |
| generator | ramp | from, to, duration |
| generator | pulse | base, peak, start, rise, hold, fall, every |
| generator | sum | terms (composite of sub-generators) |
| generator | byAsset | default, assets (a generator per asset uid) |
| influence | proportional | gain |
| influence | additive | amount |
| influence | decay | rate (0..1, relax target → source) |
| influence | max / replace | — |
| event | threshold | feature, op, value |
| event | range | feature, min, max |
pulse — something that happens between two moments
Sits at base; from start it rises to peak over rise, holds for hold, and falls back over fall (all in ms of simulation time). With every > 0 it repeats on that period. This is the building block for an incident with a beginning and an end.
{ kind: 'pulse', base: 0.5, peak: 0.95, start: 15000, rise: 6000, hold: 22000, fall: 8000, every: 90000 }byAsset — different behaviour for named assets
Generators are keyed by type:feature, so every asset of a type behaves the same. byAsset gives the type a default generator and a different one for specific assets, by uid. An asset with no entry uses default; with no default it holds its value.
'cell:utilization': {
kind: 'byAsset',
default: { kind: 'sum', terms: [ { kind: 'constant', value: 0.5 }, { kind: 'noise', amplitude: 0.03 } ] },
assets: {
'cell-a1': { kind: 'pulse', base: 0.55, peak: 0.97, start: 15000, rise: 6000, hold: 22000, fall: 8000 },
},
}sum — each term keeps its own state
sum adds its terms. Each term is given its own previous value, per asset — so a stateful term (randomWalk) walks from its own last value, not from the sum's total. constant 4 + randomWalk therefore stays around 4.
Running a behaviour headless
simulate steps a system through a stretch of simulated time — no clock, no screen — and reports what happened. It is what a test, a command line or an assistant uses to check that a behaviour does what it was written to do. The system is not changed.
import { simulate } from 'wity-system/sim';
const out = simulate(system, dynamics, { durationMs: 60000 });
// out.events → [{ id, count, firstAt, lastAt, assets: { uid: count } }]
// out.occurrences → [{ t, id, uid }] the first firings in order (capped; see occurrencesTruncated)
// out.signals → [{ uid, type, feature, min, max, last, mean, threshold?, overMs? }]simulate(system, spec, { durationMs = 120000, stepMs = 100, maxOccurrences = 200, streams? }?, registries?)
→ { durationMs, steps, events, inputs?, occurrences, occurrencesTruncated, signals }inputs (only when something came in): per input, { input, count, firstAt, lastAt, assets } — what came from outside, apart from events, what the system raised; occurrences lists the events only.
signals has one row per asset per feature of its type, counted from the feature's first emission. A feature watched by a threshold event also carries that threshold ({ event, op, value }) and overMs, the simulated time spent past it.
API
compileDynamics(runtime, spec, registries?) → runtime
validateDynamics(spec, ctx?, registries?) → [{ path, message }] // [] = valid, never throwsctx supplies the lookups the reference checks need — featureExists(type, feature), connectionTypeExists(type), featureKnown(feature), and for the spec's inputs: inputOf(type, input) (the input a type declares, or null) and inputNames() (every input declared — no event may take one's name).
validateDynamics always checks archetype kinds and shape — generators inside generators (a sum's terms, a byAsset's default and per-asset ones) at any depth, numeric parameters, and an event's watched feature, operator, value and cooldown. With ctx.featureKnown(featureId) it also checks that some type carries the feature an event watches. Reference checks (does a type:feature / connectionType exist) run only when ctx supplies featureExists / connectionTypeExists, keeping the checker decoupled from SystemGraph.
Pass a customised registry set as the third argument to extend behaviour per domain.
State space from the system
Feature schemas are declared on the system's asset types (the state space — see Type Registry). defineFeaturesFromSystem() pulls them into the runtime, so you don't define features imperatively. The full declarative flow:
const sim = new SimulationRuntime(system);
sim.defineFeaturesFromSystem(); // state space ← the system's type dispositions
compileDynamics(sim, dynamics); // behaviour ← the declarative spec
sim.bind(); // bind assets
sim.start();defineFeaturesFromSystem() skips types that declare no features or already have features defined, so it composes safely with explicit defineFeatures() and is safe to call more than once.
Modes & composite events
A single feature crossing a threshold rarely captures a real condition — "overload" is a region of feature-space, a conjunction of bands. A mode is a named predicate over features; a composite event fires while an asset is in a mode.
Conditions are a small declarative predicate language — bounded to propositional logic (all/any/not over feature atoms) plus a fixed set of graph quantifiers. It's decidable, terminating, serializable (plain data), and authorable — not a general rules engine.
const dynamics = {
generators: { 'server:cpu': {/*…*/}, 'server:mem': {/*…*/} },
modes: {
overload: { all: [ { feature: 'cpu', op: '>', value: 85 },
{ feature: 'mem', op: '>', value: 90 } ] },
healthy: { not: { mode: 'overload' } },
},
events: {
'overload-alarm': { kind: 'whenMode', mode: 'overload', cooldown: 5000 },
},
};Predicate AST
atom (compare) { feature, op: '>'|'>='|'<'|'<='|'=='|'!=', value }
atom (band) { feature, in: [min, max] }
connective { all: [pred, …] } | { any: [pred, …] } | { not: pred }
mode reference { mode: 'overload' } ← modes compose from modes (cycle-guarded)
graph quantifier { anyNeighbor: pred } | { allNeighbors: pred } | { anySource: pred } | { allSources: pred }
count { count: { of: 'neighbors'|'sources', where: pred, op, value } }Graph quantifiers evaluate a sub-predicate against related assets' states (reusing the system's topology), so conditions like "any upstream source is overloaded" or "≥3 neighbours are hot" are expressible without a general first-order engine. Modes may reference other modes; self/cyclic references resolve to false rather than looping.
The predicate substrate is exported directly for reuse: evaluatePredicate(pred, ctx) and validatePredicate(pred, ctx?) (pure, throw-free).
SimulationRuntime
The methods below are the runtime primitives.
compileDynamics(above) is the recommended authoring/serialization path; the fn-basedsetGenerator/defineInfluence/defineEventremain the escape hatch for behaviour no archetype expresses.
Top-level orchestrator. Composes FeatureStore, SimClock, InfluenceEngine, and HistoryBuffer. Runs a 5-phase tick loop.
Constructor
new SimulationRuntime(system, opts?)| Parameter | Type | Required | Description |
|---|---|---|---|
system | SystemGraph | yes | An authored wity-system graph |
opts.speed | number | no | Simulation speed multiplier (default: 1) |
opts.tickRate | number | no | Wall-clock ms between ticks (default: 100) |
opts.historyDepth | number | no | Ring buffer depth per tracked feature (default: 360) |
opts.inputLogDepth | number | no | How many of the input events that arrived are kept for getInputEvents (default: 1000) |
opts.eventNeighbours | boolean | no | An event carries neighbours: the assets one connection from its asset, with their signals at that instant (default: true; false leaves it out) |
Schema definition
defineFeatures(typeName, schemas) → this
defineFeaturesFromSystem() → this // pull feature schemas from the system's type dispositions
defineInfluence(connectionType, rules) → this
setGenerator(typeName, featureId, fn) → this
defineMode(name, predicate) → this
defineEvent(eventId, { condition, cooldown? } | { mode, cooldown? }) → thisFeature schema
runtime.defineFeatures('router', [
{ id: 'cpu-load', frequency: 5000, type: 'number', range: [0, 100], unit: '%' },
{ id: 'mem-used', frequency: 15000, type: 'number', range: [0, 64], unit: 'GB' },
{ id: 'link-up', frequency: 10000, type: 'boolean', defaultValue: 1 },
]);| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique within type |
frequency | number | yes | Emission interval in ms |
type | 'number' | 'boolean' | no | Storage type (default: 'number') |
defaultValue | number | no | Initial value (default: 0) |
range | number[] | no | [min, max] for number type |
unit | string | no | Display hint |
Generators
runtime.setGenerator('router', 'cpu-load', (current, simTime, dt, asset) => {
return current + (Math.random() - 0.5) * 10;
});Generator signature: (currentValue, simTime, dt, asset) => newValue
Influence rules
runtime.defineInfluence('data-link', [
{
source: 'cpu-load',
target: 'cpu-load',
fn: (srcVal, tgtVal, conn) => tgtVal + srcVal * 0.1,
},
]);Rules are propagated in topological order — multi-hop chains (A→B→C) fully resolve in a single tick for DAGs.
Events
runtime.defineEvent('overload', {
condition: (state, asset) => state['cpu-load'] > 90,
cooldown: 30000,
});An event is defined by a condition function or by a mode name ({ mode: 'overload', cooldown }). The cooldown is per asset. An asset that has not fired yet is treated as having last fired at simulation time 0, so an event with a cooldown cannot fire for it before simTime ≥ cooldown.
Binding
bind() → this
bindAsset(uid, typeName) → void
unbindAsset(uid) → voidbind() walks all assets in the SystemGraph and registers them into the FeatureStore. Call after defineFeatures, before start. bindAsset adds a single asset incrementally.
Control
start(opts?) → void
pause() → void
resume(speed?) → void
stop() → void
step(dt?) → void
setSpeed(speed) → void
simTime → number
running → boolean
speed → numberState access
getValue(uid, featureId) → number | undefined
getAssetState(uid) → { [featureId]: number } | null
getSnapshot() → { simTime, values }
getNaturalValue(uid, featureId) → number | undefined // what the behaviour gives it, without the interventions on itHistory
trackHistory(uid, featureId) → void
trackAssetHistory(uid) → void
getHistory(uid, featureId, opts?) → { times: number[], values: number[] } | nullgetNaturalHistory(uid, featureId, opts?) → { times, values } | null // the natural values of an intervened-on signal
History is opt-in. Only tracked features consume ring-buffer memory. A tracked signal that is intervened on also records its natural values from then on, so a view can draw what happened beside what would have happened.
Inputs
defineInputEffect(typeName, input, effects) → this // what an input does when it arrives (the spec's `inputs`)
playInputStream(stream, sources?) → this // its input events arrive as simulated time reaches them
sendInput({ input, asset, payload? }) → void // one, from outside: it arrives on the next tick
getInputEvents({ from?, to? }) → [{ input, uid, simTime, payload?, stream? }]An input event arrives after the generators and before influence; it is emitted on 'event' with source: 'input', before the events the tick raised. See Inputs for streams, what an input does, and the checks (validateInputStream, inputEventProblems, payloadProblems, streamsToRun, sourceArchetypes).
Interventions
An intervention is an outside actor changing the running system for a while — an operator's action, a decision being carried out, a scripted incident. It forces a signal; it does not change the behaviour. The behaviour keeps producing the signal's natural value as it would; each active intervention adjusts the result — set ?? natural, then × scale, then + add, kept in the feature's range. Several on one signal apply in the order they were made. When the last one on a signal ends, the signal is the behaviour's again.
applyIntervention({ uid, feature, set?, scale?, add?, durationMs?, source? }) → id
endIntervention(id) → boolean // whether it was active
getInterventions() → [{ id, uid, feature, set?, scale?, add?, startedAt, endsAt, source? }] // active, oldest first| Field | Description |
|---|---|
uid, feature | the asset and its signal (a number signal) |
set / scale / add | the change — at least one |
durationMs | how long it lasts from now; omitted: until endIntervention |
source | who made it — opaque (e.g. a decision's id); carried on its events |
It throws for an asset without signals, a signal its type does not have, a signal that is not a number, a change that is not a number, or a durationMs that is not above 0. Interventions are not part of getSnapshot() / restore(). Each start and end is emitted as an 'intervention' event: { op: 'start' | 'end', intervention }.
Events
injectEvent(eventId, data?) → void
inject(uid, featureId, value) → voidinject sets a feature value directly (external signal); on a signal under an intervention, it sets the natural value and the interventions still apply. injectEvent fires an event notification without condition evaluation, with source: 'external' and data spread into it.
Listeners
on(event, fn) → unsubscribeEvents: 'tick', 'event', 'intervention', 'started', 'paused', 'stopped'.
Tick payload: { simTime, dt, changedCount }. Event payload: { id, uid, simTime, state, source: 'condition' | 'external' | 'input', neighbours? } (an input event adds input, payload?, stream?) — neighbours (on an event fired by a condition, unless eventNeighbours is false): [{ uid, via, direction: 'to' | 'from', type, state }], the assets one connection away and their signals at that instant (state is null for an asset that measures nothing). Intervention payload: { op: 'start' | 'end', intervention }.
Serialization
serialize() → { simTime, values }
restore(data) → voidCleanup
destroy() → voidFeatureStore
Columnar storage for simulation features. One Float64Array per feature, indexed by asset ordinal within its type.
defineFeatures(typeName, schemas) → void
addAsset(uid, typeName) → void
removeAsset(uid) → void
getValue(uid, featureId) → number | undefined
setValue(uid, featureId, value) → void
getAssetState(uid) → { [featureId]: number } | null
getFeatureArray(typeName, featureId) → Float64Array | null
getDueFeatures(typeName, simTime) → string[]
markEmitted(typeName, featureId, simTime) → void
totalFeatureCount → number
getTypeNames() → string[]
getAssetUids(typeName) → string[]
getSchemas(typeName) → Map<featureId, schema> | null
getLocation(uid) → { typeName, ordinal } | null
snapshot() → object
restore(data) → voidSimClock
Simulation clock with speed control and wall-time drift compensation.
new SimClock(onTick, opts?)simTime → number
speed → number
running → boolean
tickRate → number
start() → void
pause() → void
resume(speed?) → void
stop() → void
step(dt?) → void
reset() → void
setSpeed(speed) → voidInfluenceEngine
Topologically-ordered state propagation along graph connections.
define(connectionType, rules) → void
propagate(system, store, updatedFeatures) → void
hasRules(connectionType) → boolean
getRules(connectionType) → InfluenceRule[]
clear() → voidHistoryBuffer
Opt-in ring-buffer time-series storage.
new HistoryBuffer(depth?)track(uid, featureId) → void
trackAsset(uid, featureIds) → void
untrack(uid, featureId) → void
record(uid, featureId, time, value) → void
get(uid, featureId, opts?) → { times: number[], values: number[] } | null
isTracked(uid, featureId) → boolean
clear() → void