feat(rete): add typed rule builder + handler registry (P1.5)
This commit is contained in:
parent
76b21ddd5f
commit
f9b17346c3
6 changed files with 290 additions and 7 deletions
81
packages/rete/src/builder.test.ts
Normal file
81
packages/rete/src/builder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
packages/rete/src/builder.ts
Normal file
82
packages/rete/src/builder.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
53
packages/rete/src/registry.test.ts
Normal file
53
packages/rete/src/registry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
packages/rete/src/registry.ts
Normal file
66
packages/rete/src/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue