Parsing & Implementation
by Noel, E-Liang and Mayank
William E Byrd - Relational Interpreters, Program Synthesis, Barliman
Declarative programming
Only talk about the properties of our programs
We get the exact properties we ask for
Generating functions by just talking about inputs and outputs
Functionality is implicitly produced from specifications
Easy to ideate, hard to reason about “procedure”.
E.g. difficult to ideate variable bindings
Reinventing the wheel for simple functions
Declarative, relational (Japanese - 関連 - “relation”) “family” of languages - canonically written in Scheme
Small core (desugared): MicroKanren (today’s topic)
Built to be idiomatic to the “host” language and relational programming.
Interleaving complete search v/s DFS
Handling “procedural” or “effectual” operators
Or how to avoid non-terminating programs as much as possible
Consider this:
non_terminating_rule(X) :- non_terminating_rule(Y) ; terminating_fact(Y)
Prolog (DFS-implementation): goes into an infinite recursion, doesn’t terminate.
MiniKanren (Interleaving search) : does terminate, by incrementing both branches at each stage (aka BFS).
self(X, Y): X \= Y, !, …
retract
-- | (X = "1" OR X = "2") AND (Y = "a" OR Y = "b")
-- Var 0 := X
-- Var 1 := Y
Var 1 := Atom "a"
Var 0 := Atom "1"
Var 1 := Atom "a"
Var 0 := Atom "2"
Var 1 := Atom "b"
Var 0 := Atom "1"
Var 1 := Atom "b"
Var 0 := Atom "2"
As you can see, we will get all associations which satisfy the stated constraints in the goal
.
fresh
does this implicitly for us, such that we do not need to name our bindings.
Inside our State
, we have an implicit parameter VariableCounter
:
Each time fresh
is applied to some State
, we increment the VariableCounter
and use it to generate unique identifiers for variables, e.g. Var 0, Var 1, ...
.
Gets updated to…
There can be many possible combinations of results which satisfy the constraints.
Given the following constraint for X
:
We can have:
Hence we represent these possibilities in a Stream
.
In the event answers continue indefinitely: [Ans1, Ans2, ..]
, it doesn’t matter, since we can just take as many items as we require and leave the rest unevaluated.
In that case why didn’t we just use lists ([a]
)?
Looking at our Stream
definition, we realize it is essentially the same as a list
, with an additional possibility:
Delay
which indicates the Stream
should be Delayed
for evaluation at a future time.
Delay
can be used to force switching between 2 streams.
To understand why this is needed, let’s talk about disj
.
First we need to understand what disj
does.
disj
allows us to perform fair complete search of our results.
if we had 2 result streams:
disj
would merge their results together like so:
Under the hood it does something like this:
We can see disj requires us to deconstruct at least one of its arguments, e.g. x
in (x:xs)
.
We can then ignore everything else, until we force further evaluation:
So far so good, until we recursively define a goal
-- |
-- "goalR" refers to recursive goal
-- "goalT" refers to terminal goal which always succeeds
goalR :: Goal
goalR = goalR `disj` goalT
goalT :: Goal
goalT = return
Inutitively, this should work fine for us, as disj
should behave like so…
If one of its goals never terminates, and the other one does, it should return the results from the terminal goal.
This is because disj
works like disjunction, only one of the goals need to succeed.
Since goalT
terminates, we should just return the result of goalT
.
disj
should not care about the order of its arguments, relations are commutative.
In practice however, the following occurs:
goalR s = (goalR `disj` goalT) s
= goalR s `mplus` goalT s
-- mplus behavior is the same as interleave
-- We evaluate the first argument, in order to deconstruct it
= (goalR `disj` goalT) s `mplus` goalT s
= (goalR s `mplus` goalT s) `mplus` goalT s
= ((goalR s `mplus` goalT s)
`mplus` goalT s)
`mplus` goalT s
= ...
As we can see, we are never able to extract the head of goalR s
due to its recursive definition.
However, if we swapped the arguments around:
Since goalT s
terminates, we would be able ignore the recursive part, until forced to yield more results.
This requires the user to manually push the recursive parts to the rightmost argument, which is error-prone.
By changing the data structure to include a Delay
, we can force switching.
mplus
can pattern match on the Delayed
data constructor of our Stream
and perform a switch
This can then be evaluated since goalT s
is terminal, and mplus
forces on its first argument.
This approach is still error prone however, the responsibility still lies with users to wrap the recursive parts in a Delayed
data constructor.
If we extend the idea to make Delayed
an inherent / implicit part of the recursive definition of the recursive goal, goalR
we can avoid this.
We wrap all goal constructors, disj
, conj
, fresh
, (===)
in Delayed
.
That way, whenever a goal is recursively defined, it always evaluates to a delayed stream.
To make that explicit, let’s see the following expansion:
The essence of it is that goalT s is not recursively defined, so we will always be able to deconstruct it.
More generally, as long as one of the goals are not recursively defined, we will always be able to evaluate disj
to give us results.
SAT solver
Program synthesis
Quines
Boolean Satisfiability Problem: check if a Boolean expression is solvable
NP-hard, unless in disjunctive normal form, e.g.:
(A ∧ B) ∨ (¬B ∧ C ∧ D)
Naturally, doable in microKanren.
conde::[Goal]->Goal
: satisfy a list of goalsrun
: specify variables of interest