Prolog in Haskell - Ergonomics, Terms, and Unification
In the previous log, we have started a naive Prolog implementation.
We had come up with the following odd implementation of Arity/Args:
type Args a :: Type
type Args 0 = ()
type Args 1 = Atom
type Args 2 = (Atom, Atom)
type Args 3 = (Atom, Atom, Atom)
Usually, we design type classes around plain types and add "capabilities", let's reverse this as follows:
It totally eliminates Arity, which simplify the code at the cost of forcing Typeable, but it enables us to write the following helpers:
predicate name firstClause otherClauses = Predicate (Atom name) (firstClause :| otherClauses)
(@) :: (Args args) => String -> args -> Goal
(@) name = Goal (Atom name)
We can rewrite our tests as follows:
spec =
describe "Prolog" $ do
it "atom-based facts and atom-based should be valid" $
hasGoal [fruit] ("fruit" @ Atom "tomato") `shouldBe` True
it "atom-based facts and atom-based should be invalid" $
hasGoal [fruit] ("fruit" @ Atom "carrots") `shouldBe` False
-- * Fixtures
fruit =
predicate "fruit" (Atom "tomato", []) [(Atom "melon", [])]
Much more expressive!
The next step is to introduce terms as follows:
data Term
= TAtom Atom
| TVar Var
| TGoal Goal
The next step is to introduce unification, it's a fascinating which is something like a bidirectional equality, for example, the following terms unified:
a = a.
42 = 42.
Answer = 42.
question(a) = question(a).
question(X) = question(42). % X = 42.
Each unification is called a Substitution, we can draft a first term-focused unification function, as follows:
type Substitution = Map Var Term
unifyTerm x y =
case (x, y) of
(TAtom x', TAtom y') -> guard (x' == y') *> Just mempty
(TGoal x', TGoal y') -> guard (x' == y') *> Just mempty
(TVar v, _) -> Just $ Map.singleton v y
(_, TVar v) -> Just $ Map.singleton v x
(TAtom _, TGoal _) -> Nothing
(TGoal _, TAtom _) -> Nothing
Sadly, to lift it to Args-level, we have to be type-specific, widening Args as follows:
unify () () = Just mempty
unify = unifyTerm
unify (x0, x1) (y0, y1) = (<>) <$> unifyTerm x0 y0 <*> unifyTerm x1 y1
unify (x0, x1, x2) (y0, y1, y2) = do
r0 <- unifyTerm x0 y0
r1 <- unifyTerm x1 y1
r2 <- unifyTerm x2 y2
pure $ mconcat [r0, r1, r2]
We can adjust hasGoal in order to replace the hard-coded unification based on equality as follows:
hasGoal ps (Goal gFunctor gArgs) = any go ps
where
go (Predicate pFunctor pClauses) =
case eqTypeRep (typeOf gArgs) (typeOf $ fst $ NonEmpty.head pClauses) of
Just HRefl
| gFunctor == pFunctor ->
any (\(cArgs, goals) -> isJust (unify cArgs gArgs) && all (hasGoal ps) goals) pClauses
_ -> False
We can try it, adding the following tests:
it "rules facts and atom-based goal should be valid" $
hasGoal [fruit, salad, fruitSalad] ("fruitSalad" @ atom "melon") `shouldBe` True
it "rules facts and atom-based goal should be invalid" $
hasGoal [fruit, salad, fruitSalad] ("fruitSalad" @ atom "tomato") `shouldBe` False
Unfortunately, the second test is failing because there are no interactions between each term unification, noticeably between arguments and goals.
To properly solve this problem, we have to somehow accumulate and apply Substitution on Var along the way, we can start with unifyTerm as follows:
unifyTerm ss x y =
case (sub x, sub y) of
(TAtom x', TAtom y') -> guard (x' == y') *> Just mempty
(TGoal x', TGoal y') -> guard (x' == y') *> Just mempty
(TVar v, _) -> Just $ Map.singleton v y
(_, TVar v) -> Just $ Map.singleton v x
(TAtom _, TGoal _) -> Nothing
(TGoal _, TAtom _) -> Nothing
where
sub =
\case
TVar v | Just t <- Map.lookup v ss -> t
o -> o
The next step is to update Args instances in order to accumulate Substitutions, as follows:
unify _ () () = Just mempty
unify = unifyTerm
unify ss (x0, x1) (y0, y1) = do
r0 <- unifyTerm ss x0 y0
r1 <- unifyTerm (ss <> r0) x1 y1
pure $ mconcat [r0, r1]
unify ss (x0, x1, x2) (y0, y1, y2) = do
r0 <- unifyTerm ss x0 y0
r1 <- unifyTerm (ss <> r0) x1 y1
r2 <- unifyTerm (ss <> r0 <> r1) x2 y2
pure $ mconcat [r0, r1, r2]
The final step, for this already-too-long-log, is to adjust hasGoal, but there is a design defect: we do not produce Substitutions, so we cannot use recursion on it, we have to split it.
The other defect is that, we have limited ourselves to the first match, while we might have many.
It gives the following design:
hasGoal ps = not . null . query mempty ps
query ss ps (Goal gFunctor gArgs) = do
Predicate pFunctor pClauses <- ps
guard (gFunctor == pFunctor)
(cArgs, body) <- toList pClauses
case eqTypeRep (typeOf gArgs) (typeOf cArgs) of
Just HRefl -> do
ssa <- maybeToList $ unify ss cArgs gArgs
solveGoals (ss <> ssa) body
Nothing -> []
where
solveGoals ss' =
\case
[] -> [ss']
(g : gs) -> do
ss'' <- query ss' ps g
solveGoals ss'' gs
Our implementation is still far to be complete:
- Variables are mixed-up, when there are rules-recursions, same-named variable will go in the same set
Substitutionare accumulated via(<>), which indistinctly- Produce
Substitutioncontains intermediaryVar
But that'll be the content of next logs.