Unification: Matching Terms with Variables
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.
unifier.ts
import type { Tracer } from "../trace";import { applySubstitution, occursIn, sameTerm, termText, type Term } from "./terms";export type UnifyFailure = "clash" | "occurs";export interface UnifyResult { unified: boolean; /** The most general unifier when unified, else the bindings made before the failure. */ bindings: Map<string, Term>; substitution: Map<string, Term>; failure: UnifyFailure | null; /** Pairs compared. */ steps: number;}/** The most pairs a run compares: the occurs check keeps real runs finite, this keeps a broken term from looping. */export const MAX_UNIFY_STEPS = 200;/** * Robinson's unification: find a substitution for the variables that * makes two terms identical, and the most general such one. A stack * of pairs starts with the two terms. Each pair is compared with the * bindings so far applied: identical sides need nothing; a variable * against a term binds, unless the variable occurs inside the term, * which would need an infinite term; two function symbols must agree * in name and arity, and then their arguments pair up. The occurs * check is what Prolog omits for speed, and why it can build cyclic * terms. * * Time: O(n^2) in the terms' size with this representation. Space: * O(n). */export class Unifier { constructor(private readonly tracer?: Tracer) {} unify(a: Term, b: Term): UnifyResult { const bindings = new Map<string, Term>(); const substitution = new Map<string, Term>(); const pending: [Term, Term][] = [[a, b]]; const pairsToUnify: [Term, Term][] = [[a, b]]; let failure: UnifyFailure | null = null; let steps = 0; while (pending.length > 0 && failure === null && steps < MAX_UNIFY_STEPS) { while (pairsToUnify.length > 0 && failure === null && steps < MAX_UNIFY_STEPS) { const [rawLeft, rawRight] = pending.pop()!; const [rawLeft, rawRight] = pairsToUnify.pop()!; const left = applySubstitution(rawLeft, bindings); const left = applySubstitution(rawLeft, substitution); const right = applySubstitution(rawRight, bindings); const right = applySubstitution(rawRight, substitution); steps += 1; const same = sameTerm(left, right); // ▸ take the next pair, with the bindings so far applied this.tracer?.emit("compare", { left: termText(left), right: termText(right), same, pending: pending.length }); this.tracer?.emit("compare", { left: termText(left), right: termText(right), same, pairsToUnify: pairsToUnify.length }); if (same) continue; if (left.kind === "variable" || right.kind === "variable") { const [name, term] = left.kind === "variable" ? [left.name, right] : [right.name, left]; if (occursIn(name, term)) { // ▸ the variable is inside the term: no finite solution this.tracer?.emit("occurs-check", { variable: name, term: termText(term) }); failure = "occurs"; continue; } bindings.set(name, term); substitution.set(name, term); // ▸ a variable against a term: bind it this.tracer?.emit("bind", { variable: name, term: termText(term), bindings: Unifier.text(bindings) }); this.tracer?.emit("bind", { variable: name, term: termText(term), substitution: Unifier.text(substitution) }); continue; } if (left.name !== right.name || left.args.length !== right.args.length) { // ▸ different symbols: no binding can fix that this.tracer?.emit("clash", { left: termText(left), right: termText(right) }); failure = "clash"; continue; } // ▸ same symbol and arity: the arguments pair up this.tracer?.emit("decompose", { symbol: left.name, arity: left.args.length, pending: pending.length + left.args.length }); this.tracer?.emit("decompose", { symbol: left.name, arity: left.args.length, pairsToUnify: pairsToUnify.length + left.args.length }); // Pushed last to first, so the leftmost argument pair is compared next. for (let i = left.args.length - 1; i >= 0; i--) pending.push([left.args[i]!, right.args[i]!]); for (let i = left.args.length - 1; i >= 0; i--) pairsToUnify.push([left.args[i]!, right.args[i]!]); } if (failure === null) { // ▸ no pairs left: the bindings are the most general unifier this.tracer?.emit("unified", { bindings: Unifier.text(bindings), count: bindings.size, steps }); this.tracer?.emit("unified", { substitution: Unifier.text(substitution), count: substitution.size, steps }); return { unified: true, bindings, failure: null, steps }; return { unified: true, substitution, failure: null, steps }; } // ▸ the terms have no unifier this.tracer?.emit("failed", { reason: failure, steps }); return { unified: false, bindings, failure, steps }; return { unified: false, substitution, failure, steps }; } /** The substitution as text, each binding with the others applied: `{X = a, Y = g(a)}`. */ static text(bindings: ReadonlyMap<string, Term>): string { static text(substitution: ReadonlyMap<string, Term>): string { return `{${[...bindings].map(([name, term]) => `${name} = ${termText(applySubstitution(term, bindings))}`).join(", ")}}`; return `{${[...bindings].map(([name, term]) => `${name} = ${termText(applySubstitution(term, substitution))}`).join(", ")}}`; }}Unified by {X = a, Y = a, Z = b} in 5 comparisons.