Forward Chaining: Rule Engines
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
forward-chainer.ts
import type { Tracer } from "../trace";/** A definite clause: if every premise holds, the conclusion holds. */export interface HornRule { premises: string[]; conclusion: string;}export interface HornKnowledgeBase { facts: string[]; rules: HornRule[];}export type AgendaOrder = "fifo" | "lifo";export interface ChainResult { /** The goal was derived; false when there was no goal or the facts ran out first. */ entailed: boolean; /** Every symbol known at the end, in the order it became known. */ known: string[]; /** The rules fired, by index, in order. */ fired: number[];}/** * Forward chaining over propositional Horn clauses: start from the * given facts, and whenever every premise of a rule is known, its * conclusion becomes known too, until the goal turns up or nothing * new can be derived. Each rule keeps a count of premises still * unknown, so a fact is processed once and every rule fires at most * once: the whole run is linear in the size of the knowledge base, * where general propositional entailment is NP-hard. The agenda is a * queue (breadth first) or a stack (depth first); the facts derived * are the same either way, only the order differs. * * Time: O(n) in the total size of facts and rules. Space: O(n). */export class ForwardChainer { constructor( private readonly tracer?: Tracer, private readonly order: AgendaOrder = "fifo", ) {} entails(kb: HornKnowledgeBase, goal: string | null): ChainResult { const remaining = kb.rules.map((rule) => new Set(rule.premises).size); const unknownPremises = kb.rules.map((rule) => new Set(rule.premises).size); const known: string[] = []; const fired: number[] = []; const agenda: string[] = []; const toProcess: string[] = []; for (const fact of kb.facts) { if (!agenda.includes(fact)) agenda.push(fact); if (!toProcess.includes(fact)) toProcess.push(fact); // ▸ a given fact joins the agenda this.tracer?.emit("add-fact", { symbol: fact, agenda: [...agenda] }); this.tracer?.emit("add-fact", { symbol: fact, toProcess: [...toProcess] }); } while (agenda.length > 0) { while (toProcess.length > 0) { const symbol = this.order === "fifo" ? agenda.shift()! : agenda.pop()!; const symbol = this.order === "fifo" ? toProcess.shift()! : toProcess.pop()!; if (known.includes(symbol)) continue; known.push(symbol); // ▸ take a fact off the agenda and mark it known this.tracer?.emit("pop", { symbol, known: known.length, agenda: [...agenda] }); this.tracer?.emit("pop", { symbol, known: known.length, toProcess: [...toProcess] }); if (symbol === goal) { // ▸ the goal is known: entailed this.tracer?.emit("goal-reached", { goal, known: known.length, fired: fired.length }); return { entailed: true, known, fired }; } kb.rules.forEach((rule, index) => { if (!rule.premises.includes(symbol)) return; remaining[index] = (remaining[index] ?? 0) - 1; unknownPremises[index] = (unknownPremises[index] ?? 0) - 1; if (remaining[index] !== 0) return; if (unknownPremises[index] !== 0) return; fired.push(index); if (!known.includes(rule.conclusion) && !agenda.includes(rule.conclusion)) agenda.push(rule.conclusion); if (!known.includes(rule.conclusion) && !toProcess.includes(rule.conclusion)) toProcess.push(rule.conclusion); // ▸ every premise is known, so the conclusion joins the agenda this.tracer?.emit("fire", { rule: index, premises: [...rule.premises], conclusion: rule.conclusion, agenda: [...agenda] }); this.tracer?.emit("fire", { rule: index, premises: [...rule.premises], conclusion: rule.conclusion, toProcess: [...toProcess] }); }); } // ▸ the agenda is empty and nothing new can be derived this.tracer?.emit("fixpoint", { goal, known: known.length, fired: fired.length, entailed: false }); return { entailed: false, known, fired }; }}Entailed: deployed follows from the facts, with 6 known and 3 rules fired.