A propagator cell asked, "what is the price?" and let many sources narrow the answer. This chapter asks a different question: "what assignment of unknowns makes these two structures the same?" Instead of merging numeric ranges, we merge structures that contain variables — and where the propagator settled on one answer, here there may be several, so we must search.
This is the machinery beneath a surprising range of tools. Type inference unifies a program's inferred types against its declared ones. Prolog and logic languages compute entirely by unification and backtracking. A package manager resolving versions searches an enormous space of assignments for one that satisfies every constraint. In the running system, it is how an order finds its way to a warehouse and a carrier: fulfillment routing is a search for an assignment of unknowns — which warehouse, which carrier, what cost — that makes the order match an available route and satisfies every constraint at once.
13.1 Structures With Unknowns
The data is a small, sealed set of forms — a closed structure, exactly as with the evaluator's syntax in Chapter 11. A term is a variable (an unknown to be solved for), an atom (a ground constant like a warehouse id), or a compound (a functor with argument terms, like route(Warehouse, Carrier, Cost)).
-- The structure is closed; the unknowns inside it are what we solve for.
union Term
Var(name: String) -- an unknown
Atom(value: String) -- a ground constant
Compound(functor: String, args: Array[Term]) -- functor applied to argument terms
end
A substitution is the partial knowledge here, playing the role the range played for propagators: a map from variable names to the terms they have been bound to. Two operations use it — resolve, which follows a term through the current bindings until it reaches an unbound variable or a non-variable, and bind, which extends it with one more binding. Like the propagator's merge, a substitution only ever accumulates.
class Substitution
create make() do bindings := {} end
feature
bindings: Map[String, Term]
bind(vname: String, t: Term): Substitution
do bindings.put(vname, t) result := this end
resolve(t: Term): Term
do
match t of
Var(name) then
if bindings.contains_key(name) then result := resolve(bindings.get(name))
else result := t end
else
result := t
end
end
end
13.2 Unification: Making Two Structures Agree
Unification takes two terms and a substitution, and returns either an extended substitution that makes the two terms identical, or a failure. Failure is data — a Result, as in Chapter 4 — not an exception, because failing to unify is an ordinary, expected outcome that the search above will handle.
-- Is t the variable called name?
function is_var(t: Term, name: String): Boolean
do
match t of
Var(name as n) then result := n = name
else result := false
end
end
function unify(a: Term, b: Term, s: Substitution): Result[Substitution, String]
do
let x: Term := s.resolve(a) -- follow existing bindings first
let y: Term := s.resolve(b)
match x of
Var(name) if is_var(y, name) then
result := create Ok[Substitution, String].make(s) -- X with X: nothing to bind
Var(name) then
result := create Ok[Substitution, String].make(s.bind(name, y)) -- bind the unknown
Atom as ax then
result := unify_atom(ax, y, s)
Compound as cx then
result := unify_compound(cx, y, s)
end
end
A match clause reads straight into a variant's payload: Var(name) both selects the variable case and binds its name field to a local, with no intermediate v.name — the pattern is the value it destructures. The rules are simple and total over the sealed structure. A variable unifies with anything, by binding to it. The one exception is a variable meeting itself, which has nothing to learn: binding X to X would send resolve round in a circle forever. A guard — an if after the pattern — picks out that case, and when it does not hold the match falls through to the next clause. Two atoms unify only if they are equal; otherwise it is a clash, and clash is a failure, not a crash. Here a guard separates the agreeing atoms from the clashing ones:
function unify_atom(ax: Atom, y: Term, s: Substitution): Result[Substitution, String]
do
match y of
Var(name) then
result := create Ok[Substitution, String].make(s.bind(name, ax))
Atom(value as v) if v = ax.value then
result := create Ok[Substitution, String].make(s) -- atoms agree
Atom(value as v) then
result := create Err[Substitution, String].make("clash: " + ax.value + " vs " + v)
Compound then
result := create Err[Substitution, String].make("atom does not match a structure")
end
end
The guarded Atom clause narrows to the agreeing case; the unguarded Atom clause beneath it catches the rest, so the match stays exhaustive over the closed structure even with a guard in play. Two compounds unify when their functors and arities match and their arguments unify pairwise — threading the substitution through, so a binding learned from the first argument constrains the rest. That recursion, and the occurs-check that stops a variable being bound to a structure containing itself, are what keep unification from producing a circular or inconsistent answer. They are the structural analogue of the propagator's ordered invariant: guardrails that catch an inconsistency the moment it would enter.
In code that is two small functions. unify_compound handles the three ways a compound can meet another term; unify_args is the pairwise recursion, carrying each argument's substitution into the next so learning accumulates left to right:
function unify_compound(cx: Compound, y: Term, s: Substitution): Result[Substitution, String]
do
match y of
Var(name) then result := create Ok[Substitution, String].make(s.bind(name, cx))
Atom then result := create Err[Substitution, String].make("structure vs atom")
Compound as cy then
if cx.functor = cy.functor and cx.args.length = cy.args.length then
result := unify_args(cx.args, cy.args, s, 0) -- functor + arity agree; check the args
else
result := create Err[Substitution, String].make("functor / arity mismatch")
end
end
end
-- Unify arguments left to right, threading each result substitution into the next.
function unify_args(xs: Array[Term], ys: Array[Term], s: Substitution, i: Integer): Result[Substitution, String]
do
if i >= xs.length then
result := create Ok[Substitution, String].make(s) -- all arguments agreed
else
match unify(xs.get(i), ys.get(i), s) of
Ok as ok then result := unify_args(xs, ys, ok.value, i + 1) -- carry bindings forward
Err as e then result := create Err[Substitution, String].make(e.error)
end
end
end
The occurs-check named above — refusing to bind a variable to a structure that contains it — is elided here for brevity; a production unifier performs it in the Var case before binding.
Note also that bind extends the substitution in place. The search below gets its "undo" by starting each candidate from a fresh Substitution and discarding it on failure. A search that backtracks part-way through a tree of choices would need bind to return an extended copy instead, so that the state before the choice survives.
13.3 Routing by Unification
An order presents its structure with a destination zone and the items it needs, and each fulfillment route is a pattern over that structure with the unknowns left open. Unifying the order against a route either fails (this route cannot serve this order) or succeeds with the unknowns bound. A route pattern like route("zone-a", Warehouse, Carrier, Cost) unified against an order in zone A binds Warehouse, Carrier, and Cost to whatever that route offers — computing the assignment rather than looking it up, and running in whichever direction the knowns allow.
The power over a hand-written matcher is the same power the DSL had over hand-written handlers: routes are data. New routes are new patterns added to a table, never edits to the router. The set of ways an order can be fulfilled is open, and unification is the engine that matches an order against all of them uniformly.
13.4 Search: When More Than One Fits
Usually several routes unify with an order, and not all of them will work: a route may match structurally but the warehouse is out of stock, or the carrier cannot meet the deadline. So we search — try a candidate, check whether its bindings satisfy the domain constraints, and if not, backtrack and try the next. This is the nondeterminism at the heart of logic programming: commit tentatively, and undo on failure.
-- Try each route; the first whose bindings satisfy every constraint wins.
function route(order: Term, routes: Array[Term]): Result[Substitution, String]
do
from
let i: Integer := 0
let found: Boolean := false
result := create Err[Substitution, String].make("no eligible route")
invariant
in_range: 0 <= i and i <= routes.length
variant
routes.length - i -- the search is bounded: it must end
until
i >= routes.length or found
do
match unify(order, routes.get(i), create Substitution.make()) of
Ok as s then
if satisfies_constraints(s.value) then
result := create Ok[Substitution, String].make(s.value) -- commit to this route
found := true
end
-- else: bindings don't satisfy stock/deadline — fall through and backtrack
Err then
do end -- structural mismatch — backtrack and try the next route
end
i := i + 1
end
ensure
satisfies_domain: is_admissible(result) -- a committed route meets every constraint
end
The domain check the loop defers to is where "unifies structurally" becomes "is a route we can actually use." Here it is a one-liner — did the search bind a warehouse at all — but this is the point where a real system would add its substantive checks, like stock on hand, or a carrier that meets the deadline. The loop uses it to choose, and route's postcondition satisfies_domain uses it to promise: whatever the search commits to has passed it.
-- The domain check a committed solution must pass.
-- A real one checks stock and deadline; here: was a warehouse bound at all?
function satisfies_constraints(s: Substitution): Boolean
do result := s.bindings.contains_key("Warehouse") end
-- The postcondition's test: a committed answer satisfies the domain;
-- a failure commits to nothing.
function is_admissible(r: Result[Substitution, String]): Boolean
do
match r of
Ok as ok then result := satisfies_constraints(ok.value)
Err then result := true
end
end
The essential moves are all here: unify to propose an assignment, test it against the domain, keep it if it holds, and otherwise advance to the next candidate. A richer search would explore a tree rather than a list — routes whose choices open further choices — but the flow is the same: propose, check, backtrack. And whether it seeks the first valid route or the cheapest, the searching itself is the same machinery.
Seeing it run makes both ideas concrete at once. A few one-line builders let a term read like the fact it stands for; the route table is ordinary data, and the query fixes what is known and leaves the rest open:
-- Builders, so a term reads like the fact it represents.
function atom(v: String): Term do result := create Atom.make(v) end
function unknown(name: String): Term do result := create Var.make(name) end
function compound(f: String, args: Array[Term]): Term
do result := create Compound.make(f, args) end
-- The route table is just data: route(zone, warehouse, carrier, cost).
let routes: Array[Term] := [
compound("route", [atom("zone-a"), atom("wh-1"), atom("ground"), atom("500")]),
compound("route", [atom("zone-b"), atom("wh-2"), atom("air"), atom("900")])
]
-- The order's query: the zone is known; warehouse, carrier, and cost are unknowns.
let query: Term :=
compound("route", [atom("zone-a"), unknown("Warehouse"), unknown("Carrier"), unknown("Cost")])
match route(query, routes) of
Ok as sol then
let warehouse: Term := sol.value.resolve(unknown("Warehouse"))
-- warehouse is now Atom("wh-1") — dispatch fulfillment there
Err as e then
do end -- e.error is "no eligible route": nothing in the table can ship this order
end
The query fixes the zone and leaves the rest open; route walks the table, unifies against zone-a's fact, and comes back with the unknowns filled — wh-1, ground, 500 — computed, not looked up. Point the same order at a zone no row serves and every unification clashes on the zone atom, the loop exhausts the table, and results in an Err. Add a fulfillment path and it is one more row in routes; the searcher is never touched.
13.5 The Technique That Lives on Guardrails
How much of unification and search can be guarded by the type system? Very little. The bindings are discovered at runtime; whether any assignment exists is unknown until the search runs; whether the search even terminates is not a fact any signature carries. The compiler can still prove what it proves anywhere — that the match over terms is exhaustive, and that every function returns a value of its declared type — but none of that says whether a search will find anything, or whether what it finds is any good. The type of route tells you it returns a Result[Substitution, String]; but whether the substitution is usable or not is a different question.
So the guarantees are carried, almost in their entirety, by contracts. The variant routes.length - i is a proof of termination: a search whose bound must shrink on every step, and can never fall below zero, cannot run forever, which is the single most important thing to guarantee about any search. And the postcondition satisfies_domain holds every committed solution to satisfies_constraints, refusing to return an answer that type-checks but cannot ship.
13.6 The Edge of the Frontier
Part V explored flexibility in two dimensions the earlier parts did not reach. Propagators opened the topology of computation, letting knowledge arrive from any direction. Unification and search opened its structure and control — objects with unknowns, and a nondeterministic hunt for the assignment that fits. Both left the type system nearly behind, and both stayed reliable only because a small, well-placed contract sat at the one point where an inconsistency could enter.
That is the whole spectrum, now traversed end to end: from a sealed order state whose every case the compiler checks, to a backtracking search the compiler can barely see, with contracts as the connective tissue at every step between. We have seen what it is to constrain, to compose, and to open — and, at each opening, what it costs and what buys the cost back.
Which leaves the final question, the one no technique answers on its own. For the actual decision in front of you — given a type, module, or boundary in a system — do you constrain, or do you open? That is judgment, and Part VI turns it into a working checklist for making the call.