From aa5105d1c7cc330e5110c5d79dadf5357955bb06 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 15:23:22 -0600 Subject: [PATCH] feat(rete): add append-only event log with monotonic sequence (P3.1) --- eslint.config.js | 9 ++ packages/rete/src/eventlog.test.ts | 190 +++++++++++++++++++++++++++++ packages/rete/src/eventlog.ts | 117 ++++++++++++++++++ packages/rete/src/index.ts | 6 + packages/rete/src/session.ts | 49 +++++++- 5 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 packages/rete/src/eventlog.test.ts create mode 100644 packages/rete/src/eventlog.ts diff --git a/eslint.config.js b/eslint.config.js index 787880f..30bd4e4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,5 +34,14 @@ export default tseslint.config( }, ], }, + }, + // EventLog is a transcript, not rule logic — wall-clock timestamps + // are intentional here (see SPEC.md §Rete II Reference Target and + // eventlog.ts header). Carve it out from the RHS-purity Date ban. + { + files: ["packages/rete/src/eventlog.ts"], + rules: { + "no-restricted-globals": "off", + }, } ); diff --git a/packages/rete/src/eventlog.test.ts b/packages/rete/src/eventlog.test.ts new file mode 100644 index 0000000..1ed460c --- /dev/null +++ b/packages/rete/src/eventlog.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { EventLog } from "./eventlog.js"; +import { Session } from "./session.js"; +import type { EntityId } from "./schema.js"; + +const mkId = (n: number) => n as EntityId; + +describe("EventLog", () => { + it("starts with seq=0 and no events", () => { + const log = new EventLog(); + expect(log.length).toBe(0); + expect(log.latestSeq).toBe(0); + }); + + it("appendInsert increments seq monotonically", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "Health", 100); + log.appendInsert(mkId(2), "Name", "Alice"); + expect(log.latestSeq).toBe(2); + expect(log.length).toBe(2); + }); + + it("appendRetract records retract event", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "Health", 100); + log.appendRetract(mkId(1), "Health", 100); + const events = log.getAll(); + expect(events[0]?.kind).toBe("insert"); + expect(events[1]?.kind).toBe("retract"); + }); + + it("appendFire records fire event with ruleName", () => { + const log = new EventLog(); + log.appendFire("move-piece"); + const evt = log.getAll()[0]; + expect(evt?.kind).toBe("fire"); + expect(evt?.ruleName).toBe("move-piece"); + expect(evt?.seq).toBe(1); + }); + + it("getSince(seq) returns events after seq", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "X", 1); + log.appendInsert(mkId(2), "X", 2); + log.appendInsert(mkId(3), "X", 3); + const since = log.getSince(1); + expect(since).toHaveLength(2); + expect(since[0]?.seq).toBe(2); + expect(since[1]?.seq).toBe(3); + }); + + it("getSince(0) returns all events", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "X", 1); + log.appendInsert(mkId(2), "X", 2); + expect(log.getSince(0)).toHaveLength(2); + }); + + it("getSince(latestSeq) returns empty", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "X", 1); + log.appendInsert(mkId(2), "X", 2); + expect(log.getSince(log.latestSeq)).toHaveLength(0); + }); + + it("events are append-only (getAll returns a copy)", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "X", 42); + const first = log.getAll(); + // Mutating the returned array must not affect the log. + (first as unknown as unknown[]).length = 0; + expect(log.length).toBe(1); + expect(log.getAll()).toHaveLength(1); + }); + + it("records insert event with correct fields", () => { + const log = new EventLog(); + log.appendInsert(mkId(5), "Health", 99); + const evt = log.getAll()[0]; + expect(evt?.kind).toBe("insert"); + expect(evt?.id).toBe(5); + expect(evt?.attr).toBe("Health"); + expect(evt?.value).toBe(99); + expect(evt?.seq).toBe(1); + expect(typeof evt?.ts).toBe("number"); + }); + + it("records retract event with correct fields", () => { + const log = new EventLog(); + log.appendRetract(mkId(7), "Position", "a1"); + const evt = log.getAll()[0]; + expect(evt?.kind).toBe("retract"); + expect(evt?.id).toBe(7); + expect(evt?.attr).toBe("Position"); + expect(evt?.value).toBe("a1"); + expect(evt?.seq).toBe(1); + }); + + it("timestamps are non-decreasing across appends", () => { + const log = new EventLog(); + log.appendInsert(mkId(1), "A", 1); + log.appendInsert(mkId(1), "B", 2); + log.appendInsert(mkId(1), "C", 3); + const events = log.getAll(); + expect(events[0]!.ts).toBeLessThanOrEqual(events[1]!.ts); + expect(events[1]!.ts).toBeLessThanOrEqual(events[2]!.ts); + }); + + it("seqs are strictly monotonically increasing (1, 2, 3, ...)", () => { + const log = new EventLog(); + for (let i = 0; i < 10; i++) log.appendInsert(mkId(i + 1), "N", i); + const events = log.getAll(); + for (let i = 0; i < events.length; i++) { + expect(events[i]!.seq).toBe(i + 1); + } + }); +}); + +describe("EventLog + Session integration", () => { + it("records insert via Session.insert when EventLog is attached", () => { + const log = new EventLog(); + const sess = new Session({ eventLog: log, autoFire: false }); + const id = sess.nextId(); + sess.insert(id, "Health", 100); + const events = log.getAll(); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe("insert"); + expect(events[0]?.id).toBe(id); + expect(events[0]?.attr).toBe("Health"); + expect(events[0]?.value).toBe(100); + }); + + it("records retract via Session.retract with old value", () => { + const log = new EventLog(); + const sess = new Session({ eventLog: log, autoFire: false }); + const id = sess.nextId(); + sess.insert(id, "Health", 100); + sess.retract(id, "Health"); + const events = log.getAll(); + expect(events).toHaveLength(2); + expect(events[1]?.kind).toBe("retract"); + expect(events[1]?.id).toBe(id); + expect(events[1]?.attr).toBe("Health"); + expect(events[1]?.value).toBe(100); + }); + + it("records an update as retract-of-old then insert-of-new", () => { + const log = new EventLog(); + const sess = new Session({ eventLog: log, autoFire: false }); + const id = sess.nextId(); + sess.insert(id, "Health", 100); + sess.insert(id, "Health", 50); + const events = log.getAll(); + // 1: insert 100, 2: retract 100 (update), 3: insert 50 + expect(events).toHaveLength(3); + expect(events[0]?.kind).toBe("insert"); + expect(events[0]?.value).toBe(100); + expect(events[1]?.kind).toBe("retract"); + expect(events[1]?.value).toBe(100); + expect(events[2]?.kind).toBe("insert"); + expect(events[2]?.value).toBe(50); + }); + + it("does not record when retracting a non-existent fact", () => { + const log = new EventLog(); + const sess = new Session({ eventLog: log, autoFire: false }); + const id = sess.nextId(); + sess.retract(id, "Health"); + expect(log.length).toBe(0); + }); + + it("excludes derived facts (negative ids) from the event log", () => { + const log = new EventLog(); + const sess = new Session({ eventLog: log, autoFire: false }); + const wm = sess._getWM(); + // Simulate a derived-fact insert via direct WM (bypasses Session). + // Also cover the defensive branch by calling Session.insert with a + // negative id directly. + wm.insert(-1 as EntityId, "Derived", 1); + sess.insert(-2 as EntityId, "Derived", 2); + expect(log.length).toBe(0); + }); + + it("Session without eventLog still works normally", () => { + const sess = new Session({ autoFire: false }); + const id = sess.nextId(); + sess.insert(id, "Health", 100); + expect(sess.get(id, "Health")).toBe(100); + }); +}); diff --git a/packages/rete/src/eventlog.ts b/packages/rete/src/eventlog.ts new file mode 100644 index 0000000..21c1054 --- /dev/null +++ b/packages/rete/src/eventlog.ts @@ -0,0 +1,117 @@ +/** + * EventLog — append-only event log for Session mutations. + * + * Per SPEC.md §Rete II Reference Target, the engine must support + * replay-determinism: given the same schema, rules, and event log, + * replaying events reproduces identical working-memory and firing + * sequences. The EventLog is the authoritative transcript consumed by + * the time-travel and replay systems (Phase 3). + * + * Design invariants: + * - Append-only. Events are never rewritten or deleted. + * - Sequence numbers are monotonically increasing, starting at 1. + * - Derived facts (negative EntityIds) are NOT logged — see SPEC.md + * §Truth Maintenance: derived facts are re-derived on replay. + * This exclusion is enforced at the Session integration boundary + * (see session.ts), not inside the EventLog itself. + */ +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +/** Kind of a logged event. */ +export type EventKind = "insert" | "retract" | "fire"; + +/** + * A single entry in the {@link EventLog}. + * + * Fields are `readonly` to reinforce the append-only contract; outside + * callers obtain events through {@link EventLog.getAll} / + * {@link EventLog.getSince}, which return a shallow copy. + */ +export interface LogEvent { + /** Monotonically increasing sequence number, starting at 1. */ + readonly seq: number; + /** Wall-clock timestamp (ms since epoch). Captured at append time. */ + readonly ts: number; + /** Event kind. */ + readonly kind: EventKind; + /** Entity id — present for "insert" and "retract". */ + readonly id?: EntityId; + /** Attribute name — present for "insert" and "retract". */ + readonly attr?: AttrKey; + /** Fact value — present for "insert" and "retract". */ + readonly value?: FactValue; + /** Rule name — present for "fire". */ + readonly ruleName?: string; +} + +export class EventLog { + readonly #events: LogEvent[] = []; + #seq = 0; + + /** Number of events logged. */ + get length(): number { + return this.#events.length; + } + + /** + * Highest sequence number currently in the log (0 if empty). + * + * The next appended event will carry `latestSeq + 1`. + */ + get latestSeq(): number { + return this.#seq; + } + + /** Record an insert of `(id, attr, value)`. */ + appendInsert(id: EntityId, attr: AttrKey, value: FactValue): void { + this.#events.push({ + seq: ++this.#seq, + ts: Date.now(), + kind: "insert", + id, + attr, + value, + }); + } + + /** + * Record a retract of `(id, attr)` with its previous `value` captured + * at the call site (so replay can reconstruct the missing fact). + */ + appendRetract(id: EntityId, attr: AttrKey, value: FactValue): void { + this.#events.push({ + seq: ++this.#seq, + ts: Date.now(), + kind: "retract", + id, + attr, + value, + }); + } + + /** Record a rule firing by rule name. */ + appendFire(ruleName: string): void { + this.#events.push({ + seq: ++this.#seq, + ts: Date.now(), + kind: "fire", + ruleName, + }); + } + + /** + * Return all events whose `seq` is strictly greater than `afterSeq`. + * + * Pass `0` to retrieve the full log. Callers typically store the + * last-seen seq and poll with `getSince(lastSeen)` to catch up. + */ + getSince(afterSeq: number): readonly LogEvent[] { + return this.#events.filter((e) => e.seq > afterSeq); + } + + /** Return a shallow copy of all events. */ + getAll(): readonly LogEvent[] { + return [...this.#events]; + } +} diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 813cfea..52161b8 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -37,6 +37,9 @@ export { FilterNode } from "./condition.js"; export type { SessionOptions } from "./session.js"; export { Session } from "./session.js"; +export type { EventKind, LogEvent } from "./eventlog.js"; +export { EventLog } from "./eventlog.js"; + export { RecursionLimitExceededError } from "./cycle.js"; export type { SerializedRule } from "./serialize.js"; @@ -53,3 +56,6 @@ export { orderActivations } from "./conflict.js"; export type { AggregatorKind } from "./aggregate.js"; export { AggregationNode } from "./aggregate.js"; + +export type { Snapshot, ImmutableFacts, MutableFacts, FactRecord } from "./snapshot.js"; +export { SnapshotManager } from "./snapshot.js"; diff --git a/packages/rete/src/session.ts b/packages/rete/src/session.ts index 2cb342c..e2c70f9 100644 --- a/packages/rete/src/session.ts +++ b/packages/rete/src/session.ts @@ -9,6 +9,7 @@ import type { EntityId } from "./schema.js"; import type { RuleDefinition } from "./builder.js"; import type { OrderableRule } from "./conflict.js"; import { RecursionLimitExceededError } from "./cycle.js"; +import type { EventLog } from "./eventlog.js"; /** * Internal record pairing a registered {@link RuleDefinition} with its @@ -25,13 +26,22 @@ export interface SessionOptions { autoFire?: boolean; /** Maximum recursion depth for fireRules(). Default: 64. */ recursionLimit?: number; + /** + * Optional append-only {@link EventLog}. When provided, user-initiated + * {@link Session.insert} and {@link Session.retract} calls append a + * corresponding event. Derived facts (negative {@link EntityId}s) are + * excluded — per SPEC.md §Truth Maintenance they are re-derived on + * replay rather than recorded directly. + */ + eventLog?: EventLog; } export class Session { readonly #wm: WorkingMemory; readonly #alpha: AlphaNetwork; readonly #rules: RegisteredRule[] = []; - readonly #opts: Required; + readonly #opts: Required>; + readonly #eventLog: EventLog | undefined; #idCounter = 0; /** @@ -46,6 +56,7 @@ export class Session { autoFire: opts.autoFire ?? true, recursionLimit: opts.recursionLimit ?? 64, }; + this.#eventLog = opts.eventLog; this.#wm = new WorkingMemory(); this.#alpha = new AlphaNetwork(); @@ -63,15 +74,47 @@ export class Session { return ++this.#idCounter as EntityId; } - /** Insert (or update) a fact. If autoFire, triggers fireRules(). */ + /** + * Insert (or update) a fact. If autoFire, triggers fireRules(). + * + * When an {@link EventLog} is attached, records the mutation — + * updates are recorded as a retract-of-old-value followed by an + * insert-of-new-value, mirroring the WM's internal update semantics + * (see wm.ts). Derived facts (negative ids) are never logged. + */ insert(id: EntityId, attr: AttrKey, value: FactValue): void { + const log = this.#eventLog; + const shouldLog = log !== undefined && (id as number) >= 0; + if (shouldLog) { + const oldValue = this.#wm.get(id, attr); + if (oldValue !== undefined || this.#wm.contains(id, attr)) { + log.appendRetract(id, attr, oldValue); + } + } this.#wm.insert(id, attr, value); + if (shouldLog) log.appendInsert(id, attr, value); if (this.#opts.autoFire) this.fireRules(); } - /** Retract a fact. If autoFire, triggers fireRules(). */ + /** + * Retract a fact. If autoFire, triggers fireRules(). + * + * When an {@link EventLog} is attached, the previous value is read + * from WM before the retraction and recorded on the event so replay + * can reconstruct the missing fact. Retracting a non-existent fact + * is a no-op and is not logged. + */ retract(id: EntityId, attr: AttrKey): void { + const log = this.#eventLog; + const shouldLog = log !== undefined && (id as number) >= 0; + let oldValue: FactValue | undefined; + let existed = false; + if (shouldLog) { + existed = this.#wm.contains(id, attr); + if (existed) oldValue = this.#wm.get(id, attr); + } this.#wm.retract(id, attr); + if (shouldLog && existed) log.appendRetract(id, attr, oldValue); if (this.#opts.autoFire) this.fireRules(); }