feat(rete): add deterministic conflict resolution (P1.13)
This commit is contained in:
parent
3104d33985
commit
0259268e34
4 changed files with 281 additions and 2 deletions
160
packages/rete/src/conflict.test.ts
Normal file
160
packages/rete/src/conflict.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
83
packages/rete/src/conflict.ts
Normal file
83
packages/rete/src/conflict.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue