feat(rete): add typed rule builder + handler registry (P1.5)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:42:21 -06:00
commit f9b17346c3
No known key found for this signature in database
6 changed files with 290 additions and 7 deletions

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { defineRule, v } from "./builder.js";
import { HandlerRegistry } from "./registry.js";
import type { EntityId } from "./schema.js";
const mkId = (n: number) => n as EntityId;
describe("v() variable helper", () => {
it("creates a variable binding descriptor", () => {
const vx = v("x");
expect(vx).toEqual({ __type: "variable", name: "x" });
});
});
describe("defineRule()", () => {
it("creates a RuleDefinition with name, conditions, and handler ref", () => {
const registry = new HandlerRegistry();
registry.register("myHandler", () => {});
const rule = defineRule({
name: "test-rule",
what: [
{ id: mkId(1), attr: "Health", binding: v("hp") },
],
handler: "myHandler",
registry,
});
expect(rule.name).toBe("test-rule");
expect(rule.conditions).toHaveLength(1);
expect(rule.handler).toBe("myHandler");
});
it("throws if handler is not registered", () => {
const registry = new HandlerRegistry();
expect(() => defineRule({
name: "bad-rule",
what: [{ id: null, attr: "X", binding: v("x") }],
handler: "notRegistered",
registry,
})).toThrow("UnknownHandlerError");
});
it("supports wildcard id (null) in conditions", () => {
const registry = new HandlerRegistry();
registry.register("h", () => {});
const rule = defineRule({
name: "wildcard",
what: [{ id: null, attr: "Health", binding: v("hp"), idBinding: v("id") }],
handler: "h",
registry,
});
expect(rule.conditions[0]?.id).toBeNull();
expect(rule.conditions[0]?.idBinding?.name).toBe("id");
});
it("supports salience", () => {
const registry = new HandlerRegistry();
registry.register("h", () => {});
const rule = defineRule({
name: "high-prio",
salience: 10,
what: [{ id: null, attr: "X", binding: v("x") }],
handler: "h",
registry,
});
expect(rule.salience).toBe(10);
});
it("defaults salience to 0", () => {
const registry = new HandlerRegistry();
registry.register("h", () => {});
const rule = defineRule({
name: "default-prio",
what: [{ id: null, attr: "X", binding: v("x") }],
handler: "h",
registry,
});
expect(rule.salience).toBe(0);
});
});

View file

@ -0,0 +1,82 @@
/**
* Rule builder — fluent API for defining Rete productions.
* Per SPEC.md §JSON Rule Schema.
*/
import type { EntityId } from "./schema.js";
import type { AttrKey } from "./wm.js";
import { HandlerRegistry } from "./registry.js";
/** Variable binding descriptor — used in rule conditions. */
export interface VariableDescriptor {
readonly __type: "variable";
readonly name: string;
}
/** Create a variable binding. */
export function v(name: string): VariableDescriptor {
return { __type: "variable", name };
}
/** A condition in a rule's LHS (what block). */
export interface RuleCondition {
/** Entity id to match, null = wildcard. */
readonly id: EntityId | null;
/** Attribute key to match. */
readonly attr: AttrKey;
/** Variable name to bind the VALUE to. */
readonly binding: VariableDescriptor | null;
/** Variable name to bind the ENTITY ID to. */
readonly idBinding?: VariableDescriptor;
}
/** Fully-resolved rule definition produced by defineRule(). */
export interface RuleDefinition {
readonly name: string;
readonly salience: number;
readonly conditions: readonly RuleCondition[];
readonly handler: string;
readonly handlerArgs?: readonly unknown[];
}
export interface DefineRuleOpts {
name: string;
salience?: number;
what: Array<{
id: EntityId | null;
attr: AttrKey;
binding?: VariableDescriptor;
idBinding?: VariableDescriptor;
}>;
handler: string;
handlerArgs?: readonly unknown[];
registry: HandlerRegistry;
}
/**
* Define a rule, validating that all referenced handlers are registered.
*/
export function defineRule(opts: DefineRuleOpts): RuleDefinition {
// Validate handler registration
opts.registry.verify([opts.handler]);
const conditions: RuleCondition[] = opts.what.map((w) => {
const base = {
id: w.id ?? null,
attr: w.attr,
binding: w.binding ?? null,
};
return w.idBinding !== undefined
? { ...base, idBinding: w.idBinding }
: base;
});
const base = {
name: opts.name,
salience: opts.salience ?? 0,
conditions,
handler: opts.handler,
};
return opts.handlerArgs !== undefined
? { ...base, handlerArgs: opts.handlerArgs }
: base;
}

View file

@ -14,5 +14,10 @@ export { WorkingMemory } from "./wm.js";
export type { AlphaCondition } from "./alpha.js";
export { AlphaNetwork, AlphaNode, AlphaMemory } from "./alpha.js";
export type { SessionOptions, RuleDefinition } from "./session.js";
export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js";
export { v, defineRule } from "./builder.js";
export type { HandlerFn, PredicateFn } from "./registry.js";
export { HandlerRegistry, PredicateRegistry, UnknownHandlerError, UnknownPredicateError } from "./registry.js";
export type { SessionOptions } from "./session.js";
export { Session } from "./session.js";

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { HandlerRegistry, PredicateRegistry } from "./registry.js";
describe("HandlerRegistry", () => {
it("register and get a handler by name", () => {
const registry = new HandlerRegistry();
const handler = () => {};
registry.register("myHandler", handler);
expect(registry.get("myHandler")).toBe(handler);
});
it("has() returns true for registered handlers", () => {
const registry = new HandlerRegistry();
registry.register("h", () => {});
expect(registry.has("h")).toBe(true);
});
it("has() returns false for unregistered handlers", () => {
const registry = new HandlerRegistry();
expect(registry.has("missing")).toBe(false);
});
it("get() returns undefined for unregistered handler", () => {
const registry = new HandlerRegistry();
expect(registry.get("missing")).toBeUndefined();
});
it("verify() throws UnknownHandlerError for unregistered names", () => {
const registry = new HandlerRegistry();
expect(() => registry.verify(["missing"])).toThrow("UnknownHandlerError");
});
it("verify() does not throw for all registered handlers", () => {
const registry = new HandlerRegistry();
registry.register("a", () => {});
registry.register("b", () => {});
expect(() => registry.verify(["a", "b"])).not.toThrow();
});
});
describe("PredicateRegistry", () => {
it("register and get a predicate", () => {
const registry = new PredicateRegistry();
const pred = (_match: unknown) => true;
registry.register("isAlive", pred);
expect(registry.get("isAlive")).toBe(pred);
});
it("throws UnknownPredicateError for unregistered predicate in verify()", () => {
const registry = new PredicateRegistry();
expect(() => registry.verify(["missing"])).toThrow("UnknownPredicateError");
});
});

View file

@ -0,0 +1,66 @@
/**
* Handler and Predicate registries.
* Per SPEC.md §JSON Rule Schema: handler-registry pattern (no eval, no function-to-string).
*/
export class UnknownHandlerError extends Error {
constructor(name: string) {
super(`UnknownHandlerError: handler "${name}" is not registered`);
this.name = "UnknownHandlerError";
}
}
export class UnknownPredicateError extends Error {
constructor(name: string) {
super(`UnknownPredicateError: predicate "${name}" is not registered`);
this.name = "UnknownPredicateError";
}
}
export type HandlerFn = (session: unknown, match: Record<string, unknown>) => void;
export type PredicateFn = (match: Record<string, unknown>) => boolean;
export class HandlerRegistry {
private readonly handlers = new Map<string, HandlerFn>();
register(name: string, fn: HandlerFn): void {
this.handlers.set(name, fn);
}
get(name: string): HandlerFn | undefined {
return this.handlers.get(name);
}
has(name: string): boolean {
return this.handlers.has(name);
}
/** Verify all listed handler names are registered. Throws UnknownHandlerError if any missing. */
verify(names: readonly string[]): void {
for (const name of names) {
if (!this.handlers.has(name)) throw new UnknownHandlerError(name);
}
}
}
export class PredicateRegistry {
private readonly predicates = new Map<string, PredicateFn>();
register(name: string, fn: PredicateFn): void {
this.predicates.set(name, fn);
}
get(name: string): PredicateFn | undefined {
return this.predicates.get(name);
}
has(name: string): boolean {
return this.predicates.has(name);
}
verify(names: readonly string[]): void {
for (const name of names) {
if (!this.predicates.has(name)) throw new UnknownPredicateError(name);
}
}
}

View file

@ -6,6 +6,7 @@
import { WorkingMemory, type AttrKey, type FactValue } from "./wm.js";
import { AlphaNetwork } from "./alpha.js";
import type { EntityId } from "./schema.js";
import type { RuleDefinition } from "./builder.js";
export interface SessionOptions {
/** If true, fireRules() is called automatically after each insert/retract. Default: true. */
@ -14,11 +15,6 @@ export interface SessionOptions {
recursionLimit?: number;
}
// Opaque rule definition — P1.5 will refine this type
export interface RuleDefinition {
readonly name: string;
}
export class Session {
readonly #wm: WorkingMemory;
readonly #alpha: AlphaNetwork;
@ -77,7 +73,7 @@ export class Session {
/**
* Register a rule with the session.
* P1.5 will make this typed; currently accepts an opaque RuleDefinition.
* Accepts a RuleDefinition produced by defineRule() (see builder.ts).
*/
add(rule: RuleDefinition): void {
this.#rules.push(rule);