diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index a2db880..2c32497 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -30,3 +30,6 @@ export { Session } from "./session.js"; export type { SerializedRule } from "./serialize.js"; export { serialize, deserialize, RULE_SCHEMA_V1 } from "./serialize.js"; + +export type { Match } from "./query.js"; +export { ProductionNode, query, queryAll, NoMatchError } from "./query.js"; diff --git a/packages/rete/src/query.test.ts b/packages/rete/src/query.test.ts new file mode 100644 index 0000000..d3f500e --- /dev/null +++ b/packages/rete/src/query.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; +import { ProductionNode, query, queryAll, NoMatchError } from "./query.js"; +import { Token } from "./beta.js"; +import type { EntityId } from "./schema.js"; +import type { AttrKey, FactValue } from "./wm.js"; + +const mkId = (n: number) => n as EntityId; +const mkFact = (id: number, attr: string, value: unknown) => + ({ id: mkId(id), attr: attr as AttrKey, value: value as FactValue }); + +describe("ProductionNode", () => { + it("accumulates matches when tokens are activated", () => { + const prod = new ProductionNode("my-rule"); + const t1 = new Token(null, mkFact(1, "Health", 100), { hp: 100, eid: mkId(1) }); + const t2 = new Token(null, mkFact(2, "Health", 50), { hp: 50, eid: mkId(2) }); + + prod.leftActivate(t1); + prod.leftActivate(t2); + + expect(prod.matches).toHaveLength(2); + }); + + it("removes matches when tokens are deactivated", () => { + const prod = new ProductionNode("my-rule"); + const t = new Token(null, mkFact(1, "Health", 100), { hp: 100 }); + + prod.leftActivate(t); + expect(prod.matches).toHaveLength(1); + + prod.leftDeactivate(t); + expect(prod.matches).toHaveLength(0); + }); + + it("stores match bindings from token", () => { + const prod = new ProductionNode("rule-with-bindings"); + const bindings = { hp: 100, name: "Alice", eid: mkId(5) }; + const t = new Token(null, mkFact(5, "Health", 100), bindings); + prod.leftActivate(t); + + expect(prod.matches[0]?.bindings["hp"]).toBe(100); + expect(prod.matches[0]?.bindings["name"]).toBe("Alice"); + }); + + it("exposes the originating token on each match", () => { + const prod = new ProductionNode("rule"); + const t = new Token(null, mkFact(1, "X", 1), { x: 1 }); + prod.leftActivate(t); + expect(prod.matches[0]?.token).toBe(t); + }); + + it("deactivating an unknown token is a no-op (does not throw, does not remove others)", () => { + const prod = new ProductionNode("rule"); + const kept = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const stranger = new Token(null, mkFact(2, "X", 2), { x: 2 }); + prod.leftActivate(kept); + + expect(() => prod.leftDeactivate(stranger)).not.toThrow(); + expect(prod.matches).toHaveLength(1); + expect(prod.matches[0]?.token).toBe(kept); + }); + + it("retains ruleName for diagnostics", () => { + const prod = new ProductionNode("my-diagnostic-rule"); + expect(prod.ruleName).toBe("my-diagnostic-rule"); + }); +}); + +describe("query()", () => { + it("returns the first match bindings", () => { + const prod = new ProductionNode("rule"); + const t = new Token(null, mkFact(1, "X", 42), { x: 42 }); + prod.leftActivate(t); + + const result = query(prod); + expect(result["x"]).toBe(42); + }); + + it("throws NoMatchError when no matches exist", () => { + const prod = new ProductionNode("rule"); + expect(() => query(prod)).toThrow(NoMatchError); + }); + + it("throws NoMatchError with rule name in message", () => { + const prod = new ProductionNode("my-specific-rule"); + expect(() => query(prod)).toThrow("my-specific-rule"); + }); + + it("returns the earliest match when multiple are active", () => { + const prod = new ProductionNode("rule"); + const tFirst = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const tSecond = new Token(null, mkFact(2, "X", 2), { x: 2 }); + prod.leftActivate(tFirst); + prod.leftActivate(tSecond); + + expect(query(prod)["x"]).toBe(1); + }); + + it("after deactivating the first match, returns the next remaining one", () => { + const prod = new ProductionNode("rule"); + const t1 = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const t2 = new Token(null, mkFact(2, "X", 2), { x: 2 }); + prod.leftActivate(t1); + prod.leftActivate(t2); + prod.leftDeactivate(t1); + + expect(query(prod)["x"]).toBe(2); + }); +}); + +describe("queryAll()", () => { + it("returns empty array when no matches", () => { + const prod = new ProductionNode("rule"); + expect(queryAll(prod)).toEqual([]); + }); + + it("returns all matches", () => { + const prod = new ProductionNode("rule"); + const t1 = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const t2 = new Token(null, mkFact(2, "X", 2), { x: 2 }); + prod.leftActivate(t1); + prod.leftActivate(t2); + const results = queryAll(prod); + expect(results).toHaveLength(2); + }); + + it("returns matches in deterministic order (insertion order)", () => { + const prod = new ProductionNode("rule"); + const t1 = new Token(null, mkFact(1, "X", 10), { x: 10 }); + const t2 = new Token(null, mkFact(2, "X", 20), { x: 20 }); + const t3 = new Token(null, mkFact(3, "X", 30), { x: 30 }); + prod.leftActivate(t1); + prod.leftActivate(t2); + prod.leftActivate(t3); + const results = queryAll(prod); + expect(results[0]?.["x"]).toBe(10); + expect(results[1]?.["x"]).toBe(20); + expect(results[2]?.["x"]).toBe(30); + }); + + it("queryAll is stable after deactivation", () => { + const prod = new ProductionNode("rule"); + const t1 = new Token(null, mkFact(1, "X", 1), { x: 1 }); + const t2 = new Token(null, mkFact(2, "X", 2), { x: 2 }); + prod.leftActivate(t1); + prod.leftActivate(t2); + prod.leftDeactivate(t1); + const results = queryAll(prod); + expect(results).toHaveLength(1); + expect(results[0]?.["x"]).toBe(2); + }); + + it("does not re-sort by bindings — later-activated tokens do not move earlier even if their entity ids are smaller", () => { + const prod = new ProductionNode("rule"); + // Activate high-id token first, low-id second; insertion order wins. + const tHighIdFirst = new Token(null, mkFact(99, "X", 99), { x: 99 }); + const tLowIdSecond = new Token(null, mkFact(1, "X", 1), { x: 1 }); + prod.leftActivate(tHighIdFirst); + prod.leftActivate(tLowIdSecond); + + const results = queryAll(prod); + expect(results[0]?.["x"]).toBe(99); + expect(results[1]?.["x"]).toBe(1); + }); + + it("returns a snapshot that is safe to mutate without affecting the node", () => { + const prod = new ProductionNode("rule"); + const t = new Token(null, mkFact(1, "X", 1), { x: 1 }); + prod.leftActivate(t); + const snapshot = queryAll(prod); + snapshot.pop(); + expect(prod.matches).toHaveLength(1); + expect(queryAll(prod)).toHaveLength(1); + }); +}); + +describe("NoMatchError", () => { + it("has name 'NoMatchError'", () => { + const err = new NoMatchError("r"); + expect(err.name).toBe("NoMatchError"); + }); + + it("is an Error instance", () => { + const err = new NoMatchError("r"); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/packages/rete/src/query.ts b/packages/rete/src/query.ts new file mode 100644 index 0000000..957edbc --- /dev/null +++ b/packages/rete/src/query.ts @@ -0,0 +1,130 @@ +/** + * ProductionNode — terminal Rete node accumulating full matches, plus the + * `query()` / `queryAll()` point-in-time query API. + * + * Per `packages/rete/SPEC.md §Iteration Order`, `queryAll()` returns results + * in a deterministic order. Determinism is provided *upstream* by the beta + * network: internal beta-memory tokens are sorted by their constituent fact + * ids in the variable order defined by the rule's condition list (SPEC §81), + * so by the time tokens reach a `ProductionNode` via `leftActivate` they + * already arrive in canonical order. `ProductionNode` therefore stores them + * in insertion order and `queryAll()` returns that same order — re-sorting + * here would discard information (rule-declared variable order) that the + * beta network has already baked in. + * + * This module is intentionally limited to *point-in-time* queries. Reactive + * subscriptions (observer-style APIs) are out of scope for P1.10 per the + * phase plan; higher-level filtering (e.g. filter by binding) is layered on + * top in a later phase. + */ +import type { Bindings, Token } from "./beta.js"; + +/** + * Thrown by {@link query} when the target {@link ProductionNode} has no + * currently-active matches. The error carries the rule name so that callers + * — and, more importantly, test failures — can identify which production + * ran dry without needing to attach a stack frame of context. + */ +export class NoMatchError extends Error { + constructor(ruleName: string) { + super(`NoMatchError: no match found for rule "${ruleName}"`); + this.name = "NoMatchError"; + } +} + +/** + * A full match accumulated by a {@link ProductionNode}. + * + * `token` is the underlying beta-network token (useful for diagnostics and + * for future truth-maintenance wiring that needs to walk the match chain) + * and `bindings` is the flat variable-binding map that the public query API + * returns. Both fields are `readonly` at the type level to discourage + * external mutation, but the arrays returned by {@link queryAll} are fresh + * copies so callers can mutate them without disturbing the node. + */ +export interface Match { + readonly token: Token; + readonly bindings: Bindings; +} + +/** + * Terminal node in the Rete network. + * + * A `ProductionNode` subscribes (in a future wiring step) to the final + * beta-memory or filter node of a rule and accumulates the full matches + * produced by that rule. It exposes: + * + * - `matches` — the currently-active matches, in insertion order. The array + * reference itself is stable (`readonly` at the type level); its contents + * are mutated by {@link leftActivate} / {@link leftDeactivate}. + * - `ruleName` — carried for diagnostic messages (see {@link NoMatchError}). + * + * The `leftActivate` / `leftDeactivate` pair mirrors {@link BetaMemory}'s + * contract so a `ProductionNode` is a drop-in downstream subscriber for any + * node that fires left-activations. + */ +export class ProductionNode { + /** + * Accumulated matches in insertion order. + * + * Declared `readonly` so external code cannot swap the array out from + * underneath the node, but its contents are mutated internally — tests + * and downstream code rely on positional access (`matches[0]`). + */ + readonly matches: Match[] = []; + + constructor(readonly ruleName: string) {} + + /** + * Record a new full match. + * + * Called by the upstream node (a {@link BetaMemory} or a filter node) + * when a token representing a complete match enters the network. + */ + leftActivate(token: Token): void { + this.matches.push({ token, bindings: token.bindings }); + } + + /** + * Withdraw a previously-recorded match. + * + * Removes the first match whose `token` is reference-equal to the + * supplied token. Unknown tokens are a no-op: upstream memories always + * broadcast deactivations to their subscribers, even when this particular + * node never accepted the token, so silently ignoring the absent case + * keeps wiring lenient (matching {@link BetaMemory.leftDeactivate}). + */ + leftDeactivate(token: Token): void { + const idx = this.matches.findIndex((m) => m.token === token); + if (idx !== -1) { + this.matches.splice(idx, 1); + } + } +} + +/** + * Return the first currently-active match's bindings, or throw + * {@link NoMatchError} if the production has no matches. + * + * "First" is defined by the same insertion order as {@link queryAll} — the + * upstream beta network is responsible for making that order deterministic + * (see module header). + */ +export function query(prod: ProductionNode): Bindings { + const first = prod.matches[0]; + if (!first) throw new NoMatchError(prod.ruleName); + return first.bindings; +} + +/** + * Return a fresh array containing the bindings of every currently-active + * match, in insertion order. + * + * The returned array is a new array on every call, so callers may mutate + * it without affecting subsequent queries or the production node. The + * bindings objects themselves are shared by reference with the node — + * treat them as read-only. + */ +export function queryAll(prod: ProductionNode): Bindings[] { + return prod.matches.map((m) => m.bindings); +}