From f9b17346c32cb20c92274c3e935e1acdca22a662 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 13:42:21 -0600 Subject: [PATCH] feat(rete): add typed rule builder + handler registry (P1.5) --- packages/rete/src/builder.test.ts | 81 +++++++++++++++++++++++++++++ packages/rete/src/builder.ts | 82 ++++++++++++++++++++++++++++++ packages/rete/src/index.ts | 7 ++- packages/rete/src/registry.test.ts | 53 +++++++++++++++++++ packages/rete/src/registry.ts | 66 ++++++++++++++++++++++++ packages/rete/src/session.ts | 8 +-- 6 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 packages/rete/src/builder.test.ts create mode 100644 packages/rete/src/builder.ts create mode 100644 packages/rete/src/registry.test.ts create mode 100644 packages/rete/src/registry.ts diff --git a/packages/rete/src/builder.test.ts b/packages/rete/src/builder.test.ts new file mode 100644 index 0000000..3182a2f --- /dev/null +++ b/packages/rete/src/builder.test.ts @@ -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); + }); +}); diff --git a/packages/rete/src/builder.ts b/packages/rete/src/builder.ts new file mode 100644 index 0000000..d6a4c84 --- /dev/null +++ b/packages/rete/src/builder.ts @@ -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; +} diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index 5dd3d19..1d36e1a 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -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"; diff --git a/packages/rete/src/registry.test.ts b/packages/rete/src/registry.test.ts new file mode 100644 index 0000000..287b470 --- /dev/null +++ b/packages/rete/src/registry.test.ts @@ -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"); + }); +}); diff --git a/packages/rete/src/registry.ts b/packages/rete/src/registry.ts new file mode 100644 index 0000000..0f2b6a8 --- /dev/null +++ b/packages/rete/src/registry.ts @@ -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) => void; +export type PredicateFn = (match: Record) => boolean; + +export class HandlerRegistry { + private readonly handlers = new Map(); + + 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(); + + 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); + } + } +} diff --git a/packages/rete/src/session.ts b/packages/rete/src/session.ts index a81089f..df90283 100644 --- a/packages/rete/src/session.ts +++ b/packages/rete/src/session.ts @@ -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);