Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions js/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())],
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
}

Expand Down
61 changes: 57 additions & 4 deletions js/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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()});`);
Expand Down Expand Up @@ -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;
Expand Down
128 changes: 128 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
Loading