feat(rete): add append-only event log with monotonic sequence (P3.1)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 15:23:22 -06:00
commit aa5105d1c7
No known key found for this signature in database
5 changed files with 368 additions and 3 deletions

View file

@ -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",
},
}
);

View file

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

View file

@ -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];
}
}

View file

@ -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";

View file

@ -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<SessionOptions>;
readonly #opts: Required<Pick<SessionOptions, "autoFire" | "recursionLimit">>;
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();
}