feat(rete): add deterministic conflict resolution (P1.13)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 14:01:32 -06:00
commit 0259268e34
No known key found for this signature in database
4 changed files with 281 additions and 2 deletions

View file

@ -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"]);
});
});

View file

@ -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;
});
}

View file

@ -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";

View file

@ -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<SessionOptions>;
#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,
}));
}
/**