What is "prolog style" in Scala?

Viewed 150

The Scala 3 reference at https://docs.scala-lang.org/scala3/reference/metaprogramming/compiletime-ops.html mentions some "Prolog-like programming style" possible with Scala 3 mataprogramming:

The problem so far was that the Prolog-like programming style of implicit search becomes viral: Once some construct depends on implicit search it has to be written as a logic program itself.

But they all keep the viral nature of implicit search programs based on logic programming.

I did some search but understood only that it somehow abuses the Scala compile-time behavior, and that something in it resembles Prolog.

What is that "Prolog-like programming style" and how it works? What namely resembles Prolog? Does it work in Scala 3?

1 Answers

Here is a basic correspondence to get you started:

  • Prolog-Atoms correspond to ordinary types.
  • Prolog-Variables correspond to type parameters.
  • What's called "Functors" here corresponds to type constructors.
  • Stating facts corresponds to providing constant givens.
  • Making queries corresponds to summoning corresponding proof-terms.

Let's start with a very simple example where we have just one atom, one prolog-"functor", one fact, and one query: this example from Wikipedia

cat(tom).
?- cat(tom).
>> Yes

can be directly translated into

trait Tom     // Atom 'Tom'
trait Cat[X]  // Functor 'Cat' with arity = 1

// Fact: cat(tom).
given Cat[Tom] with {}

// Query: ?- cat(tom). (is Tom a Cat?)
val query = summon[Cat[Tom]] // type-checks = Yes

Prolog rules correspond to parameterized given-definitions, the antecedents on the right side correspond to the using-parameters in the parameter list. For example, the classical syllogism example expressed in Prolog as

man(socrates).
mortal(X) :- man(X).
?- mortal(socrates).
>> True

can be encoded with a parameterized given that uses Man[X] and produces a proof of Mortal[X]:

trait Socrates
trait Man[X]
trait Mortal[X]

given socratesIsAMan: Man[Socrates] with {}
given allMenAreMortal[X](using m: Man[X]): Mortal[X] with {}

val query = summon[Mortal[Socrates]]

You can use scala3-compiler -Xprint:typer to see the composite proof term that the compiler generated to prove that Socrates is Mortal:

allMenAreMortal[Socrates](socratesIsAMan)

Knowing how to encode rules allows you to encode the more complicated example from the Wikipedia:

mother_child(trude, sally).
 
father_child(tom, sally).
father_child(tom, erica).
father_child(mike, tom).
 
sibling(X, Y)      :- parent_child(Z, X), parent_child(Z, Y).
 
parent_child(X, Y) :- father_child(X, Y).
parent_child(X, Y) :- mother_child(X, Y).

?- sibling(sally, erica).
>> Yes

as follows:

trait Trude
trait Sally
trait Tom
trait Erica
trait Mike

trait FatherChild[F, C]
trait MotherChild[M, C]
trait ParentChild[P, C]
trait Sibling[X, Y]

given MotherChild[Trude, Sally] with {}
given FatherChild[Tom, Sally] with {}
given FatherChild[Tom, Erica] with {}
given FatherChild[Mike, Tom] with {}
given [X, Y, Z](using pczx: ParentChild[Z, X], pczy: ParentChild[Z, Y])
  : Sibling[X, Y] with {}

given fatherhoodImpliesParentship[X, Y](using fc: FatherChild[X, Y])
  : ParentChild[X, Y] with {}

given motherhoodImpliesParentship[X, Y](using mc: MotherChild[X, Y])
  : ParentChild[X, Y] with {}

val query = summon[Sibling[Erica, Sally]] // Yes

Here, the compiler will generate a proof term that explains that Erica and Sally are Siblings because they have the same father Tom:

given_Sibling_X_Y[Erica, Sally, Tom](
  fatherhoodImpliesParentship[Tom, Erica](given_FatherChild_Tom_Erica),
  fatherhoodImpliesParentship[Tom, Sally](given_FatherChild_Tom_Sally)
)

More generally, conjunctions are encoded by multiple using-parameters, and disjunctions are encoded by multiple givens with the same result type:

// We can write "X /\ Y" as infix operator for conjunction
case class /\[A, B](a: A, b: B)

// We can write "X \/ Y" as infix operator for disjunctions
enum \/[+A, +B]:
  case Inl(a: A)
  case Inr(b: B)

// Inference for conjunctions: multiple parameters in `using`
given [A, B](using a:A, b: B): (A /\ B) = /\(a, b)

// Inference for disjunctions: multiple rules
given [A](using a: A): \/[A, Nothing] = \/.Inl(a)
given [B](using b: B): \/[Nothing, B] = \/.Inr(b)

// Example:
trait X
trait Y
trait Z
trait W
given X with { override def toString = "X" }
given W with { override def toString = "W" }

@main def query(): Unit =
  println(summon[(X \/ Y) /\ (Z \/ W)])
  // Finds a proof and prints `/\(Inl(X), Inr(W))`.

Since Scala 3, there is even negation available through util.NotGiven:

import scala.util.NotGiven

trait X
trait Y
trait /\[X, Y]

given X with {}
given [A, B](using a: A, b: B): /\[A, B] with {}

// Fails if we add `given Y with {}`
val query = summon[X /\ NotGiven[Y]]

Scala 3 adds a whole bunch of stuff on top of that, such as tuples (which are basically type-level lists) or computing with numeric / boolean / string singleton types, but I don't want to go too deeply into the details here.

Instead, I'd like to conclude by briefly sketching how it all fits into the landscape. The interesting difference between Prolog and Scala's type system is that the Scala compiler actually generates proof terms, and unlike in Prolog (where you get a simple "Yes"/"No"), those proof terms can carry around arbitrarily complicated computational content.

You might have noticed that in the examples above, the with {} mostly remained empty. This is usually not the case in the real code, quite the contrary: in the real code, you usually have some non-trivial definitions in the body of every given ... with { ... }. The reason why one is writing all those facts and inference rules is not for solving logical puzzles and obtaining "Yes" / "No" answers, but for generating huge complicated proof terms that do useful stuff.

The way it works is usually as follows:

  • Suppose you want to obtain a thing X
  • You import some big-&-smart library that knows how to construct a variety of things similar to the desired X
  • You use the "predicates" ( = typeclasses) / facts / rules ( = givens) of that library to express very precisely the properties that you want the thing X to have
  • If your description is precise enough, the library & the Scala compiler is able to summon the thing X purely from its type description.

So, in your average programming language, you have to write out all the terms manually. In Scala 3, you can instead specify the desired properties of the desired term through types, and the compiler will use this Prolog-like term inference system to summon a term with the right properties (given the right libraries, that is).

Related