houserules/packages/rete/tests/golden/g8-variable-binding-chain.test.ts
Joey Yakimowich-Payne 0401295bbc
test(rete): port pararules golden tests; tag Phase 1 parity (P1.14)
10 integration tests in packages/rete/tests/golden/ manually wire
AlphaNode → BetaMemory → JoinNode → ProductionNode chains and drive
them via Session.insert/retract. Each test maps to a pararules Nim
reference test (documented in GOLDEN-MAP.md).

Coverage: packages/rete/src at 96.8% statements / 95.4% branch /
97.8% functions — all well above the 90% Phase 1 gate.

Tests: 227 total (166 pre-existing + 61 new golden), all green.
2026-04-16 14:16:19 -06:00

174 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* G8 — Variable binding propagation across a 3-condition join chain
*
* Pararules origin: tests/test1.nim "number of conditions != number of facts"
* and tests/test2.nim "join value with id" / "multiple joins":
* rule rule1(Fact):
* what:
* (Bob, LeftOf, id) ← id bound from value
* (id, Color, color) ← id used in entity position (value join)
* (id, Height, height) ← id equality
*
* Our equivalent: 3-condition chain where eid is bound in condition 1,
* then used as idEquality in conditions 2 and 3.
*
* (?eid, "Type", ?type) ∧ (?eid, "Health", ?hp) ∧ (?eid, "Position", ?pos)
*
* Network:
* root → join1(alphaType) → mem1 → join2(alphaHealth, idEq:eid) →
* mem2 → join3(alphaPos, idEq:eid) → prod
*/
import { describe, it, expect } from "vitest";
import { Session } from "../../src/session.js";
import { BetaMemory } from "../../src/beta.js";
import { Token } from "../../src/beta.js";
import { JoinNode } from "../../src/join.js";
import { ProductionNode } from "../../src/query.js";
import type { EntityId } from "../../src/schema.js";
import type { JoinTest } from "../../src/join.js";
import type { TokenFact } from "../../src/beta.js";
const ROOT_FACT: TokenFact = { id: 0 as EntityId, attr: "__root", value: null };
const idEq: JoinTest = { type: "idEquality", leftVar: "eid" };
function buildThreeConditionNetwork() {
const session = new Session({ autoFire: false });
const alpha = session._getAlpha();
// Root
const rootMem = new BetaMemory();
rootMem.leftActivate(new Token(null, ROOT_FACT, {}));
// Alpha nodes for each condition
const alphaType = alpha.buildNode({ id: null, attr: "Type" });
const alphaHealth = alpha.buildNode({ id: null, attr: "Health" });
const alphaPos = alpha.buildNode({ id: null, attr: "Position" });
// Intermediate beta memories
const mem1 = new BetaMemory();
const mem2 = new BetaMemory();
// join1: root × Type → binds type and eid
const join1 = new JoinNode(rootMem, alphaType.memory, [], "type", "eid");
// join2: mem1 × Health with idEquality → binds hp
const join2 = new JoinNode(mem1, alphaHealth.memory, [idEq], "hp", null);
// join3: mem2 × Position with idEquality → binds pos
const join3 = new JoinNode(mem2, alphaPos.memory, [idEq], "pos", null);
// Wire: join1 → mem1 → join2 → mem2 → join3
join1.addDownstreamActivate(mem1.leftActivate.bind(mem1));
join1.addDownstreamDeactivate(mem1.leftDeactivate.bind(mem1));
mem1.addDownstreamActivate(join2.leftActivate.bind(join2));
mem1.addDownstreamDeactivate(join2.leftDeactivate.bind(join2));
join2.addDownstreamActivate(mem2.leftActivate.bind(mem2));
join2.addDownstreamDeactivate(mem2.leftDeactivate.bind(mem2));
mem2.addDownstreamActivate(join3.leftActivate.bind(join3));
mem2.addDownstreamDeactivate(join3.leftDeactivate.bind(join3));
// Wire alpha events → join right sides
alphaType.onActivate((id, attr, value) => join1.rightActivate(id, attr, value));
alphaType.onDeactivate((id, attr, value) => join1.rightDeactivate(id, attr, value));
alphaHealth.onActivate((id, attr, value) => join2.rightActivate(id, attr, value));
alphaHealth.onDeactivate((id, attr, value) => join2.rightDeactivate(id, attr, value));
alphaPos.onActivate((id, attr, value) => join3.rightActivate(id, attr, value));
alphaPos.onDeactivate((id, attr, value) => join3.rightDeactivate(id, attr, value));
// ProductionNode
const prod = new ProductionNode("threeCondRule");
join3.addDownstreamActivate(prod.leftActivate.bind(prod));
join3.addDownstreamDeactivate(prod.leftDeactivate.bind(prod));
return { session, prod };
}
describe("G8 — variable binding propagation across 3-condition chain", () => {
it("entity with all 3 facts produces 1 match with all bindings correct", () => {
const { session, prod } = buildThreeConditionNetwork();
const e1 = session.nextId();
session.insert(e1, "Type", "knight");
session.insert(e1, "Health", 100);
session.insert(e1, "Position", "e4");
expect(prod.matches).toHaveLength(1);
const b = prod.matches[0]?.bindings;
expect(b?.["type"]).toBe("knight");
expect(b?.["hp"]).toBe(100);
expect(b?.["pos"]).toBe("e4");
expect(b?.["eid"]).toBe(e1);
});
it("entity missing one fact (Position) does not produce a match", () => {
const { session, prod } = buildThreeConditionNetwork();
const e1 = session.nextId();
session.insert(e1, "Type", "pawn");
session.insert(e1, "Health", 50);
// no Position
expect(prod.matches).toHaveLength(0);
});
it("2 complete entities → 2 matches; 1 partial → still 2 matches", () => {
const { session, prod } = buildThreeConditionNetwork();
const e1 = session.nextId();
const e2 = session.nextId();
const e3 = session.nextId();
session.insert(e1, "Type", "knight");
session.insert(e1, "Health", 100);
session.insert(e1, "Position", "e4");
session.insert(e2, "Type", "rook");
session.insert(e2, "Health", 80);
session.insert(e2, "Position", "a1");
session.insert(e3, "Type", "pawn");
// e3 missing Health and Position
expect(prod.matches).toHaveLength(2);
});
it("idEquality prevents cross-entity joins (different entities cannot merge bindings)", () => {
const { session, prod } = buildThreeConditionNetwork();
const e1 = session.nextId();
const e2 = session.nextId();
session.insert(e1, "Type", "knight");
session.insert(e1, "Health", 100);
// e1 has no Position
session.insert(e2, "Type", "rook");
session.insert(e2, "Position", "a1");
// e2 has no Health
// Neither entity has all 3 facts, so no complete matches
expect(prod.matches).toHaveLength(0);
});
it("bindings from all 3 conditions available in the final token", () => {
const { session, prod } = buildThreeConditionNetwork();
const e1 = session.nextId();
session.insert(e1, "Type", "bishop");
session.insert(e1, "Health", 75);
session.insert(e1, "Position", "c3");
const token = prod.matches[0]?.token;
expect(token).toBeDefined();
// Walk the token chain: 3 facts deep
let depth = 0;
let curr = token ?? null;
while (curr !== null) {
depth++;
curr = curr.parent;
}
// Root token (depth=1) + 3 condition facts = depth 4
expect(depth).toBe(4);
});
});