Skip to content

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 ​

js
import { SimulationRuntime, FeatureStore, SimClock, InfluenceEngine, HistoryBuffer } from 'system-sim';

Or via the monorepo root export:

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

js
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 runtime

Spec 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).

js
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 started

Features 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
FamilykindParams
generatorconstantvalue
generatorsineoffset, amplitude, period, phase
generatorrandomWalkstep, bias
generatornoisebase, amplitude
generatorrampfrom, to, duration
generatorpulsebase, peak, start, rise, hold, fall, every
generatorsumterms (composite of sub-generators)
generatorbyAssetdefault, assets (a generator per asset uid)
influenceproportionalgain
influenceadditiveamount
influencedecayrate (0..1, relax target → source)
influencemax / replace—
eventthresholdfeature, op, value
eventrangefeature, 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.

js
{ 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.

js
'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.

js
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 throws

ctx 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:

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

js
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-based setGenerator / defineInfluence / defineEvent remain 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?)
ParameterTypeRequiredDescription
systemSystemGraphyesAn authored wity-system graph
opts.speednumbernoSimulation speed multiplier (default: 1)
opts.tickRatenumbernoWall-clock ms between ticks (default: 100)
opts.historyDepthnumbernoRing buffer depth per tracked feature (default: 360)
opts.inputLogDepthnumbernoHow many of the input events that arrived are kept for getInputEvents (default: 1000)
opts.eventNeighboursbooleannoAn 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? }) → this

Feature schema ​

js
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 },
]);
FieldTypeRequiredDescription
idstringyesUnique within type
frequencynumberyesEmission interval in ms
type'number' | 'boolean'noStorage type (default: 'number')
defaultValuenumbernoInitial value (default: 0)
rangenumber[]no[min, max] for number type
unitstringnoDisplay hint

Generators ​

js
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 ​

js
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 ​

js
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) → void

bind() 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 → number

State 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 it

History ​

trackHistory(uid, featureId) → void
trackAssetHistory(uid) → void
getHistory(uid, featureId, opts?) → { times: number[], values: number[] } | null

getNaturalHistory(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
FieldDescription
uid, featurethe asset and its signal (a number signal)
set / scale / addthe change — at least one
durationMshow long it lasts from now; omitted: until endIntervention
sourcewho 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) → void

inject 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) → unsubscribe

Events: '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) → void

Cleanup ​

destroy() → void

FeatureStore ​

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) → void

SimClock ​

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) → void

InfluenceEngine ​

Topologically-ordered state propagation along graph connections.

define(connectionType, rules) → void
propagate(system, store, updatedFeatures) → void
hasRules(connectionType) → boolean
getRules(connectionType) → InfluenceRule[]
clear() → void

HistoryBuffer ​

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