/** * 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); }); });