diff --git a/packages/rete/src/conflict.test.ts b/packages/rete/src/conflict.test.ts new file mode 100644 index 0000000..c107dac --- /dev/null +++ b/packages/rete/src/conflict.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { orderActivations, type Activation, type OrderableRule } from "./conflict.js"; +import { Token, type TokenFact } from "./beta.js"; +import type { EntityId } from "./schema.js"; + +// Minimal token for testing — orderActivations doesn't inspect token content. +const mockFact: TokenFact = { id: 1 as EntityId, attr: "x", value: 1 }; +const mockToken = new Token(null, mockFact, {}); + +function makeRule( + name: string, + salience: number, + conditionCount: number, + addedAt: number, +): OrderableRule { + return { + name, + salience, + conditions: Array.from({ length: conditionCount }, (_, i) => ({ + id: null, + attr: `attr${i}`, + binding: null, + })), + handler: "noop", + addedAt, + }; +} + +function makeActivation( + name: string, + salience: number, + conditions: number, + addedAt: number, +): Activation { + return { + rule: makeRule(name, salience, conditions, addedAt), + token: mockToken, + }; +} + +describe("orderActivations()", () => { + it("orders by salience descending", () => { + const activations = [ + makeActivation("low", 0, 1, 0), + makeActivation("high", 10, 1, 1), + makeActivation("mid", 5, 1, 2), + ]; + const ordered = orderActivations(activations); + expect(ordered.map((a) => a.rule.name)).toEqual(["high", "mid", "low"]); + }); + + it("when salience ties, orders by specificity (condition count) descending", () => { + const activations = [ + makeActivation("one-cond", 5, 1, 0), + makeActivation("three-cond", 5, 3, 1), + makeActivation("two-cond", 5, 2, 2), + ]; + const ordered = orderActivations(activations); + expect(ordered.map((a) => a.rule.name)).toEqual([ + "three-cond", + "two-cond", + "one-cond", + ]); + }); + + it("when salience and specificity tie, orders by insertion order ascending (earlier fires first)", () => { + const activations = [ + makeActivation("third", 0, 2, 2), + makeActivation("first", 0, 2, 0), + makeActivation("second", 0, 2, 1), + ]; + const ordered = orderActivations(activations); + expect(ordered.map((a) => a.rule.name)).toEqual(["first", "second", "third"]); + }); + + it("combines all three tiebreakers correctly", () => { + const activations = [ + makeActivation("A", 5, 2, 1), // salience 5, 2 conds, added 1st + makeActivation("B", 5, 3, 0), // salience 5, 3 conds, added 0th + makeActivation("C", 10, 1, 2), // salience 10, 1 cond + makeActivation("D", 5, 2, 0), // salience 5, 2 conds, added 0th + ]; + const ordered = orderActivations(activations); + // C first (highest salience=10) + // B second (salience=5, most conditions=3) + // D third (salience=5, conditions=2, addedAt=0) + // A fourth (salience=5, conditions=2, addedAt=1) + expect(ordered.map((a) => a.rule.name)).toEqual(["C", "B", "D", "A"]); + }); + + it("returns empty array for empty input", () => { + expect(orderActivations([])).toEqual([]); + }); + + it("returns single-element array unchanged", () => { + const a = makeActivation("only", 5, 3, 0); + const result = orderActivations([a]); + expect(result).toHaveLength(1); + expect(result[0]).toBe(a); + }); + + it("does not mutate the input array", () => { + const a1 = makeActivation("a", 0, 1, 0); + const a2 = makeActivation("b", 10, 1, 1); + const input = [a1, a2]; + const beforeRefs = [input[0], input[1]]; + orderActivations(input); + expect(input[0]).toBe(beforeRefs[0]); + expect(input[1]).toBe(beforeRefs[1]); + expect(input).toHaveLength(2); + }); + + it("returns a new array (referential non-identity)", () => { + const input: Activation[] = [makeActivation("x", 0, 1, 0)]; + const result = orderActivations(input); + expect(result).not.toBe(input); + }); + + it("determinism fuzz: 20 permutations of the same activation set produce identical ordering", () => { + const base = [ + makeActivation("r1", 5, 3, 0), + makeActivation("r2", 5, 2, 1), + makeActivation("r3", 10, 1, 2), + makeActivation("r4", 0, 5, 3), + makeActivation("r5", 5, 3, 4), + ]; + + // Create 20 deterministic permutations (no Math.random) + const shuffles: Activation[][] = []; + for (let i = 0; i < 20; i++) { + const shuffled = [...base]; + // Rotate by i positions + for (let j = 0; j < i % base.length; j++) { + shuffled.push(shuffled.shift()!); + } + // Reverse every other permutation for more variety + if (i % 2 === 1) shuffled.reverse(); + shuffles.push(shuffled); + } + + const results = shuffles.map((s) => + orderActivations(s).map((a) => a.rule.name), + ); + const first = results[0]!; + expect(results.every((r) => r.join(",") === first.join(","))).toBe(true); + // Expected: r3 (sal=10) → r1/r5 (sal=5, specificity=3) → r2 (sal=5, spec=2) → r4 (sal=0) + // r1 before r5 because addedAt 0 < 4 + expect(first).toEqual(["r3", "r1", "r5", "r2", "r4"]); + }); + + it("negative salience still orders correctly", () => { + const activations = [ + makeActivation("neg", -5, 1, 0), + makeActivation("zero", 0, 1, 1), + makeActivation("pos", 5, 1, 2), + ]; + const ordered = orderActivations(activations); + expect(ordered.map((a) => a.rule.name)).toEqual(["pos", "zero", "neg"]); + }); +}); diff --git a/packages/rete/src/conflict.ts b/packages/rete/src/conflict.ts new file mode 100644 index 0000000..541a501 --- /dev/null +++ b/packages/rete/src/conflict.ts @@ -0,0 +1,83 @@ +/** + * Deterministic conflict resolution for the Rete agenda. + * + * Per `packages/rete/SPEC.md §Conflict Resolution`, pending rule activations + * must fire in a stable, reproducible order determined by three keys, applied + * in strict priority: + * + * 1. **Salience descending** — higher salience fires before lower. + * 2. **Specificity descending** — the rule with more LHS conditions fires + * first when salience ties. This matches CLIPS/Jess conventions: a more + * specific match is preferred over a general one. + * 3. **Insertion order ascending** — when salience *and* specificity tie, + * the rule registered earliest with the session wins. `addedAt` is + * the zero-based index assigned by {@link Session.add} at registration + * time. + * + * The ordering is a total order — no key permits ties, so two activations + * can only compare equal if they share salience, specificity, AND addedAt, + * which is forbidden at the session level (each rule gets a unique index). + * Downstream callers therefore do not need `Array#sort` stability guarantees. + * + * {@link orderActivations} is a pure function: it allocates a fresh array + * and never mutates its input. This is load-bearing for the integration + * with the agenda, which keeps the raw activation list around and repeatedly + * re-orders a snapshot. + */ +import type { Token } from "./beta.js"; + +/** + * A rule shape accepted by {@link orderActivations}. + * + * This intentionally mirrors {@link RuleDefinition} from `builder.ts` but + * tags on the `addedAt` insertion index assigned by {@link Session.add}. + * It is kept structurally independent of `RuleDefinition` so the conflict + * module has no circular dependency on the builder. + */ +export interface OrderableRule { + readonly name: string; + readonly salience: number; + readonly conditions: readonly unknown[]; + readonly handler: string; + readonly addedAt: number; +} + +/** + * A pending rule firing: the rule that matched plus the full-match token. + * + * Activations are opaque to the ordering function — only fields on `rule` + * are inspected. The `token` is carried through so the agenda can later + * invoke the rule's handler with the correct bindings. + */ +export interface Activation { + readonly rule: OrderableRule; + readonly token: Token; +} + +/** + * Return a new array of activations in firing order. + * + * Pure; input is not mutated. See module-level doc for the three ordering + * keys. Implemented via a single `Array#sort` with a lexicographic + * comparator — `Array#sort` in V8/JSC is guaranteed stable since ES2019, + * but this comparator already yields a total order so stability is + * redundant. + */ +export function orderActivations( + activations: readonly Activation[], +): Activation[] { + return [...activations].sort((a, b) => { + // 1. Salience descending (higher salience sorts earlier). + if (b.rule.salience !== a.rule.salience) { + return b.rule.salience - a.rule.salience; + } + // 2. Specificity (number of LHS conditions) descending. + const specA = a.rule.conditions.length; + const specB = b.rule.conditions.length; + if (specB !== specA) { + return specB - specA; + } + // 3. Insertion order ascending (earlier-registered rule wins). + return a.rule.addedAt - b.rule.addedAt; + }); +} diff --git a/packages/rete/src/index.ts b/packages/rete/src/index.ts index d4b8718..47bc186 100644 --- a/packages/rete/src/index.ts +++ b/packages/rete/src/index.ts @@ -41,3 +41,6 @@ export { ProductionNode, query, queryAll, NoMatchError } from "./query.js"; export type { DerivedFact, DerivedHandler } from "./derived.js"; export { DerivedFactProduction } from "./derived.js"; + +export type { Activation, OrderableRule } from "./conflict.js"; +export { orderActivations } from "./conflict.js"; diff --git a/packages/rete/src/session.ts b/packages/rete/src/session.ts index 9d06455..2cb342c 100644 --- a/packages/rete/src/session.ts +++ b/packages/rete/src/session.ts @@ -7,8 +7,19 @@ 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"; +import type { OrderableRule } from "./conflict.js"; import { RecursionLimitExceededError } from "./cycle.js"; +/** + * Internal record pairing a registered {@link RuleDefinition} with its + * zero-based insertion index. The index is the third tiebreaker used by + * {@link orderActivations} (see conflict.ts). + */ +interface RegisteredRule { + readonly rule: RuleDefinition; + readonly addedAt: number; +} + export interface SessionOptions { /** If true, fireRules() is called automatically after each insert/retract. Default: true. */ autoFire?: boolean; @@ -19,7 +30,7 @@ export interface SessionOptions { export class Session { readonly #wm: WorkingMemory; readonly #alpha: AlphaNetwork; - readonly #rules: RuleDefinition[] = []; + readonly #rules: RegisteredRule[] = []; readonly #opts: Required; #idCounter = 0; @@ -82,9 +93,31 @@ export class Session { /** * Register a rule with the session. * Accepts a RuleDefinition produced by defineRule() (see builder.ts). + * + * Each registration is assigned a zero-based `addedAt` index, exposed + * via {@link _getOrderableRules} for the conflict-resolution sort + * (see conflict.ts, SPEC.md §Conflict Resolution). */ add(rule: RuleDefinition): void { - this.#rules.push(rule); + this.#rules.push({ rule, addedAt: this.#rules.length }); + } + + /** + * Package-internal accessor returning registered rules in a shape + * compatible with {@link OrderableRule} (i.e. with `addedAt` attached). + * + * Used by the agenda/beta network to build {@link Activation} values + * that can be passed to {@link orderActivations}. Not exported from + * the package index — consumers should go through `fireRules()`. + */ + _getOrderableRules(): readonly OrderableRule[] { + return this.#rules.map(({ rule, addedAt }) => ({ + name: rule.name, + salience: rule.salience, + conditions: rule.conditions, + handler: rule.handler, + addedAt, + })); } /**