feat(rete): add query/queryAll API (P1.10)
This commit is contained in:
parent
8046da7728
commit
572ffd27e0
3 changed files with 319 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
186
packages/rete/src/query.test.ts
Normal file
186
packages/rete/src/query.test.ts
Normal 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
130
packages/rete/src/query.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue