feat(rete): add append-only event log with monotonic sequence (P3.1)
This commit is contained in:
parent
6baab9f3fd
commit
aa5105d1c7
5 changed files with 368 additions and 3 deletions
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
190
packages/rete/src/eventlog.test.ts
Normal file
190
packages/rete/src/eventlog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
117
packages/rete/src/eventlog.ts
Normal file
117
packages/rete/src/eventlog.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue