Logic Programming: How Prolog Answers a Query
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.
backward-chainer.ts
import type { Tracer } from "../trace";import { applySubstitution, termText, termVariables, variable, type Term } from "./terms";import { Unifier } from "./unifier";/** A program clause: a head that holds when every body goal does; a fact has no body. */export interface ProgramClause { head: Term; body: Term[];}export interface LogicProgram { clauses: ProgramClause[];}export interface SldResult { /** Each solution as the query's variables bound, `W = ann`, or `yes` for a ground query. */ solutions: string[]; /** Goals selected. */ steps: number; /** Whether some branch hit the depth cap, so the search was not exhaustive. */ limited: boolean;}/** The most goals a run selects: a safety net beneath the depth cap. */export const MAX_SLD_STEPS = 300;/** * SLD resolution, how Prolog answers a query: take the first goal, * try the program's clauses in order, rename a clause's variables so * two uses never share one, unify its head with the goal, and replace * the goal by the clause's body under the bindings. No goals left is * a solution, read off the query's variables; a goal no clause * unifies with fails, and the search backtracks to the next clause * of the nearest choice. Depth-first in clause order, which is what * makes a left-recursive clause loop; a depth cap turns that loop * into a visible limit. * * Time: exponential in the worst case. Space: O(depth). */export class BackwardChainer { private steps = 0; private nodes = 0; private limited = false; private solutions: string[] = []; constructor( private readonly tracer?: Tracer, private readonly maxDepth = 8, private readonly maxSolutions = 3, ) {} query(program: LogicProgram, goals: readonly Term[]): SldResult { this.steps = 0; this.nodes = 0; this.limited = false; this.solutions = []; const asked = goals.reduce<string[]>((vars, goal) => termVariables(goal, vars), []); this.search(program, [...goals], new Map(), 0, this.nodes++, asked); return { solutions: [...this.solutions], steps: this.steps, limited: this.limited }; } private search(program: LogicProgram, goals: Term[], bindings: ReadonlyMap<string, Term>, depth: number, node: number, asked: readonly string[]): void { if (goals.length === 0) { const answer = asked.length === 0 ? "yes" : asked.map((name) => `${name} = ${termText(applySubstitution(variable(name), bindings))}`).join(", "); this.solutions.push(answer); // ▸ no goals left: the bindings are an answer this.tracer?.emit("solution", { node, answer, count: this.solutions.length }); return; } const [first, ...rest] = goals; const [first, ...remainingGoals] = goals; const selected = applySubstitution(first!, bindings); this.steps += 1; // ▸ the first goal is the one to prove next this.tracer?.emit("select-goal", { node, goal: termText(selected), goals: goals.map((g) => termText(applySubstitution(g, bindings))), depth }); if (depth >= this.maxDepth) { this.limited = true; // ▸ too deep: give up on this branch this.tracer?.emit("depth-limit", { node, goal: termText(selected), depth }); return; } if (this.steps >= MAX_SLD_STEPS) return; for (let index = 0; index < program.clauses.length && this.solutions.length < this.maxSolutions; index++) { const renamed = BackwardChainer.rename(program.clauses[index]!, depth + 1); const freshClause = BackwardChainer.rename(program.clauses[index]!, depth + 1); // ▸ rename the clause's variables and try its head this.tracer?.emit("try-clause", { node, clause: index, head: termText(renamed.head), goal: termText(selected) }); this.tracer?.emit("try-clause", { node, clause: index, head: termText(freshClause.head), goal: termText(selected) }); // Head first, so a head variable binds to the goal's and the goal's own variables survive in the resolvent. const result = new Unifier().unify(renamed.head, selected); const result = new Unifier().unify(freshClause.head, selected); if (!result.unified) { // ▸ the head does not unify: next clause this.tracer?.emit("fail", { node, clause: index, reason: result.failure }); continue; } const merged = new Map([...bindings, ...result.bindings]); const next = [...renamed.body, ...rest]; const next = [...freshClause.body, ...remainingGoals]; const child = this.nodes++; // ▸ the head unifies: the goal becomes the clause body this.tracer?.emit("unify", { node, clause: index, child, bindings: Unifier.text(result.bindings), goals: next.map((g) => termText(applySubstitution(g, merged))) }); this.search(program, next, merged, depth + 1, child, asked); // ▸ that branch is done: try the next clause if (this.solutions.length < this.maxSolutions) this.tracer?.emit("backtrack", { node, clause: index, goal: termText(selected) }); } } /** * The clause with every variable suffixed by the depth it is used at, so two uses of one clause * along a branch never share a variable; siblings may, since each branch keeps its own bindings. */ private static rename(clause: ProgramClause, depth: number): ProgramClause { const suffix = `${depth}`; const fresh = (term: Term): Term => (term.kind === "variable" ? { kind: "variable", name: term.name + suffix } : { kind: "function", name: term.name, args: term.args.map(fresh) }); return { head: fresh(clause.head), body: clause.body.map(fresh) }; }}2 solutions to grandparent(tom, W): W = ann, W = pat, after 3 goals.