diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md index 5376b77..6c10464 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -181,6 +181,17 @@ From `SimEngine._tick()`: 6. **Commit atomically** (`_applyCtx`). All accumulated movements apply at once. 7. **Apply modifiers**, then **refresh variables** and **evaluate registers**. +### Conditions are synchronous + +Activator conditions and connection conditions are evaluated against the state as it +stood at the **start of the step**: the engine snapshots every node's value (including +registers, which keep their usual one‑step lag) before the fire phase, and every check +reads that snapshot. A pull node that empties a pool mid‑step therefore can't flip a +condition another node was about to test, so results never depend on the order nodes +happen to be stored in. **Trigger cascades are the exception.** A node fired by a +trigger or reverse trigger sees the current mid‑step state, because a cascade is a +causal reaction within the step and its order comes from the graph, not from storage. + ### Fair allocation under contention When several outgoing connections compete for one pool's resources, the engine uses diff --git a/js/engine.js b/js/engine.js index c38f103..4958c33 100644 --- a/js/engine.js +++ b/js/engine.js @@ -12,6 +12,10 @@ class SimEngine { // Terminal state when a node's end/goal condition is met. this.ended = null; // { nodeId, label, step, value } | null this.onEnd = null; // Callback(ended) + // Tick-start snapshot of every node's state value, set for the duration + // of each doStep. Condition/activator checks read it via _condValueOf so + // decisions are synchronous (see that method). Null between steps. + this._tickSnap = null; } saveInitial() { @@ -136,7 +140,16 @@ class SimEngine { // rate formulas; per-play ones keep the value sampled when Run started. this._sampleCustomVars('step'); this.step++; + // Snapshot every node's state value before the fire phase. Activator and + // connection-condition checks read this snapshot (via _condValueOf), so a + // decision reflects the state at the START of the step and cannot depend + // on the order nodes happen to be stored in (a pull node draining a pool + // mid-tick can't flip a condition another node was about to check). + // Movement itself (rates, allocation, capacity) still reads live state. + this._tickSnap = new Map(); + for (const n of this.diagram.nodes.values()) this._tickSnap.set(n.id, this._stateValueOf(n)); const { fired, transfers } = this._tick(); + this._tickSnap = null; // fireInteractive between steps reads live state this._record(); if (this.onStep) this.onStep(this.step, fired, transfers); this._checkEnd(); @@ -233,7 +246,9 @@ class SimEngine { for (const c of d.outgoing(node.id)) { if (c.type === ConnectionType.STATE && c.reverseTrigger && this._triggerPasses(c)) { const tgt = d.nodes.get(c.targetId); - if (tgt) failQueue.push({ node: tgt, forced: true }); + // live: like trigger cascades, reverse-triggered fires are causal + // reactions and see mid-step state (see _nodeEnabled). + if (tgt) failQueue.push({ node: tgt, forced: true, live: true }); } } } @@ -280,27 +295,35 @@ class SimEngine { const queue = [...initial]; let guard = 0; while (queue.length && guard++ < 5000) { - const { node, forced } = queue.shift(); + const { node, forced, live } = queue.shift(); if (!node || activated.has(node.id)) continue; - if (!this._nodeEnabled(node)) continue; + if (!this._nodeEnabled(node, live)) continue; if (!this._fireNode(node, ctx, forced)) continue; activated.add(node.id); fired.push(node.id); for (const t of d.outgoing(node.id)) { if (t.type === ConnectionType.STATE && t.trigger && this._triggerPasses(t)) { const tgt = d.nodes.get(t.targetId); - if (tgt) queue.push({ node: tgt, forced: true }); + // live: a cascade-fired node's activators see mid-step state (see + // _nodeEnabled); the cascade's order is causal, not storage order. + if (tgt) queue.push({ node: tgt, forced: true, live: true }); } } } } // A node may fire only while every incoming activator's condition holds. - _nodeEnabled(node) { + // Main-phase fires (automatic/starting/artificial player) evaluate against + // the tick-start snapshot, so the result is independent of node storage + // order. Trigger and reverse-trigger cascades pass live=true and see the + // current mid-step state: a cascade is a causal reaction within the step + // whose order is defined by the graph, so live reads there stay + // deterministic and are intentional. + _nodeEnabled(node, live = false) { for (const conn of this.diagram.incoming(node.id)) { if (conn.type !== ConnectionType.STATE || !conn.activator) continue; const src = this.diagram.nodes.get(conn.sourceId); - const val = this._stateValueOf(src); + const val = live ? this._stateValueOf(src) : this._condValueOf(src); if (!this._evalCond(val, conn.actOperator, conn.actValue, conn.actValue2)) return false; } return true; @@ -332,6 +355,18 @@ class SimEngine { return node.resources; } + // The value a condition/activator check sees for `node`. During a step this + // is the tick-start snapshot taken in doStep, so decisions are synchronous: + // they read the state as it stood when the step began, not whatever an + // earlier-stored node already moved this tick. Registers are in the + // snapshot too (their previous settled value, matching the one-step + // variable lag). Outside a step (fireInteractive) there is no snapshot and + // the read is live. Movement helpers keep using _stateValueOf directly. + _condValueOf(node) { + if (node && this._tickSnap && this._tickSnap.has(node.id)) return this._tickSnap.get(node.id); + return this._stateValueOf(node); + } + // Re-evaluate custom variables and publish them into the variable store. // which: 'all' (reset), 'step' (each step), 'play' (each Run press). // Math-kind variables read the store itself, so params are seeded first and @@ -1218,9 +1253,12 @@ class SimEngine { if (conn.interval > 1 && (this.step - 1) % conn.interval !== 0) return false; if (conn.chance < 100 && SimRandom.random() * 100 >= conn.chance) return false; if (conn.condEnabled) { + // Source-value conditions read the tick-start snapshot (variables + // already lag one step by design). Cascade-fired nodes never get here: + // their fires are forced, which bypasses _connFires entirely. const val = (conn.condRefMode === 'variable' && conn.condVariable) ? (this.diagram.variables[conn.condVariable] ?? 0) - : this._stateValueOf(sourceNode); + : this._condValueOf(sourceNode); if (!this._evalCond(val, conn.condOperator, conn.condValue, conn.condValue2)) return false; } return true; diff --git a/test/run.js b/test/run.js index ded94e1..8eb98a7 100644 --- a/test/run.js +++ b/test/run.js @@ -2644,6 +2644,123 @@ test('restoreState restores structure removed after the checkpoint', () => { eq(d.connections.size, 1, 'connection restored from checkpoint'); }); +// ── Synchronous conditions (tick-start snapshot) ──────────────────────────── +console.log('\nSynchronous conditions'); + +test('activator result does not depend on node insertion order', () => { + // A pull-mode drain empties P during the fire phase; a source gated by an + // activator "P >= 5" must see P's tick-start value regardless of whether + // the drain was inserted before or after it. + const build = (drainFirst) => { + const d = new Diagram(); + d.seed = 'sync-a'; + const p = node(d, NodeType.POOL); p.setCount(5); + let dr, out; + const mkDrain = () => { dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull'; conn(d, p, dr).rate = 5; }; + const mkSource = () => { + const s = node(d, NodeType.SOURCE); + out = node(d, NodeType.POOL); + conn(d, s, out).rate = 1; + const a = conn(d, p, s, ConnectionType.STATE); + a.activator = true; a.actOperator = '>='; a.actValue = 5; + }; + if (drainFirst) { mkDrain(); mkSource(); } else { mkSource(); mkDrain(); } + const e = new SimEngine(d); + e.reset(); + const trace = []; + for (let i = 0; i < 3; i++) { e.doStep(); trace.push([p.resources, out.resources, dr.drained].join('/')); } + SimRandom.seed(null); + return trace.join(' '); + }; + const first = build(true), second = build(false); + eq(first, second, 'both insertion orders produce the same trace'); + // Tick-start semantics: on step 1 P started at 5, so the source DOES fire + // even though the drain empties P within that same tick. + eq(first, '0/1/5 0/1/5 0/1/5', 'source fired exactly on the step P started at 5'); +}); + +test('trace is invariant under node-array permutation (property test)', () => { + // A nontrivial seeded diagram (source, pools, conditional gate feed, pull + // drain, register, activator, trigger). Rebuilding it from the same JSON + // with the nodes array permuted must yield a byte-identical trace. + const d = new Diagram(); + d.seed = 'perm'; + const s = node(d, NodeType.SOURCE); + const a = node(d, NodeType.POOL); + conn(d, s, a).rate = 2; + const g = node(d, NodeType.GATE); + const toGate = conn(d, a, g); toGate.rate = 3; + toGate.condEnabled = true; toGate.condOperator = '>='; toGate.condValue = 3; + const b = node(d, NodeType.POOL); + const c = node(d, NodeType.POOL); + conn(d, g, b).weight = 2; + conn(d, g, c).weight = 1; + const dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull'; + conn(d, b, dr).rate = 2; + const r = node(d, NodeType.REGISTER); r.formula = 'cv * 2'; + conn(d, c, r, ConnectionType.STATE).variableName = 'cv'; + const s2 = node(d, NodeType.SOURCE); + const e2 = node(d, NodeType.POOL); + conn(d, s2, e2).rate = 1; + const act = conn(d, b, s2, ConnectionType.STATE); + act.activator = true; act.actOperator = '>='; act.actValue = 2; + const f = node(d, NodeType.POOL); f.setCount(20); f.activation = ActivationMode.PASSIVE; + conn(d, f, c).rate = 1; + conn(d, s2, f, ConnectionType.STATE).trigger = true; + + const json = d.toJSON(); + const runWith = (permute) => { + const j = JSON.parse(JSON.stringify(json)); + j.nodes = permute(j.nodes); + const dg = new Diagram(); dg.loadJSON(j); + const eng = new SimEngine(dg); + eng.reset(); + const trace = []; + for (let i = 0; i < 25; i++) { + eng.doStep(); + trace.push([...dg.nodes.values()].sort((x, y) => x.id.localeCompare(y.id)) + .map(n => `${n.id}=${n.chartValue}`).join(',')); + } + SimRandom.seed(null); + return trace.join('\n'); + }; + // Deterministic permutations only (no Math.random in tests). + const identity = runWith(ns => ns); + const reversed = runWith(ns => ns.slice().reverse()); + const rotated = runWith(ns => ns.slice(3).concat(ns.slice(0, 3))); + eq(reversed, identity, 'reversed node order gives an identical trace'); + eq(rotated, identity, 'rotated node order gives an identical trace'); +}); + +test('trigger cascade reads live mid-step state (causal exception)', () => { + // The drain empties P and triggers T within the same tick. T's activator + // "P == 0" must see the live, post-pull value (a cascade is a causal + // reaction inside the step), while an identical automatic node stored + // after the drain checks the tick-start snapshot and stays put. + const d = new Diagram(); + d.seed = 'sync-c'; + const p = node(d, NodeType.POOL); p.setCount(5); + const dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull'; + conn(d, p, dr).rate = 5; + const t = node(d, NodeType.SOURCE); t.activation = ActivationMode.PASSIVE; + const out1 = node(d, NodeType.POOL); + conn(d, t, out1).rate = 1; + const at = conn(d, p, t, ConnectionType.STATE); + at.activator = true; at.actOperator = '=='; at.actValue = 0; + conn(d, dr, t, ConnectionType.STATE).trigger = true; + const t2 = node(d, NodeType.SOURCE); + const out2 = node(d, NodeType.POOL); + conn(d, t2, out2).rate = 1; + const at2 = conn(d, p, t2, ConnectionType.STATE); + at2.activator = true; at2.actOperator = '=='; at2.actValue = 0; + const e = new SimEngine(d); + e.reset(); + e.doStep(); + SimRandom.seed(null); + eq(out1.resources, 1, 'cascade-fired node saw live P == 0 and fired'); + eq(out2.resources, 0, 'automatic node saw tick-start P = 5 and did not fire'); +}); + // ── Async engine API (Monte Carlo runner) ──────────────────────────────────── testAsync('runMonteCarloAsync honours shouldCancel — resolves null, no results', async () => {