Constraint handling rules
A goal can be suspended instead of solved: it becomes a constraint, and
sits in the constraint store until it is woken up. Constraint handling
rules (CHR) look at that store as a whole, and can drop constraints from it
and spawn new goals. The syntax has three parts: the constraint block, the
rules inside it, and the declare_constraint builtin that puts a goal in
the store. How the store then evolves and how rules fire is covered in
The constraint store.
The constraint block
A constraint block names a clique of predicates and holds the rules
that act on their constraints:
constraint even odd {
rule (even X) (odd X) <=> false.
}
The rule keyword may appear only inside such a block. The cliques of a
program must be pairwise disjoint.
A resumed constraint carries a context with it: the hypothetical rules that
were in force when it was suspended (see Sequent patterns below). By
default that context is trimmed to the rules of the constraint’s own clique.
An optional context filter, written before ?-, names further predicates
whose rules are kept as well:
constraint ctx ?- infer {
% rules for `infer`; a resumed `infer` constraint also keeps
% the rules it accumulated for `ctx`
}
The ?- is omitted when the filter is empty, as in the even odd block
above.
Rules
A rule has four parts. Only the rule keyword itself is required; each of
the others may be left out:
rule TO-MATCH \ TO-REMOVE | GUARD <=> NEW-GOAL .
TO-MATCH and TO-REMOVE are sequences of sequent patterns, matched against the constraints in the store. A constraint
matched on the left of the \ stays in the store; one matched on its right
is taken out. With no \ at all, nothing is removed, and the rule fires at
most once for each ordering of the constraints it matches, so a rule that
only adds goals does not loop. A two-pattern rule with two matching
constraints in the store therefore fires twice, once per ordering of the
pair, not once; The constraint store works this through.
GUARD, after the |, is a goal that must succeed for the rule to fire.
Once it does, the choice is committed: no later rule is tried on the same
constraints.
NEW-GOAL, after the <=>, is a sequent (see below) that is run as a
fresh goal when the rule fires.
So rule (even X) (odd X) <=> false runs the goal false, which fails,
whenever the store holds both even X and odd X for one X; because
a rule that has fired is not reconsidered, that failure is final. A rule may
be preceded by :name "…" (naming it for trace output) and :if "…"
(Rule attributes).
Sequent patterns
Elpi manages a context for every goal: the eigenvariables introduced by
pi and the hypothetical rules introduced by ==>. A suspended goal
therefore is not a bare predicate but a sequent, Eigen ▷ Context ⊢ Goal,
and a sequent pattern chooses how much of that to match:
Goalmatches only the goal, ignoring the eigenvariables and hypotheses the constraint carried;(Context ?- Goal)also matches the hypotheses.Contextis the list of rules added by implication before the constraint was suspended, ordered most-recently-added first, so its head is the highest-priority rule. It holds only the rules for the predicates the context filter admits: the clique’s own, plus any named before?-in theconstraintblock;(Eigen :> Context ?- Goal)also matchesEigen, the list of eigenvariables (thepi-introduced constants) the constraint lives under.
Context and Eigen are ordinary patterns: they may be _, a
variable, or a list pattern, and any variable they bind is in scope in the
guard and in the new goal.
Every unification variable inside a matched constraint is frozen: replaced
by a fresh constant that the rule cannot assign. Freezing is what makes a
rule match the store rather than unify with it: the non-linear K in the
rule below does not merge two unrelated constraints, it only fires when two
constraints really are about the same variable. A frozen variable is shown to
the rule through the uvar head pattern
(Unification and variables) as uvar K L, where K
identifies it and L is the list of bound variables in its scope; two
uvar K … with the same K are the same variable.
When holes never appear under a binder, the “a hole has one type” rule needs no context at all: keep the first typing, unify the second with it.
constraint infer {
rule (infer (uvar K _) T1)
\ (infer (uvar K _) T2)
<=> (T1 = T2).
}
Once holes can occur under binders, the same hole shows up in different
contexts, and the two types live in different scopes. Each matched constraint
is frozen into its own space of names (the Eigen lists of two matched
sequents are disjoint), so the rule must relate the terms across them itself,
in the guard, before it can compare them. This is the rule coq-elpi’s
elaborator actually uses for uniqueness of typing:
constraint declare-evar evar decl def cache rm-evar {
rule (E1 :> G1 ?- evar _ T1 (uvar K L1)) % K's declared type, in scope L1
\ (E2 :> G2 ?- evar _ T2 (uvar K L2)) % a use of K, in scope L2
| (canonical? L1, utc L1 T1 L2 T2 Condition) % relocate T1 into L2's scope,
% producing a goal `Condition`
<=> (E2 :> G2 ?- Condition). % run it under the using
} % sequent's eigenvars and hypotheses
The guard utc (“uniqueness of typing”) walks L1 and L2 in step,
substituting the canonical eigenvariables for the ones actually supplied, and
hands back Condition, the goal that checks the two relocated types
agree. <=> (E2 :> G2 ?- Condition) then runs that goal back inside the
second sequent’s context, where its eigenvariables and hypotheses are in
scope again. The full treatment, for a type theory where terms occur in
types, is given by Guidi, Sacerdoti Coen & Tassi; see
also The constraint store.
Generating a constraint
declare_constraint turns a goal into a constraint and puts it in the
store. The goal must be a function: it needs a func signature
(Type declarations), and a relational predicate is rejected. The
constraint is suspended on one or more keys, each of which must be a
unification variable (or _):
declare_constraint (even X) [X]
The constraint sits in the store until one of its keys is assigned, and then
runs as an ordinary goal again. A key given as _, and a constraint keyed
on the empty list, are never resumed. Several keys may be passed as one list,
[X, Y], or as separate arguments.
Every unification variable that occurs in the constraint should be among the keys; an assignment to one that is left out will not wake the constraint. Elpi does not check this yet (issue #441).
The usual idiom is a rule that fires while the interesting argument is still a
variable, matched with the uvar pattern
(Unification and variables), and suspends the goal on it:
func even int.
even (uvar as X) :- !, declare_constraint (even X) [X].
even 0 :- !.
even N :- N > 0, !, M is N - 2, even M.
At the top-level prompt a leftover constraint is printed after the solution;
print_constraints prints the store at any point:
goal> declare_constraint (even X) [X].
Success:
X = X0
Constraints:
even X0 /* suspended on X0 */
goal> declare_constraint (even X) [X], X = 1.
Failure
A larger program using every part of the syntax above is Euclid’s GCD, with
each gcd fact reduced against the others in its group until one number is
left:
../code/chr.elpi:
1% Euclid's GCD as constraint handling rules. Each `gcd N G` fact says "N is one
2% of the numbers of group G"; the rules keep reducing the store until a single
3% number per group is left — its GCD.
4
5data group.
6symb group-1, group-2 group.
7
8func gcd int, group.
9
10% suspend until the group is known
11gcd A (uvar as G) :- !, declare_constraint (gcd A G) [G].
12
13% once resumed on a concrete group, report the surviving number
14gcd X group-1 :- print "group 1 GCD is" X.
15gcd X group-2 :- print "group 2 GCD is" X.
16
17constraint gcd {
18 % drop a duplicate number
19 rule (gcd A _) \ (gcd B _) | (A = B).
20 % replace the larger number by the difference
21 rule (gcd A _) \ (gcd B G) | (A < B) <=> (C is B - A, gcd C G).
22}
23
24main :-
25 gcd 99 X, gcd 66 X, gcd 22 X, % group X = { 99, 66, 22 }
26 gcd 14 Y, gcd 77 Y, % group Y = { 14, 77 }
27 X = group-1, Y = group-2. % force resumption
group 1 GCD is 11
group 2 GCD is 7