diff --git a/js/engine.js b/js/engine.js index 5bc6621..c38f103 100644 --- a/js/engine.js +++ b/js/engine.js @@ -37,7 +37,15 @@ class SimEngine { const infiniteSource = n.type === NodeType.SOURCE && !n.limited; n.resources = n._initialResources ?? (infiniteSource ? Infinity : 0); n.colorMap = { ...(n._initialColorMap || {}) }; - if (n.type === NodeType.DELAY) n._queue = []; + if (n.type === NodeType.DELAY) { + // Rebuild in-flight batches from any pre-loaded resources (e.g. a + // diagram saved mid-run keeps its counts but not its _queue): one + // batch per color holding the full amount, releasing after the node's + // delay — mirrors the QUEUE _fifo rebuild below. Without this the + // resources would sit in the node forever, never released. + n._queue = Object.entries(n.colorMap).filter(([, a]) => a > 0) + .map(([color, amount]) => ({ amount, color, stepsLeft: n.delay })); + } if (n.type === NodeType.QUEUE) { n._procs = []; n.processed = 0; n.totalWait = 0; n.maxWait = 0; n.maxLen = 0; @@ -87,6 +95,9 @@ class SimEngine { histStride: this._histStride, ended: this.ended, history: this.history, + // Seeded RNG position (null when unseeded), so a restored seeded run + // replays the exact same stochastic draws it originally would have. + rng: SimRandom.getState(), vars: this.diagram.variables, trigCounts: [...(this._trigCounts || new Map())], prevStateVals: [...(this._prevStateVals || new Map())], @@ -112,6 +123,7 @@ class SimEngine { this.ended = s.ended; this._trigCounts = new Map(s.trigCounts); this._prevStateVals = new Map(s.prevStateVals); + SimRandom.setState(s.rng ?? null); } doStep() { @@ -663,7 +675,10 @@ class SimEngine { // Max-min fair integer allocation of `available` across `wants`. _fairAllocate(available, wants) { const alloc = wants.map(() => 0); - let remaining = available; + // The engine moves whole units; a fractional `available` (e.g. a capacity + // of 5.5) must round down or the remainder loop would hand out an extra + // unit and overfill the target. + let remaining = Math.floor(available); let active = wants.map((w, i) => i).filter(i => wants[i] > 0); while (remaining > 0 && active.length) { @@ -690,11 +705,14 @@ class SimEngine { // Split `total` into integer shares proportional to `weights` (remainder // distributed round-robin). Zero total weight falls back to an even split. _proportionalShares(total, weights) { - const n = weights.length; const wSum = weights.reduce((a, b) => a + b, 0); const shares = weights.map(w => wSum > 0 ? Math.floor(total * w / wSum) : 0); let rem = total - shares.reduce((a, b) => a + b, 0); - for (let i = 0; rem > 0 && n; i++, rem--) shares[i % n]++; + // Remainder goes round-robin over the weighted outputs only — a zero + // weight routes nothing. With no positive weight at all, fall back to an + // even split across every output. + const idx = weights.map((_, i) => i).filter(i => wSum <= 0 || weights[i] > 0); + for (let k = 0; rem > 0 && idx.length; k++, rem--) shares[idx[k % idx.length]]++; return shares; } @@ -1263,8 +1281,12 @@ class SimEngine { } this.history.push({ step: this.step, snap }); if (this.history.length >= 600) { - this.history = this.history.filter((_, i) => i % 2 === 0); + // Halve the sampling rate, dropping the snapshots that fall off the new + // grid. Filter by step (not array index): an index-based cut would + // strand the retained steps at the wrong phase for the doubled stride, + // skipping the samples right after each doubling and leaving a gap. this._histStride *= 2; + this.history = this.history.filter(h => h.step % this._histStride === 0); } } diff --git a/js/model.js b/js/model.js index d459e78..8064a42 100644 --- a/js/model.js +++ b/js/model.js @@ -37,6 +37,7 @@ const RateMode = { // stub Math.random working). const SimRandom = { _fn: null, // null → delegate to Math.random + _a: 0, // mulberry32 state (32-bit), advanced on every seeded draw random() { return this._fn ? this._fn() : Math.random(); }, // Seed with any string/number; null/'' clears back to Math.random. seed(s) { @@ -47,8 +48,19 @@ const SimRandom = { const str = String(s); for (let i = 0; i < str.length; i++) a = ((a * 31) + str.charCodeAt(i)) >>> 0; if (a === 0) a = 0x9e3779b9; + this.setState(a); + }, + // Checkpoint support: the internal 32-bit state, so captureState / + // restoreState can put a seeded stream back at its exact position. + // null ↔ unseeded (Math.random has no position to save). + getState() { return this._fn ? this._a : null; }, + setState(state) { + if (state == null) { this._fn = null; return; } + this._a = state >>> 0; + const self = this; this._fn = function () { - a |= 0; a = (a + 0x6d2b79f5) | 0; + let a = self._a | 0; a = (a + 0x6d2b79f5) | 0; + self._a = a; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; @@ -87,6 +99,37 @@ const VALID_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; // so existing diagrams keep working unchanged. const _mathCompileCache = new Map(); // expr → compiled math.js code (or null if unparseable) +// Formula randomness routed through SimRandom: these shadow math.js's own +// random()/randomInt()/pickRandom() (which are Math.random-backed) via the +// evaluation scope, so seeded runs stay reproducible when a formula rolls +// randomness. Injected per evaluation — never baked into compiled code — +// because _mathCompileCache is shared across Monte Carlo trials. +function _formulaRandomScope() { + return { + // random() → [0,1); random(max) → [0,max); random(min,max) → [min,max) + random: (min, max) => { + if (min === undefined) return SimRandom.random(); + if (max === undefined) { max = min; min = 0; } + return min + SimRandom.random() * (max - min); + }, + // randomInt(max) → [0,max); randomInt(min,max) → [min,max); integers + randomInt: (min, max) => { + if (max === undefined) { max = min; min = 0; } + return Math.floor(min + SimRandom.random() * (max - min)); + }, + // pickRandom([…]) → one element (math.js matrices unwrap to arrays) + pickRandom: (arr) => { + const a = arr && typeof arr.toArray === 'function' ? arr.toArray() : arr; + if (!Array.isArray(a) || !a.length) return 0; + return a[Math.floor(SimRandom.random() * a.length)]; + }, + }; +} + +// `Math` stand-in whose random() draws from SimRandom (everything else is +// inherited); shadows the global in the legacy Function-based evaluator. +const _seededMath = Object.create(Math, { random: { value: () => SimRandom.random() } }); + function _evalMathJS(expr, vars) { if (typeof math === 'undefined' || !math.compile) return undefined; let code = _mathCompileCache.get(expr); @@ -100,6 +143,8 @@ function _evalMathJS(expr, vars) { for (const [k, v] of Object.entries(vars || {})) { if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v)) scope[k] = v; } + // Seeded randomness (scope functions take precedence over math.js built-ins). + Object.assign(scope, _formulaRandomScope()); try { let r = code.evaluate(scope); if (r && typeof r === 'object' && typeof r.toNumber === 'function') r = r.toNumber(); @@ -113,13 +158,19 @@ function evalFormula(expr, vars = {}) { if (!expr || typeof expr !== 'string' || !expr.trim()) return 0; const viaMath = _evalMathJS(expr.trim(), vars); if (viaMath !== undefined) return viaMath; - // Legacy fallback: plain JS expression over the same variables. + // Legacy fallback: plain JS expression over the same variables. The seeded + // random helpers are passed in too, and `Math` is shadowed so Math.random() + // also draws from SimRandom (seeded runs stay reproducible on this path). + const rng = _formulaRandomScope(); const keys = [], vals = []; for (const [k, v] of Object.entries(vars || {})) { - if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v)) { + if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v) + && !(k in rng) && k !== 'Math') { keys.push(k); vals.push(v); } } + for (const [k, fn] of Object.entries(rng)) { keys.push(k); vals.push(fn); } + keys.push('Math'); vals.push(_seededMath); try { // eslint-disable-next-line no-new-func const fn = new Function(...keys, `"use strict"; return (${expr.trim()});`); @@ -183,7 +234,9 @@ function rollDice(expr) { const s = String(expr).trim().toLowerCase(); const m = s.match(/^(\d+)\s*d\s*(\d+)$/); if (!m) { const n = parseFloat(s); return isNaN(n) ? 0 : n; } - const count = parseInt(m[1]), sides = parseInt(m[2]); + // Cap the dice count (like sampleDist's poisson loop) so a pathological + // expression such as 999999999d6 can't freeze the tick. + const count = Math.min(10000, parseInt(m[1])), sides = parseInt(m[2]); if (sides < 1) return 0; let sum = 0; for (let i = 0; i < count; i++) sum += Math.floor(SimRandom.random() * sides) + 1; diff --git a/test/run.js b/test/run.js index 663a752..ded94e1 100644 --- a/test/run.js +++ b/test/run.js @@ -217,6 +217,15 @@ test('pool allocation is work-conserving when one target is full', () => { eq(a.resources, 1, 'A stays full'); }); +test('a fractional capacity is floored, never overfilled', () => { + const { d, e } = setup(); + const s = node(d, NodeType.SOURCE); + const p = node(d, NodeType.POOL); p.capacity = 5.5; + conn(d, s, p).rate = 10; + steps(e, 3); + eq(p.resources, 5, 'pool fills to floor(capacity) only'); +}); + // ── Multi-ingredient recipe converter ──────────────────────────────────────── console.log('\nConverter recipes'); @@ -538,6 +547,21 @@ test('deterministic gate splits proportionally to weights', () => { eq(g.resources, 0, 'gate emptied'); }); +test('deterministic gate remainder never lands on a zero-weight output', () => { + const { d, e } = setup(); + const g = node(d, NodeType.GATE); g.setCount(4); g.gateMode = 'deterministic'; + const p0 = node(d, NodeType.POOL); + const p1 = node(d, NodeType.POOL); + const p2 = node(d, NodeType.POOL); + conn(d, g, p0).weight = 0; + conn(d, g, p1).weight = 2; + conn(d, g, p2).weight = 3; + steps(e, 1); + eq(p0.resources, 0, 'zero-weight output routes nothing'); + eq(p1.resources + p2.resources, 4, 'all units routed to the weighted outputs'); + eq(g.resources, 0, 'gate emptied'); +}); + test('probabilistic gate never routes to a zero-weight output', () => { withRandom(0.5, () => { const { d, e } = setup(); @@ -667,6 +691,28 @@ test('toJSON/loadJSON preserves new fields', () => { eq(av.actValue, 9, 'actValue preserved'); }); +test('delay in-flight resources survive a JSON round-trip and still release', () => { + const { d, e } = setup(); + const a = node(d, NodeType.POOL); a.setCount(2); + const dl = node(d, NodeType.DELAY); dl.delay = 3; + const b = node(d, NodeType.POOL); + conn(d, a, dl).rate = 2; + conn(d, dl, b).rate = 2; + e.reset(); + e.doStep(); // the 2 units are now in flight inside the delay + eq(d.nodes.get(dl.id).resources, 2, 'delay holds the units'); + + // Saving keeps the node's counts but not its _queue; reset() must rebuild + // the in-flight batch or the units would sit in the node forever. + const d2 = new Diagram(); + d2.loadJSON(JSON.parse(JSON.stringify(d.toJSON()))); + const e2 = new SimEngine(d2); + e2.reset(); + for (let i = 0; i < 10; i++) e2.doStep(); + eq(d2.nodes.get(b.id).resources, 2, 'units arrive downstream after the round-trip'); + eq(d2.nodes.get(dl.id).resources, 0, 'delay drained'); +}); + // ── P1: finite source / queue / modifiers ────────────────────────────────── console.log('\nFinite source'); @@ -1449,6 +1495,48 @@ test('different seeds produce different traces', () => { assert(seededTrace('abc') !== seededTrace('xyz'), 'distinct seeds → distinct traces'); }); +// Formula randomness must draw from SimRandom too — math.js's own random +// functions are Math.random-backed and would break seeded reproducibility. +function formulaTrace(seed, n = 10) { + const { d, e } = setup(); + d.seed = seed; + const s = node(d, NodeType.SOURCE); + const p = node(d, NodeType.POOL); + const c = conn(d, s, p); c.rateMode = RateMode.FORMULA; c.formula = 'randomInt(1,100)'; + e.reset(); + const trace = []; + for (let i = 0; i < n; i++) { e.doStep(); trace.push(p.resources); } + SimRandom.seed(null); + return trace.join(','); +} + +test('a randomInt() formula rate is reproducible under the run seed', () => { + eq(formulaTrace('abc'), formulaTrace('abc'), 'same seed → identical trace'); + assert(formulaTrace('abc') !== formulaTrace('xyz'), 'distinct seeds → distinct traces'); +}); + +test('random()/pickRandom() in formulas draw from the seeded stream too', () => { + SimRandom.seed('f2'); + const a = [evalFormula('random()'), evalFormula('random(5,10)'), evalFormula('pickRandom([10,20,30])')]; + SimRandom.seed('f2'); + const b = [evalFormula('random()'), evalFormula('random(5,10)'), evalFormula('pickRandom([10,20,30])')]; + SimRandom.seed(null); + eq(a.join(','), b.join(','), 'same seed → identical formula draws'); + assert(a[1] >= 5 && a[1] < 10, 'random(min,max) stays in range'); + assert([10, 20, 30].includes(a[2]), 'pickRandom returns a list element'); +}); + +test('legacy-fallback formulas (Math.random) are seeded as well', () => { + // math.js cannot evaluate `Math.*`, so this takes the Function fallback, + // where the shadowed Math draws from SimRandom. + SimRandom.seed('legacy'); + const a = [evalFormula('Math.round(Math.random()*1000)'), evalFormula('Math.round(Math.random()*1000)')]; + SimRandom.seed('legacy'); + const b = [evalFormula('Math.round(Math.random()*1000)'), evalFormula('Math.round(Math.random()*1000)')]; + SimRandom.seed(null); + eq(a.join(','), b.join(','), 'same seed → identical legacy draws'); +}); + test('an empty seed leaves the RNG on Math.random', () => { // With the seed cleared, reset() must restore Math.random so stubs still work. SimRandom.seed('leftover'); // simulate a seed left by a prior batch @@ -1894,6 +1982,13 @@ test('a NaN connection rate moves nothing and never corrupts node state', () => eq(p.resources, 0, 'no resources moved by a NaN rate'); }); +test('a huge dice count is capped and returns promptly', () => { + const t0 = Date.now(); + const v = rollDice('999999999d6'); + assert(Date.now() - t0 < 1000, 'rollDice returns promptly'); + assert(v >= 10000 && v <= 60000, `sum bounded by the 10000-dice cap (got ${v})`); +}); + // ── Sweep fixes: delay honours a finite capacity ────────────────────────────── console.log('\nDelay capacity'); @@ -2427,6 +2522,20 @@ test('history uses adaptive stride: long runs keep full-range coverage, bounded for (let i = 1; i < steps.length; i++) assert(steps[i] > steps[i - 1], 'steps strictly increasing'); }); +test('history stays uniformly spaced across decimations (no post-doubling gaps)', () => { + const { d, e } = setup(); + const s = node(d, NodeType.SOURCE); + const p = node(d, NodeType.POOL); + conn(d, s, p).rate = 1; + e.reset(); + for (let i = 0; i < 1300; i++) e.doStep(); + const st = e.history.map(h => h.step); + // After each doubling the retained snapshots must stay on the new stride's + // grid, so recording continues seamlessly — every gap equals the stride. + for (let i = 1; i < st.length; i++) + eq(st[i] - st[i - 1], e._histStride, `uniform spacing at #${i} (${st[i - 1]}→${st[i]})`); +}); + test('diagram JSON carries a schema version and loads without one (legacy)', () => { const { d } = setup(); eq(d.toJSON().version, 1, 'version written'); @@ -2501,6 +2610,25 @@ test('captureState carries in-flight delay queue contents', () => { assert(d.nodes.get(p.id).resources > 0, 'delayed batches still release after restore'); }); +test('captureState/restoreState replays a seeded run identically (RNG position)', () => { + const { d, e } = setup(); + d.seed = 'fork'; + const s = node(d, NodeType.SOURCE); + const p = node(d, NodeType.POOL); + const c = conn(d, s, p); + c.rateMode = RateMode.DICE; c.dice = '2d6'; + e.reset(); + for (let i = 0; i < 3; i++) e.doStep(); + const snap = e.captureState(); + const t1 = []; + for (let i = 0; i < 2; i++) { e.doStep(); t1.push(d.nodes.get(p.id).resources); } + e.restoreState(snap); + const t2 = []; + for (let i = 0; i < 2; i++) { e.doStep(); t2.push(d.nodes.get(p.id).resources); } + SimRandom.seed(null); + eq(t2.join(','), t1.join(','), 'post-restore trace matches the original branch'); +}); + test('restoreState restores structure removed after the checkpoint', () => { const { d, e } = setup(); const s = node(d, NodeType.SOURCE);