From 01348aa36a651c147e8f15185050ec792c982b03 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 14:02:05 +0000 Subject: [PATCH] feat: binary-heap event queue with stable tie-breaking --- src/sim/events.js | 54 ++++++++++++++++++++++++++++++++++++++++++++ tests/events.test.js | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/sim/events.js create mode 100644 tests/events.test.js diff --git a/src/sim/events.js b/src/sim/events.js new file mode 100644 index 0000000..8e34eaf --- /dev/null +++ b/src/sim/events.js @@ -0,0 +1,54 @@ +// Binary min-heap keyed by (time, insertion sequence). +export class EventQueue { + constructor() { + this.heap = []; + this.seq = 0; + } + + get size() { + return this.heap.length; + } + + push(time, type, data) { + const e = { time, seq: this.seq++, type, data }; + const h = this.heap; + h.push(e); + let i = h.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (!before(h[i], h[p])) break; + [h[i], h[p]] = [h[p], h[i]]; + i = p; + } + return e; + } + + peek() { + return this.heap[0] ?? null; + } + + pop() { + const h = this.heap; + if (h.length === 0) return null; + const top = h[0]; + const last = h.pop(); + if (h.length > 0) { + h[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1, r = 2 * i + 2; + let m = i; + if (l < h.length && before(h[l], h[m])) m = l; + if (r < h.length && before(h[r], h[m])) m = r; + if (m === i) break; + [h[i], h[m]] = [h[m], h[i]]; + i = m; + } + } + return top; + } +} + +function before(a, b) { + return a.time < b.time || (a.time === b.time && a.seq < b.seq); +} diff --git a/tests/events.test.js b/tests/events.test.js new file mode 100644 index 0000000..60c3d46 --- /dev/null +++ b/tests/events.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { EventQueue } from '../src/sim/events.js'; + +describe('EventQueue', () => { + it('pops events in time order regardless of insertion order', () => { + const q = new EventQueue(); + q.push(5, 'c', {}); + q.push(1, 'a', {}); + q.push(3, 'b', {}); + expect(q.pop().type).toBe('a'); + expect(q.pop().type).toBe('b'); + expect(q.pop().type).toBe('c'); + expect(q.pop()).toBeNull(); + }); + + it('breaks time ties by insertion order', () => { + const q = new EventQueue(); + q.push(2, 'first', {}); + q.push(2, 'second', {}); + expect(q.pop().type).toBe('first'); + expect(q.pop().type).toBe('second'); + }); + + it('peek does not remove; size tracks contents', () => { + const q = new EventQueue(); + q.push(1, 'a', { n: 7 }); + expect(q.size).toBe(1); + expect(q.peek().time).toBe(1); + expect(q.size).toBe(1); + expect(q.pop().data.n).toBe(7); + expect(q.size).toBe(0); + expect(q.peek()).toBeNull(); + }); + + it('survives a large randomized load in order', () => { + const q = new EventQueue(); + const times = Array.from({ length: 500 }, (_, i) => (i * 7919) % 1000); + for (const t of times) q.push(t, 'e', {}); + let last = -1; + while (q.size > 0) { + const e = q.pop(); + expect(e.time).toBeGreaterThanOrEqual(last); + last = e.time; + } + }); +});