feat(rete): add query/queryAll API (P1.10)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:54:11 -06:00
commit 572ffd27e0
No known key found for this signature in database
3 changed files with 319 additions and 0 deletions

View file

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

View file

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

130
packages/rete/src/query.ts Normal file
View file

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