Logic programming
Monadic logic
by Samir Talwar
Wednesday, 24 June 2026 at 09:00 CEST
It is a truth universally acknowledged that a data structure in possession of information, must be in want of an extra type parameter.
In the last couple of posts, we looked at implementing a simple logic DSL in Haskell.
- I relent
- Solving the technical interview
- Solving the technical interview, explained
- Monadic logic
- Fairness in disjunctions
It works, but the syntax is a little un-Haskell-like. We can do better, simply by shoehorning in elevating it into a monad.
We start with the basics, except we’ll rename Knowledge to Subst (short for “substitutions”), as is more traditional in µKanren:
{-# LANGUAGE GHC2024 #-}
{-# LANGUAGE BlockArguments #-}
import Control.Applicative
import Control.Monad.State
import Data.Char qualified as Char
import Data.List qualified as List
import Data.Maybe (listToMaybe)
import Prelude hiding (negate)
import System.Environment (getArgs)
newtype Var = Var Int
deriving newtype (Eq, Enum)
data Term atom
= TAtom atom
| TVar Var
deriving stock (Eq)
type Subst atom = [(Var, Term atom)]
data GoalState a = GoalState Var (Subst a)
emptyState :: GoalState a
emptyState = GoalState (Var 0) []
Consider the Lesson, which we will rename here to its more conventional name, Goal:
type Goal atom = GoalState atom -> [GoalState atom]
We can make it carry an extra value around:
type Goal atom a = GoalState atom -> [(GoalState atom, a)]
If you are a Haskell programmer, you might recognise this as very similar to the State monad, except the return type is a list. It is, in fact, the same as StateT (GoalState atom) []. For example, the Control.Monad.State.runStateT function:
runStateT :: StateT s m a -> s -> m (a, s)
specialises to:
runStateT :: StateT (GoalState atom) [] a -> GoalState atom -> [(a, GoalState atom)]
Except for the order of the tuple, this is a function that takes a StateT (GoalState atom) [] a and returns the exact data type of our Goal. So let’s use it:
newtype Goal atom a = Goal (StateT (GoalState atom) [] a)
deriving newtype (Functor, Applicative, Alternative, Monad)
And as Goal is now a wrapper around an exisiting monad type, and we can claim all of the benefits with deriving newtype.
Each output of the goal (which you may remember can be zero, one, or many, represented by a list here) also carries a value, which allows us to pass stuff around. This will be useful later.
We also derive Alternative as a replacement for Monoid; we will use empty instead of mempty and (<|>) instead of (<>). For lists, they are equivalent.
We’ll need to modify the combinators to fit the new type signature, starting with the basics:
falsity :: Goal atom ()
falsity = empty
truth :: Goal atom ()
truth = pure ()
negate :: Goal atom () -> Goal atom ()
negate (Goal g) = Goal do
state <- get
case evalStateT g state of
[] -> pure ()
_ -> empty
Note how it’s much clearer now that negate is working on the current version of the state, and therefore can only negate things that come before, not after.
Equality works the same way except that it wraps the behavior in StateT (and therefore has to pass subst around).
infix 4 ===
(===) :: forall atom. (Eq atom) => Term atom -> Term atom -> Goal atom ()
x === y = Goal $ StateT \(GoalState vars subst) ->
maybe mempty (\newSubst -> pure ((), GoalState vars newSubst)) $
unifyTerm (walk subst x) (walk subst y) subst
where
unifyTerm (TAtom x') (TAtom y') subst
| x' == y' = Just subst
| otherwise = Nothing
unifyTerm (TVar v) y' subst = unifyVar v y' subst
unifyTerm x' (TVar v) subst = unifyVar v x' subst
unifyVar v t subst
| occurs v t = Nothing
| otherwise = Just (pure (v, t) <> subst)
occurs _ (TAtom _) = False
occurs v (TVar tv) = tv == v
&&& and ||| are almost the same:
infixr 3 &&&
(&&&) :: Goal atom a -> Goal atom a -> Goal atom a
(&&&) = (>>)
infixr 2 |||
(|||) :: Goal atom a -> Goal atom a -> Goal atom a
(|||) = (<|>)
within :: (Eq atom) => Term atom -> [Term atom] -> Goal atom ()
within x = foldr ((|||) . (=== x)) falsity
And fresh (previously consider) is where it gets interesting. Rather than accepting a lambda, fresh can return a value.
fresh :: Goal atom (Term atom)
fresh = Goal do
GoalState nextVar subst <- get
put $ GoalState (succ nextVar) subst
pure (TVar nextVar)
We can do the same for understand, which I am renaming to current (and doesn’t have a name in µKanren):
current :: Term atom -> Goal atom (Term atom)
current term = Goal do
GoalState _ subst <- get
pure $ walk subst term
climb and learn get renamed to walk and run, and run is modified to return the value as well as the subst.
walk :: Subst a -> Term a -> Term a
walk _ term@TAtom {} = term
walk subst (TVar v) =
maybe (TVar v) (walk subst) $ lookup v subst
run :: Goal atom a -> [(a, Subst atom)]
run (Goal goal) =
map (\(x, GoalState _ subst) -> (x, subst)) $ runStateT goal emptyState
And now that we have a logic system working, we can start defining our solution for the N queens problem.
We start with add, which uses our monadic version of current:
add :: (Eq atom, Num atom) => Term atom -> Term atom -> Term atom -> Goal atom ()
add x y z = do
x <- current x
y <- current y
z <- current z
case (x, y, z) of
(TAtom x, TAtom y, z) -> z === TAtom (x + y)
(TAtom x, y, TAtom z) -> y === TAtom (z - x)
(x, TAtom y, TAtom z) -> x === TAtom (z - y)
_ -> falsity
And now we can define queens:
queens :: Int -> Goal Int [(Term Int, Term Int)]
queens n = queens' n []
where
valid = map TAtom [0 .. pred n]
queens' 0 _ = pure []
queens' m threatened = do
rank <- fresh
file <- fresh
diag1 <- fresh
diag2 <- fresh
rank `within` valid
negate (rank `within` threatenedRanks)
file `within` valid
negate (file `within` threatenedFiles)
add diag1 rank file
negate (diag1 `within` threatenedDiag1)
add rank file diag2
negate (diag2 `within` threatenedDiag2)
positions <- queens' (pred m) ((rank, file, diag1, diag2) : threatened)
pure $ (rank, file) : positions
where
(threatenedRanks, threatenedFiles, threatenedDiag1, threatenedDiag2) = List.unzip4 threatened
We have rewritten the whole thing in do notation, using bindings to allocate fresh variables. And we’ve lost all the &&& chaining because it’s just >>, which means it can be replaced with a newline.
Check out the new return type. We also return the lists of positions (ranks and files), which we can use for a much more pleasant way of solving this problem.
We define our Position:
data Position = Position Int Int
deriving stock (Eq, Ord)
instance Show Position where
show (Position rank file) =
Char.chr (rank + Char.ord 'a') : show (file + 1)
And now we can rewrite solveQueens to take advantage of our new return value. We have the terms, but they are TVars, so we need to walk over subst to get the actual TAtom values out. We no longer need to guess the variable names.
findQueen :: Subst Int -> (Term Int, Term Int) -> Position
findQueen subst (rankTerm, fileTerm) = Position rank file
where
TAtom rank = walk subst rankTerm
TAtom file = walk subst fileTerm
solveQueens :: Int -> [Position]
solveQueens n = map (findQueen subst) positionTerms
where
(positionTerms, subst) = head $ run (queens n)
main :: IO ()
main = do
args <- getArgs
let n = maybe 8 read $ listToMaybe args
print $ solveQueens n
There we go. Add a type parameter, get a free monad, and much more understandable code.
- I relent
- Solving the technical interview
- Solving the technical interview, explained
- Monadic logic
- Fairness in disjunctions
If you enjoyed this post, you can subscribe to this blog using Atom or RSS.
Maybe you have something to say. You can email me or toot at me. I love feedback. I also love gigantic compliments, so please send those too.
Please feel free to share this on any and all good social networks.
This article is licensed under the Creative Commons Attribution 4.0 International Public License (CC-BY-4.0). The Markdown source is available on Codeberg.