# wity-system -- Full Documentation Version: 0.15.0 --- ## QUICK REFERENCE -- read this first ### Core model wity-system is a headless spatial/topological network computation library. Level 2 domain library in the Wity ecosystem, composing over @wity/graph-headless (Level 1). SystemGraph is the top-level domain object. It wraps a GraphAbstract internally — one graph; consumers never touch it directly. Assets are typed nodes, connections are typed edges: the system's structure. Beside the structure a system can hold aspects — further models of the same system in the same graph, each in a formalism declared as data (see ASPECTS AND FORMALISMS). TypeRegistry declares behavioral traits (role, canFail, carriesFlow, costField) that algorithms consume automatically. Explicit options always override trait-derived defaults. ``` Level 1 @wity/graph-headless — DAG, typed nodes/edges, traversal, serialization ↑ Level 2 wity-system — type registry, cascade/impact, regions, playback, scenarios, spatial projection; aspects, each in a formalism declared as data (system-formalisms) ↑ Level 3 (application widget) — water networks, telecom infra, military planning ``` ### Install ```bash npm install wity-system # Peer dependency: npm install @wity/graph-headless ``` ### Import ```js import { SystemGraph, TypeRegistry, cascade, impact, serialize, parse, setGraphProvider } from 'wity-system'; ``` ### Minimal example ```js import { GraphAbstract } from '@wity/graph-headless'; import { SystemGraph } from 'wity-system'; const sys = new SystemGraph({ GraphAbstract }); sys.registerAssetType('plant', { role: 'source', canFail: true }); sys.registerConnectionType('feeds', { carriesFlow: true, costField: 'length' }); sys.addAsset('WTP', 'plant', { pressure: 100 }); sys.addAsset('V1', 'valve', {}); sys.addAsset('H1', 'house', {}); sys.addConnection('WTP', 'V1', 'feeds', { length: 500 }); sys.addConnection('V1', 'H1', 'feeds', { length: 120 }); sys.cascade(['V1']); // → { reachable: ['WTP'], cutOff: ['H1'], disabled: ['V1'] } sys.shortestPath('WTP', 'H1'); // → { path: ['WTP', 'V1', 'H1'], cost: 620 } ``` --- ## TYPE REGISTRY ### Asset type definition | Field | Type | Default | Description | |---|---|---|---| | `label` | string | type name | Display label | | `description` | string | `''` | Description | | `role` | `'source'`\|`'sink'`\|`'relay'` | `'relay'` | `cascade` auto-detects `'source'` nodes | | `canFail` | boolean | `true` | Which assets can be disabled | | `cascadeOnFailure` | boolean | `true` | Does failure sever downstream | | `coordFields` | string[]\|null | `null` | Per-type coordinate field names for `reproject` | | `meta` | object | `{}` | Open bag — L3 only | ### Connection type definition | Field | Type | Default | Description | |---|---|---|---| | `label` | string | type name | Display label | | `description` | string | `''` | Description | | `directionality` | `'directed'`\|`'bidirectional'` | `'directed'` | Direction handling | | `carriesFlow` | boolean | `true` | Edge filtering for propagate/cascade/impact/reachable | | `defaultCost` | number | `1` | Fallback cost for shortestPath/allPaths | | `costField` | string\|null | `null` | Reads cost from `edge.data[costField]` | | `meta` | object | `{}` | Open bag — L3 only | ### Trait queries ```js sys.types.getSourceTypes(); // → asset type names with role: 'source' sys.types.getSinkTypes(); // → asset type names with role: 'sink' sys.types.getFlowTypes(); // → connection type names with carriesFlow: true sys.types.getFailableTypes(); // → asset type names with canFail: true sys.types.buildCostFn(); // → (edge) => number cost function ``` ### Meta field Open bag on every type. wity-system stores it, never reads it. L3 consumers use it for domain-specific data: ```js meta: { snmpOid: '1.3.6.1.2.1', category: 'layer3', icon: 'router.svg' } // Cisco meta: { milSymbol: 'SFGPUCI', echelon: 'company', color: '#0000ff' } // Military meta: { material: 'PVC', maxPressure: 150, unit: 'psi' } // Water ``` --- ## ALGORITHMS ### Propagation BFS traversal from a starting node. ```js sys.propagate('V1'); // → [{ uid: 'H1', depth: 1, via: 'V1' }] ``` Options: `{ direction?, edgeTypes?, maxDepth? }`. Direction: `'downstream'` (default), `'upstream'`, `'both'`. Auto-filters to flow types when registry populated. ### Cascade Cut-off analysis: given disabled nodes, which nodes lose connectivity to sources. ```js sys.cascade(['V1']); // → { reachable: ['WTP'], cutOff: ['H1'], disabled: ['V1'] } ``` Algorithm: auto-detect sources (role: 'source'), filter to flow edges (carriesFlow: true), BFS from sources skipping disabled nodes, classify everything not reached as cut off. Options: `{ sourceUids?, edgeTypes?, direction? }`. ### Impact Upstream + downstream analysis from a node. ```js sys.impact('V1'); // → { downstream: [...], upstream: [...] } ``` Options: `{ edgeTypes?, maxDepth? }`. ### Reachable ```js sys.reachable('WTP'); // → ['V1', 'H1'] ``` Options: `{ direction?, edgeTypes? }`. ### Shortest path Dijkstra with trait-driven cost. Reads `costField` from connection type, falls back to `defaultCost`. ```js sys.shortestPath('WTP', 'H1'); // → { path: ['WTP', 'V1', 'H1'], cost: 620 } sys.shortestPath('WTP', 'H1', { cost: () => 1 }); // → { path: ['WTP', 'V1', 'H1'], cost: 2 } ``` Options: `{ edgeTypes?, cost? }`. Returns `{ path, cost }` or `null`. ### All paths DFS enumeration. ```js sys.allPaths('WTP', 'H1', { maxDepth: 10 }); // → [{ path: [...], cost: 620 }] ``` Options: `{ maxDepth?, edgeTypes?, cost? }`. ### Pure function variants ```js import { cascade, impact } from 'wity-system'; cascade(graph, ['V1'], { sourceUids: ['WTP'], edgeTypes: ['feeds'] }); impact(graph, 'V1', { edgeTypes: ['feeds'], maxDepth: 5 }); ``` --- ## ASPECTS AND FORMALISMS A system has one graph. Its assets and connections are its STRUCTURE: what the system is made of. An ASPECT is one more model of the same system, held in the same graph beside the structure — the order process of a shop. A system can hold any number of aspects, and needs no assets to hold one. A FORMALISM is the kind of model an aspect is. It is declared as data and fixes: the kinds of MEMBER an aspect of it has, the kinds of LINE between members, the RULES an aspect is checked against, what a member may name in the rest of the system (a LINK), and its own analyses. system-core is the mechanism and names no formalism; the formalisms are the package system-formalisms, handed to a system when it is made. | Word | Means | |---|---| | structure | the assets and connections of a system | | aspect | one further model of the same system, in the same graph: `{ id, formalism, label?, members, lines }` | | formalism | the kind of model an aspect is, declared as data | | member / line | a node of an aspect / an edge between two members of one aspect | | link | a member naming something of the structure (`performedBy: 'warehouse'`) — an edge of the graph, kept in step with the field | | member type | a kind of member a system declares for itself, in one formalism | ```js import { GraphAbstract } from '@wity/graph-headless'; import { SystemGraph } from 'wity-system'; import { formalisms, lanes } from 'wity-system/formalisms'; const sys = new SystemGraph({ GraphAbstract, formalisms }); sys.addAsset('warehouse', 'team', { label: 'Warehouse' }); sys.setAspect({ id: 'order', formalism: 'process', label: 'Handling an order', members: [ { id: 'placed', kind: 'start' }, { id: 'check', kind: 'task', label: 'Check stock', performedBy: 'warehouse', minutes: 5 }, { id: 'instock', kind: 'branch', label: 'In stock?' }, { id: 'pack', kind: 'task', minutes: 10 }, { id: 'done', kind: 'end' }, { id: 'refused', kind: 'end' }, ], lines: [{ from: 'placed', to: 'check' }, { from: 'check', to: 'instock' }, { from: 'instock', to: 'pack', label: 'yes' }, { from: 'instock', to: 'refused', label: 'no' }, { from: 'pack', to: 'done' }], }); sys.getAspect('order'); // the same value; each line with its kind sys.checkAspect('order'); // [] — or [{ rule, member?, line?, message }] sys.impact('instock', { in: 'order' }); // { downstream: [{ uid, depth, via }], upstream } — the same call that answers for an asset sys.shortestPath('placed', 'done', { in: 'order', costField: 'minutes' }); // { path: [...], cost: 15 } sys.cascade(['pack'], { in: 'order' }); // { reachable, cutOff, disabled }; with direction: 'upstream', what can no longer finish sys.membersLinkedTo('warehouse'); // [{ aspect: 'order', member: 'check', link: 'performed-by' }] lanes(sys.getAspect('order')); // the process formalism's own analysis // one member or line at a time — every other member stays the node it was sys.setAspectMember('order', { id: 'weigh', kind: 'task' }, { before: 'done' }); sys.setAspectLine('order', { from: 'pack', to: 'weigh' }); sys.removeAspectLine('order', { from: 'pack', to: 'done' }); sys.removeAspectMember('order', 'weigh'); sys.registerMemberType('approval', { formalism: 'process', kind: 'branch', label: 'Approval' }); ``` Shape and soundness are different things: a value that is not an aspect of its formalism in shape is refused (`setAspect` throws, naming where); one that is, but breaks a rule, is kept as a draft and `checkAspect` says what breaks which rule. The structure is unaffected: `getAssets`, `getConnections`, `cascade`, `impact`, `shortestPath`, regions, scenarios and the simulator see assets and connections only, and give the same answers with or without aspects. ### How an aspect is stored | In the graph | Type | Uid | Data | |---|---|---|---| | an aspect (node) | `system-aspect` | `$sys/aspect/` | `{ id, formalism, label?, … }` | | a member (node) | its member type, else its kind | `$sys/aspect//` | the member as written | | membership (edge) | `contains` | aspect → member | `{ order }` | | a line (edge) | the line's kind | member → member | `{ order, label?, … }` | | a link (edge) | the link's (`performed-by`) | member → what it names | follows from the member's field; not stored | In `snapshot()`: `aspects: { nodes, edges }`, only when there are some; member types under `types.memberTypes`. Earlier forms are still read and stored back in the present form: 0.14's `occurants` and `types.stepTypes`, 0.13's `activities` — each as an aspect of `process`. A system handed no formalisms keeps every aspect whole and stores it back unchanged; it cannot check or analyse it. ### The formalisms (generated from their declarations) #### Process — formalism `process` **Use it for:** Things that happen in an order — a procedure, a workflow, a flowchart, a checklist with decisions in it. **Not for:** Parts that exist and connect (that is the map's assets and connections), or how a number changes over time (a behaviour). Its members are called **steps**, its lines **flows**. | Kind of step | What it is | flows in | flows out | |---|---|---|---| | `start` | where the process begins | 0 | 1 | | `end` | where it finishes | 1 or more | 0 | | `task` | a piece of work | 1 or more | 1 | | `branch` | one way out is taken — each flow out says its case in its label | 1 or more | 2 or more | | `split` | every way out is taken, side by side | 1 or more | 2 or more | | `join` | waits for all the ways in, then goes on | 2 or more | 1 | | Kind of flow | What it says | |---|---| | `then` | `from` happens, then `to` | A step may name: `performedBy` — the uid of the asset that does the step — the steps one asset does are its lane. A member type of this formalism is a `task` unless it says another kind. Checked (beside the lines in and out above): - A process begins at one start - A process finishes at an end - Each way out of a branch says the case it is taken in, and no two say the same - A step is performed by an asset of the system - Every step can be reached from the start - From every step, an end can be reached Its own analyses: `lanes` — who performs which steps, and where the work changes hands. #### State machine — formalism `state-machine` **Use it for:** The conditions one thing can be in and the events that move it between them — an order (placed, paid, shipped), a connection, a door, a document under review. **Not for:** Work done in an order by several parts (that is a process), or a choice someone must make (a decision). Its members are called **states**, its lines **transitions**. | Kind of state | What it is | transitions in | transitions out | |---|---|---|---| | `initial` | where it starts — a marker, with one transition to the first state | 0 | 1 | | `state` | a condition it stays in until an event takes it elsewhere | any | any | | `final` | where it ends — nothing leaves it | any | 0 | | Kind of transition | What it says | |---|---| | `transition` | on the event in `label`, `from` gives way to `to` (`guard`: a condition that must also hold) | A member type of this formalism is a `state` unless it says another kind. Checked (beside the lines in and out above): - A state machine starts at one initial marker - Each transition out of a state says the event it is taken on, and no two out of one state say the same - Every state can be reached from the initial marker #### Petri net — formalism `petri-net` **Use it for:** Things that are used up and made, or shared and waited for — stock and orders, machines and jobs, a protocol where two sides must both be ready. **Not for:** A plain order of steps with no counting of what is available (that is a process). Its members are called **nodes**, its lines **arcs**. | Kind of node | What it is | arcs in | arcs out | |---|---|---|---| | `place` | holds tokens — a stock, a free machine, a waiting order (`tokens`: how many it starts with) | any | any | | `transition` | something that happens when every place leading into it has enough tokens | any | any | | Kind of arc | What it says | |---|---| | `arc` | from a place to a transition (it takes tokens) or from a transition to a place (it gives them); `weight`: how many — only place → transition, transition → place | A member type of this formalism is a `place` unless it says another kind. Checked (beside the lines in and out above): - A Petri net has at least one place - A Petri net has at least one transition - Each arc joins the kinds it may: arc place → transition or transition → place - No node is cut off from the rest #### Behaviour tree — formalism `behaviour-tree` **Use it for:** How an agent — a robot, a game character, an automated responder — picks what to do next from what is true right now: priorities, fallbacks, checks before acting. **Not for:** A fixed order of steps (that is a process), or the conditions a thing can be in (a state machine). Its members are called **nodes**, its lines **links**. | Kind of node | What it is | links in | links out | |---|---|---|---| | `sequence` | does its children in order; fails at the first that fails | 0 to 1 | 1 or more | | `selector` | tries its children in order; succeeds at the first that succeeds | 0 to 1 | 1 or more | | `parallel` | does all its children; succeeds when all succeed | 0 to 1 | 1 or more | | `inverter` | one child; succeeds when it fails and fails when it succeeds | 0 to 1 | 1 | | `repeat` | one child, done again (`times`: how many; forever when not said) | 0 to 1 | 1 | | `action` | a leaf: something done | 0 to 1 | 0 | | `condition` | a leaf: something checked — true or false | 0 to 1 | 0 | | Kind of link | What it says | |---|---| | `child` | `to` is a child of `from`; a node's children are tried in the order of its lines | A member type of this formalism is a `action` unless it says another kind. Checked (beside the lines in and out above): - It is a tree: each node has at most one parent, and none leads back to itself - It is one tree: no node is cut off from the rest #### Syntax tree — formalism `syntax-tree` **Use it for:** The structure of something written — a formula, a line of code, a query, a sentence — shown as parts within parts. **Not for:** Choosing what to do (a behaviour tree), or steps in an order (a process). Its members are called **nodes**, its lines **links**. A node is of any kind you name. | Kind of link | What it says | |---|---| | `child` | `to` is a part of `from`; parts are in the order of the lines, and `label` may say what part it is | Checked (beside the lines in and out above): - It is a tree: each node has at most one parent, and none leads back to itself - It is one tree: no node is cut off from the rest ### Declaring a formalism ```js sys.registerFormalism({ id: 'net', label: 'Net', words: { member: 'node', line: 'arc', self: 'net' }, nodeKinds: { place: { label: 'Place', fields: { tokens: { type: 'number', min: 0 } } }, gate: { label: 'Gate' } }, lineKinds: { arc: { label: 'Arc', pairs: [['place', 'gate'], ['gate', 'place']] } }, rules: [{ rule: 'joins' }, { rule: 'connected' }], links: { heldBy: { to: 'asset', edge: 'held-by', says: "an asset's uid" } }, roles: { entry: 'place' }, guide: { when: 'Things consumed and produced.', not: 'Steps in an order — that is a process.' }, }); ``` `nodeKinds` (or `openKinds: true` for any kind), `lineKinds` (`pairs`, `self`, `fields`), `rules`, `links`, `roles` (`entry`, `exit` — the kinds an aspect begins and ends at), `memberTypeKind`, `analyses` (`{ name: { says, run(aspect) } }`), `guide` (`when`, `not`). The rules a declaration may list, in the order they are checked: - `count` — a kind of member occurs exactly / at least / at most n times — `{ rule: 'count', kind, exactly | min | max }` - `degree` — how many lines come into and go out of each member of a kind — `{ rule: 'degree', kind, in: [min, max], out: [min, max], line? }` (`null` max: any number) - `labelled-out` — every line out of a kind of member carries a label, and no two the same — `{ rule: 'labelled-out', kind, line? }` - `link` — what a member names in a link field is there — `{ rule: 'link', field }` (checked against the system: a task's performer is one of its assets) - `joins` — every line joins kinds of member its kind of line allows — `{ rule: 'joins' }`, with `pairs` on the line kinds - `reachable-from` — every member can be reached from the one member of a kind — `{ rule: 'reachable-from', kind, line? }` - `can-reach` — from every member, a member of a kind can be reached — `{ rule: 'can-reach', kind, line? }` - `acyclic` — no line of a kind leads back to where it started — `{ rule: 'acyclic', line }` - `tree` — each member has at most one line of a kind coming in, and none leads back to itself — `{ rule: 'tree', line }` - `connected` — no member is cut off from the rest — `{ rule: 'connected' }` --- ## SCENARIOS Named graph + viewport + regions snapshots. Deep-cloned. ```js sys.saveScenario('baseline'); sys.updateAsset('V1', { status: 'closed' }); sys.saveScenario('v1-shutdown'); sys.loadScenario('baseline'); const diff = sys.diffScenarios('baseline', 'v1-shutdown'); // → { assets: { added, removed, modified }, connections: { ... }, regions: { ... } } ``` CRUD: `saveScenario(name)`, `loadScenario(name)`, `deleteScenario(name)`, `getScenarioNames()`. --- ## REGIONS Polygon spatial annotations. Not part of topology. ```js sys.addRegion('zone-north', { label: 'North Service Zone', polygon: [[12.95, 77.55], [12.95, 77.65], [13.05, 77.65], [13.05, 77.55]], meta: { fill: '#3b82f620', stroke: '#3b82f6' }, }); sys.getAssetsInRegion('zone-north'); // → ['T1', 'P3', 'H12'] sys.getRegionsOfAsset('T1'); // → ['zone-north'] ``` Containment reads coordinate fields per-type or from global spatial config. CRUD: `addRegion`, `updateRegion`, `getRegion`, `removeRegion`, `getRegions`, `regionCount`. Included in scenario snapshots. --- ## PLAYBACK Ordered step sequences. Pure data — L3 interprets. ```js sys.setPlayback([ { type: 'focus', center: { a: 12.42, b: 76.57 }, zoom: 12 }, { type: 'reveal-assets', uids: ['KRS', 'TKH-1'] }, { type: 'reveal-connections', uids: ['trunk:KRS→TKH-1'] }, { type: 'scenario', name: 'drought-2024' }, ]); ``` Step types: `focus`, `reveal-assets`, `reveal-connections`, `reveal-regions`, `annotate`, `scenario`. Open-ended. ### Selector resolution ```js sys.resolveSelector({ type: 'region', uid: 'zone-north' }); // → asset UIDs in region sys.resolveSelector({ type: 'assetType', name: 'plant' }); // → all assets of type sys.resolveSelector({ type: 'path', from: 'WTP', to: 'H1' }); // → shortest path UIDs sys.resolveSelector({ type: 'uids', uids: ['WTP', 'V1'] }); // → validated UIDs sys.resolveSelector({ type: 'all' }); // → all asset UIDs sys.resolveSelector({ type: 'union', selectors: [...] }); // → deduplicated union ``` `resolvePlaybackStep(step, prevAssets?)` resolves a full step into `{ assets, connections, regions, viewport }`. --- ## VIEWPORT ```js sys.setCenter(a, b); sys.setZoom(zoom); sys.setExtent(sw, ne); sys.setViewport({ center, zoom, bounds }); sys.getViewport(); // → { center, zoom, bounds } sys.computeExtent(); // → bounding box from all spatial assets sys.getExtentOfAssets(uids); // → bounding box for subset ``` --- ## SPATIAL PROJECTION Optional. Pluggable coordinate adapters. ```js const sys = new SystemGraph({ GraphAbstract, spatial: { project: (lat, lon) => mapLib.project([lon, lat]), unproject: (x, y) => mapLib.unproject([x, y]), coordFields: ['lat', 'lon'], }, }); sys.reproject(); // writes x/y into all assets from their coord fields ``` Per-type `coordFields` override global default. --- ## ASSET & CONNECTION CRUD ### Assets ```js sys.addAsset(uid, type, data?); sys.updateAsset(uid, data); sys.getAsset(uid); sys.removeAsset(uid); sys.getAssets(); sys.getAssetsByType(type); sys.assetCount; ``` ### Connections ```js sys.addConnection(srcUid, targetUid, type?, data?); sys.updateConnection(srcUid, targetUid, type?, data?); sys.getConnection(srcUid, targetUid, type?); sys.removeConnection(srcUid, targetUid, type?); sys.getConnections(); sys.getConnectionsByType(type); sys.getOutgoing(uid, type?); sys.getIncoming(uid, type?); sys.getConnectionsAmong(assetUids); sys.connectionCount; ``` Connection UIDs: `${type}:${srcUid}→${targetUid}`. --- ## SERIALIZATION ### Snapshot (in-memory) ```js const data = sys.snapshot(); // → plain object sys.restore(data); // rebuild from snapshot ``` ### (XML round-trip) ```js import * as graph from '@wity/graph-headless'; import { setGraphProvider, serialize, parse } from 'wity-system'; setGraphProvider(graph); // once at startup const xml = serialize(sys.snapshot()); const snapshot = parse(xml); sys.restore(snapshot); ``` ### Snapshot format (v1) ```js { version: 1, graph: { nodes, edges }, scenarios: { name: { graph, viewport, regions } }, spatial: { coordFields } | null, types: { assetTypes: {...}, connectionTypes: {...} } | null, viewport: { center, zoom, bounds }, regions: { uid: { label, polygon, meta } } | null, playback: [{ type, ...fields }] | null, // each of these only when the system has it canvas, meta, run, view, dynamics, aspects: { nodes, edges }, // its aspects, as they are in the graph (see Aspects and formalisms) } ``` ### Graph mapping Metadata nodes use `$sys` UID prefix: - `$sys` (type `system-document`) — version, viewport, spatial, other top-level fields - `$sys/types` (type `system-types`) — serialized TypeRegistry - `$sys/scenario/` (type `system-scenario`) — each saved scenario - `$sys/aspect/` (type `system-aspect`) and `$sys/aspect//` — each aspect and its members, with the edges between them - User asset nodes and connection edges map directly --- ## SIMULATION (system-sim) ### Architecture wity-system is a monorepo with two packages: ``` system-core — SystemGraph, TypeRegistry, cascade, impact, serialize, parse, setGraphProvider (wity-system) Peer dependency: @wity/graph-headless. Zero DOM. Isomorphic. system-sim — SimulationRuntime, FeatureStore, SimClock, InfluenceEngine, HistoryBuffer Temporal simulation runtime over system-core. Frequency-based features, topological influence, event detection. ``` system-sim layers temporal dynamics onto an authored SystemGraph. The runtime never mutates the SystemGraph — it reads the topology and types, stores all simulation state in its own columnar FeatureStore. ### Import ```js // system-core import { SystemGraph, TypeRegistry } from 'wity-system'; // system-sim import { SimulationRuntime, FeatureStore, SimClock, InfluenceEngine, HistoryBuffer } from 'wity-system/sim'; ``` ### Minimal simulation example ```js import { GraphAbstract } from '@wity/graph-headless'; import { SystemGraph } from 'wity-system'; import { SimulationRuntime } from 'wity-system/sim'; const sys = new SystemGraph({ GraphAbstract }); sys.registerAssetType('router', { role: 'relay' }); sys.registerConnectionType('data-link', { carriesFlow: true }); sys.addAsset('R1', 'router', {}); sys.addAsset('R2', 'router', {}); sys.addConnection('R1', 'R2', 'data-link'); const sim = new SimulationRuntime(sys, { speed: 10, tickRate: 100 }); sim.defineFeatures('router', [ { id: 'cpu-load', frequency: 5000, range: [0, 100], unit: '%' }, { id: 'mem-used', frequency: 15000, range: [0, 64], unit: 'GB' }, ]); sim.setGenerator('router', 'cpu-load', (v, t, dt) => { return Math.max(0, Math.min(100, v + (Math.random() - 0.5) * 10)); }); sim.defineInfluence('data-link', [ { source: 'cpu-load', target: 'cpu-load', fn: (src, tgt) => tgt + src * 0.1 }, ]); sim.defineEvent('overload', { condition: (state) => state['cpu-load'] > 90, cooldown: 30000, }); sim.on('tick', ({ simTime, dt, changedCount }) => { console.log(`tick at ${simTime}ms, ${changedCount} features changed`); }); sim.on('event', ({ id, uid, simTime }) => { console.log(`${id} on ${uid} at ${simTime}`); }); sim.bind().start(); ``` --- ### SimulationRuntime Top-level orchestrator. Composes FeatureStore, SimClock, InfluenceEngine, and HistoryBuffer. Runs a 5-phase tick loop: Generate → Influence → History → Events → Emit. #### Constructor ```js 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) | #### Schema definition **defineFeatures(typeName, schemas) → this** Registers features for an asset type. Each feature has a frequency (emission interval in ms). ```js sim.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 }, ]); ``` Feature schema fields: | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Unique within type | | `frequency` | number | yes | Emission interval in ms (must be > 0) | | `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 | **setGenerator(typeName, featureId, fn) → this** Registers a value-producing function for a feature. Called once per tick when that feature is due. ```js sim.setGenerator('router', 'cpu-load', (currentValue, simTime, dt, asset) => { return currentValue + (Math.random() - 0.5) * 10; }); ``` **defineInfluence(connectionType, rules) → this** Declares how feature values propagate along connections. Rules are applied in topological order — multi-hop chains (A→B→C) fully resolve in a single tick for DAGs. Cycles converge over multiple ticks. ```js sim.defineInfluence('data-link', [ { source: 'cpu-load', target: 'cpu-load', fn: (srcVal, tgtVal, conn) => tgtVal + srcVal * 0.1 }, ]); ``` **defineEvent(eventId, { condition, cooldown? }) → this** Registers a condition-based event. Checked per-asset per-tick. ```js sim.defineEvent('overload', { condition: (state, asset) => state['cpu-load'] > 90, cooldown: 30000, // ms before re-firing for same asset }); ``` #### Binding ```js sim.bind(); // walks all SystemGraph assets, registers into FeatureStore sim.bindAsset('R3', 'router'); // add a single asset incrementally ``` `bind()` must be called after `defineFeatures`, before `start`. #### Control ```js sim.start({ speed: 10 }); // begin simulation sim.pause(); // pause (retains state) sim.resume(5); // resume, optionally at new speed sim.stop(); // halt and reset clock sim.step(100); // advance by dt ms (manual tick) sim.setSpeed(2); // change speed multiplier sim.simTime; // current simulation time (ms) sim.running; // boolean sim.speed; // current speed multiplier ``` #### State access ```js sim.getValue('R1', 'cpu-load'); // → number | undefined sim.getAssetState('R1'); // → { 'cpu-load': 42.5, 'mem-used': 12.1 } | null sim.getSnapshot(); // → { simTime, values } ``` #### Injection (external signals) ```js sim.inject('R1', 'cpu-load', 95); // set feature value directly sim.injectEvent('overload', { uid: 'R1' }); // fire event without condition check ``` #### History History is opt-in. Only tracked features consume ring-buffer memory. ```js sim.trackHistory('R1', 'cpu-load'); sim.trackAssetHistory('R1'); // track all features for asset sim.getHistory('R1', 'cpu-load'); // → { times: [1000, 6000, 11000, ...], values: [45.2, 51.8, 48.3, ...] } sim.getHistory('R1', 'cpu-load', { from: 5000, to: 15000 }); // → filtered to time window ``` #### Events ```js const unsub = sim.on('tick', ({ simTime, dt, changedCount }) => { ... }); const unsub2 = sim.on('event', ({ id, uid, simTime, state, source }) => { ... }); sim.on('started', () => { ... }); sim.on('paused', () => { ... }); sim.on('stopped', () => { ... }); unsub(); // unsubscribe ``` Event sources: `'condition'` (from defineEvent checks) or `'external'` (from injectEvent). #### Serialization ```js const data = sim.serialize(); // → { simTime, values } sim.restore(data); // rebuild feature state from snapshot sim.destroy(); // clean up timers, clear all state ``` --- ### Declarative dynamics Behaviour is authored and serialized as data. A Dynamics spec is a plain object; `compileDynamics` applies it onto a runtime through `setGenerator` / `defineInfluence` / `defineEvent`. The state space (features) lives on the system's asset types; behaviour lives in the spec, so one system can bear many Dynamics. Raw functions remain the escape hatch. ```js import { buildRuntime, compileDynamics, validateDynamics } from 'wity-system/sim'; const dynamics = { generators: { 'router:cpu': { kind: 'randomWalk', step: 6, bias: 0.5 }, '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 } }, }, }, influence: { fiber: [{ source: 'cpu', target: 'cpu', kind: 'proportional', gain: 0.2 }] }, modes: { overload: { all: [ { feature: 'cpu', op: '>', value: 85 }, { feature: 'mem', op: '>', value: 90 } ] } }, events: { 'high-cpu': { kind: 'threshold', feature: 'cpu', op: '>', value: 85, cooldown: 5000 }, 'overload-alarm': { kind: 'whenMode', mode: 'overload', cooldown: 5000 }, }, }; validateDynamics(dynamics); // [{ path, message }] — [] when valid; never throws const sim = buildRuntime(system, dynamics, { speed: 1 }); // features from the types, spec compiled, bound sim.start(); ``` `buildRuntime(system, spec, opts?, registries?)` is the canonical spec → running sim path: construct, `defineFeaturesFromSystem()`, `compileDynamics`, `bind()`. It returns a bound runtime that is not started. The types must carry their feature schemas. Generator archetypes (times in ms of simulation time): | kind | params | |---|---| | `constant` | `value` | | `sine` | `offset, amplitude, period, phase` | | `randomWalk` | `step, bias` | | `noise` | `base, amplitude` | | `ramp` | `from, to, duration` | | `pulse` | `base, peak, start, rise, hold, fall, every` — sits at `base`; from `start` rises to `peak` over `rise`, holds `hold`, falls back over `fall`; repeats every `every` when > 0 | | `sum` | `terms` — adds sub-generators; each term keeps its own previous value per asset, so a `randomWalk` term walks from its own last value | | `byAsset` | `default`, `assets` — a generator per asset uid; no entry → `default`; no `default` → the value is held | Influence archetypes: `proportional` (`gain`), `additive` (`amount`), `decay` (`rate`, 0..1), `max`, `replace`. Event archetypes: `threshold` (`feature, op, value`), `range` (`feature, min, max`), `whenMode` (`mode`). Modes are named predicates over features: atoms `{ feature, op, value }` / `{ feature, in: [min, max] }`; connectives `all` / `any` / `not`; `{ mode }` references (cyclic references resolve to false); graph quantifiers `anyNeighbor` / `allNeighbors` / `anySource` / `allSources`; `{ count: { of: 'neighbors'|'sources', where, op, value } }`. Exported as `evaluatePredicate(pred, ctx)` and `validatePredicate(pred, ctx?)`. `simulate(system, spec, { durationMs = 120000, stepMs = 100, maxOccurrences = 200 })` runs a behaviour headless — no clock, no screen — and reports what happened: `events` (`{ id, count, firstAt, lastAt, assets }`), `occurrences` (the first firings, `{ t, id, uid }`, capped) and `signals` (per asset per feature: `min, max, last, mean`, and for a feature watched by a threshold event its `threshold` and `overMs`). The system is not changed. The system itself holds its behaviours: `sys.getDynamics()`, `setDynamics(list)`, `setDynamicsSpec(spec)` (add or replace by id), `removeDynamicsSpec(id)`; they are part of `snapshot()` when there is at least one. `validateDynamics` checks nested generators and event fields; pass `ctx.featureExists`, `ctx.connectionTypeExists`, `ctx.featureKnown` to check references against a system. Event cooldown is per asset. An asset that has not fired is treated as having last fired at simulation time 0, so an event with a cooldown cannot fire for it before `simTime ≥ cooldown`. --- ### FeatureStore Columnar Float64Array storage. One array per feature per asset type, indexed by asset ordinal. Cache-friendly sequential scans for the generate phase (~1.6MB for 100 nodes × 2000 features). ```js const store = new FeatureStore(); store.defineFeatures('router', [ { id: 'cpu-load', frequency: 5000 }, ]); store.addAsset('R1', 'router'); store.setValue('R1', 'cpu-load', 42.5); store.getValue('R1', 'cpu-load'); // → 42.5 store.getAssetState('R1'); // → { 'cpu-load': 42.5 } store.getFeatureArray('router', 'cpu-load'); // → Float64Array (raw column) store.getDueFeatures('router', 10000); // → feature IDs due at simTime 10000 store.markEmitted('router', 'cpu-load', 10000); store.removeAsset('R1'); // swap-and-pop ordinal compaction store.totalFeatureCount; // → total defined features const snap = store.snapshot(); store.restore(snap); ``` #### Swap-and-pop removal When `removeAsset` is called, the removed ordinal is swapped with the last element in each Float64Array. The moved asset's index is updated. This keeps arrays dense with no holes. --- ### SimClock Simulation clock with speed control and wall-time drift compensation. Uses `performance.now()` elapsed wall time (not fixed increment) to prevent drift accumulation. ```js const clock = new SimClock((simTime, dt) => { console.log(`tick: ${simTime}ms, dt: ${dt}ms`); }, { tickRate: 100, speed: 1 }); clock.start(); clock.setSpeed(10); clock.pause(); clock.resume(5); clock.step(100); // manual tick clock.stop(); clock.reset(); clock.simTime; // current simulation time clock.speed; // current speed multiplier clock.running; // boolean clock.tickRate; // wall-clock ms between ticks ``` --- ### InfluenceEngine Topologically-ordered state propagation along graph connections. Uses Kahn's algorithm so multi-hop chains (A→B→C) fully resolve in a single tick for DAGs. Cycle remnants are appended in original order and converge over multiple ticks. ```js const engine = new InfluenceEngine(); engine.define('data-link', [ { source: 'cpu-load', target: 'cpu-load', fn: (src, tgt, conn) => tgt + src * 0.1 }, ]); engine.hasRules('data-link'); // → true engine.getRules('data-link'); // → [{ source, target, fn }] engine.propagate(system, store, updatedFeatures); engine.clear(); ``` --- ### HistoryBuffer Opt-in ring-buffer time-series storage per feature. Fixed depth, oldest entries overwritten. ```js const history = new HistoryBuffer(360); // depth history.track('R1', 'cpu-load'); history.trackAsset('R1', ['cpu-load', 'mem-used']); history.record('R1', 'cpu-load', 1000, 42.5); history.record('R1', 'cpu-load', 6000, 48.3); history.get('R1', 'cpu-load'); // → { times: [1000, 6000], values: [42.5, 48.3] } history.get('R1', 'cpu-load', { from: 2000, to: 10000 }); // → filtered history.isTracked('R1', 'cpu-load'); // → true history.untrack('R1', 'cpu-load'); history.clear(); ``` --- ### Design decisions **Why columnar Float64Array?** Sequential scan over one feature across all assets of the same type = one contiguous memory read. The generate phase iterates feature-by-feature, not asset-by-asset. For 100 nodes × 2000 features, total storage is ~1.6MB. **Why topological sort for influence?** Arbitrary-order iteration would miss multi-hop propagation in a single tick. Kahn's algorithm gives O(V+E) single-pass guarantee for DAGs. **Why swap-and-pop for removal?** Preserves dense arrays. Alternative (tombstone markers) fragments the Float64Array and requires skip logic in every scan. **Why wall-time drift compensation?** `setInterval` accumulates error because each callback fires after `tickRate + scheduling jitter`. Using `performance.now()` elapsed time ensures simTime advances at the correct scaled rate regardless of timer precision. **Why are events L2?** Condition predicates evaluate against feature state (potentially 200K values) that only the simulation runtime owns. Exporting this state to L3 every tick would be prohibitive. Event routing and handling (UI notifications, automated responses) is L3. --- ## SHOWING A SYSTEM (system-ui-compute, system-ui) Two more packages in the monorepo: ``` system-ui-compute — published as @wity-ai/system-ui-compute (monorepo: wity-system/ui-compute). Zero DOM. computeScene (a system resolved to a render scene), computeAspectScene (one aspect of it as the same scene) and the signal strip views. system-ui — published as @wity-ai/system-ui. Muffin components that draw what system-ui-compute returns; the host page provides the Muffin global. . ``` ### computeScene `computeScene(system, opts?) → { bounds, nodes, edges, regions }` — one source of visual truth for every renderer. Options: `getAssetType`, `getConnectionType` (default to the system's), `positionOf(asset) → { x, y }` (default: the asset's `data.x` / `data.y`). - node: `{ uid, type, x, y, shape, fill, fillLight, fillLighter, icon, label }`; a circle adds `radius`; a block (type `meta.shape === 'block'`) adds `dims: { w, h }`, `items`, `contentColor`; annotations add `annotations: [{ text, status, dx, dy, chipW, chipH }]` - edge: `{ uid, type, srcUid, targetUid, from, to, stroke, strokeHover, dashed, carriesFlow, arrow }` — endpoints offset to each node's rim - `blockDims(asset, typeDef) → { w, h }` ### computeAspectScene `computeAspectScene(system, aspectId) → { aspect: { id, label, formalism }, bounds, nodes, edges, regions, legend, view, findings } | null` — one aspect as the same scene shape, so one painter draws both; null when there is none, or its formalism declares no layout. Drawn from the declaration: each kind's `look` (shape, size, colour), `layout` 'rows' (from where it begins) or 'tree' (parents over children, in line order), a link marked `lane` framing the members that name one asset. ### Signal strip views Worked out from the simulation's recorded history; inputs are plain data and functions. ``` assets [{ uid, label, type }] featuresOf (type) → { [featureId]: { range?: [lo, hi] } } events the applied dynamics' events — only `threshold` events are read as thresholds history (uid, featureId) → { times, values } | null marks [{ t, uid, kind: 'event' | 'decision', name, status? }] now current simulation time (ms) windowMs default 120000 ``` `wavesView({ …, pinned?, max? = 4 }) → { t0, t1, rows: [{ uid, label, featureId, range, last, threshold, points, over, marks }] }` — a few signals in detail. `points` are `[x, y]` in 0…1; `over` are `[x0, x1]` spans past the threshold. Shown: pinned assets, then those that fired events (most recent first), then — while there is room — the assets closest to a threshold. A pinned asset shows all its signals; others show the watched ones. `heatView({ …, buckets? = 60 }) → { t0, t1, buckets, rows: [{ uid, label, featureId, cells, peak, marks }] }` — every asset with a watched signal; a cell is the highest stress in its bucket (`null`: no sample); a sparsely sampled signal holds its value between its first and last sample; rows hottest first. `stress(value, { op, value }, range?)` — 1 = at the threshold, above 1 = past it. `thresholdsByFeature(events)`, `stripAssets({ assets, marks, pinned, max })`, `assetsOverAt({ assets, featuresOf, events, history, t })`. ### ```js import { SignalStrip } from '@wity-ai/system-ui'; SignalStrip.compose(); strip.configure({ assets: () => [{ uid, label, type }], featuresOf: (type) => ({ utilization: { range: [0, 1] } }), events: () => appliedDynamics.events, history: (uid, featureId) => sim.getHistory(uid, featureId), now: () => sim.simTime, onSelect: (uid) => {}, simulated: true, }); strip.mark({ t, uid, kind: 'event' | 'decision', name, status }); strip.pin(uid); strip.unpin(uid); strip.focus(uid, t); // keep uid in view, Signals view, marker at t; focus(null) clears strip.setView('waves' | 'heat'); strip.refresh(); // on the simulation's tick — throttle it strip.clear(); // a new run: forget the marks (pins stay) ```