Skip to content

Variables and Scoping

Logic variables look deceptively simple — a variable is just a name, like X — but naming is exactly where the subtlety lives. This page explains why 2P-Kt's Var behaves the way it does, and what problem the Scope abstraction exists to solve. For the mechanics of using Scope and unification day-to-day, see Unification API.

Why two variables named "X" are never equal

In 2P-Kt, this is always false:

Var.of("X") == Var.of("X")

That looks wrong until you consider what a Prolog variable actually is: a placeholder scoped to one particular occurrence in one particular clause (or query). Two occurrences of X in two unrelated clauses are, logically, two entirely different variables that just happen to share a human-readable name — the same way two different methods can both have a local variable called x without those two xs being "the same variable." If Var.of("X") produced interchangeable variables, resolution would have no way to tell those occurrences apart, and every clause containing X would accidentally alias every other clause containing an X, which would silently break unification the moment two clauses using the same variable names were combined.

2P-Kt solves this by making variable identity independent of the simple name entirely. Internally, every Var carries a complete name — the simple name plus a per-name sequential identifier chosen behind the scenes (X becomes X_1, the next unrelated X becomes X_2, and so on) — and == compares complete names. Var.of and Var.anonymous() are the only ways to create a variable, and neither one lets the caller pick the identifier, precisely so no code can accidentally "forge" a collision. This is deliberate, not an oversight: a factory that returns "the same" variable for the same name would be wrong far more often than it would be convenient — see Term hierarchy for the identity vs. equality distinction this feeds into, and Term Model for how equals exposes both a complete-name and a simple-name comparison mode for exactly this reason.

The problem this creates, and what Scope is for

Taken alone, the rule above has an awkward consequence: if you do want the same variable to occur twice (as in member(H, [_|T]) :- member(H, T)., where H and T each occur twice), calling Var.of("H") twice gives you two unrelated variables, not two occurrences of one. The direct fix — save the Var into a Kotlin val and reuse that reference — works, but it scales badly: real terms and clauses often involve several variables reused across several sub-terms, and threading explicit Kotlin variables through nested factory calls turns into exactly the kind of bookkeeping the rest of the API tries to avoid.

Scope exists to take over that bookkeeping. A Scope is a small, stateful factory of terms that caches the variables it has created by their simple name: asking a Scope for varOf("X") a second time hands back the same Var instance it produced the first time, instead of minting a new one. Everything else a Scope offers — structOf, listOf, ruleOf, and so on — mirrors the static factories on Term's subtypes, so building a term inside a Scope reads the same as building one outside of it; the only difference is that variable reuse becomes automatic instead of manual. Anonymous variables are the deliberate exception: each call to anonymous() still produces a genuinely fresh variable, because anonymous variables are never meant to be shared.

A Scope is intentionally mutable and single-use — it accumulates variable bindings as you use it and is not meant to be reset or shared across unrelated terms. The natural granularity is one Scope per independent unit of construction (typically one clause), which keeps the "same name means same variable" rule scoped to exactly where a Prolog programmer would expect it: within one clause, not across the whole program.

Why terms need to be refreshed

The same distinctness rule that makes Var.of("X") != Var.of("X") is also what makes clause reuse during resolution correct rather than merely convenient. A clause stored in a knowledge base gets used — and potentially unified against — many times over the course of a computation. If each use shared the clause's actual variable instances, one goal's bindings for X would leak into every other goal that happens to reuse the same clause. Standard SLD resolution avoids this by renaming apart: each time a clause is selected, its variables are replaced with fresh ones before unification is attempted.

Term.freshCopy() is that operation: it walks a term and replaces each variable it contains with a new one sharing the same simple name but a distinct complete name — consistently, so that if a variable occurs more than once in the original term, all of its occurrences are replaced by the same fresh variable in the copy (rather than each occurrence getting its own, which would silently change the term's meaning). Ground terms are unaffected, since there is nothing to refresh. Under the hood, freshCopy() is implemented in terms of Scope for exactly this reason — a fresh, empty Scope guarantees that repeated occurrences of the same variable are refreshed consistently, and a freshCopy(scope) overload lets several related terms be refreshed against one shared Scope when they need to keep referring to the same fresh variables as each other.