Logic Programming Interpreter

PPL2023

We describe an interpreter for Logic Programming written in Scheme. The interpreter re-uses many of the tools we developed when developing interpreters for Functional Programming (the sequence \(L1\) to \(L7\) we introduced in previous chapters). In particular, we reuse directly:

We also implement the abstract syntax for LP using the same methodology we used for \(Li\) - as a disjoint union type of expression types.

The new part of the LP interpreter is the implementation of the answer-query algorithm. Recall that the answer-query relies on two key ingredients:

We describe the proof-tree construction algorithm through the usage of a data type we call lazy tree which is a generalization of the lazy tree we introduced in Chapter 4 to the case of a possibly infinite tree.

Architecture of the Logic Programming Interpreter

The overall system architecture is given in the following architecture diagram.

  1. Syntax: This layer includes, so far, only an abstract syntax module LP-AST, which defines aconvenient interface to all syntactic elements in an LP program.
  2. ADTs: The LP related ADTs are Substitution and Term-equation, which are used for implementing a unification operation in Unify.
  3. lazy-tree-ADT represents an n-ary labeled tree (has labels on internal nodes), whose depth might be infinite. The constructor of lazy trees wraps the child-branches of a node with a lambda abstraction that enables laziness: it delays the construction of children nodes until requested.
  4. LP-solver: The Answer-Query module, defines the LP Gsel and Rsel, and the algorithm for proof-tree construction and search.

Scheme Programming Style

We implement this interpreter in Scheme (the subset of Scheme we have covered in \(L5\)). The programming style we use is extremely close to the one we used in TypeScript:

;; Term: Symbol | Number | Variable | Compound-term

;;  Signature: term?(x)
;;  Type: [T -> Boolean]
;;  Purpose: Type predicate for terms
;;  Pre-conditions: -
(define term?
  (lambda (x)
    (or (symbol? x)
        (number? x)
        (variable? x)
        (compound-term? x))))

Error Handling

We can implement the Result monad in Scheme the same way we did in TypeScript. Here we take a shortcut for the sake of brevity, and implement error handling in the interpreter by using exceptions in Scheme.
Exceptions are thrown in Racket using the error primitive - which interrupts the current code and returns to the toplevel execution with an error value. For example:

(define program->procedure 
  (lambda (program predicate)
    (let ((procedure (assoc predicate program)))
      (if procedure
          procedure
          (error 'program->procedure
                 "Program does not include predicate ~s" predicate)))))

LP Abstract Syntax

To simplify the code of the interpreter, we do not implement tagged disjoint types the way we did in TypeScript. We also do not implement a parser / unparser to read logic programs in concrete syntax and convert them to their AST representation. Instead, we use a readable S-exp-based AST encoding.

In this approach, a programs is represented as a list of the abstract representations of its procedures. Note that this list actually represents a set. For example, the program:

Example1:
% Signature: append(List1, List2, List3)/3
% Purpose: List3 is the concatenation of List1 and List2.
append([], Xs, Xs).
append([X|Xs], Y, [X|Zs] ) :- append(Xs, Y, Zs).
member(X, Ys) :- append(Zs, [X|Xs], Ys).

is represented as the list:

( ((append 3) 
   (0 ((append empty (var Xs) (var Xs))
       true))
   (1 ((append (cons (var X) (var Xs))
               (var Y)
               (cons (var X) (var Zs)))
       (append (var Xs) (var Y) (var Zs)))) )
       
  ((member 2)
   (0 ((member (var X) (var Ys))
       (append (var Zs)
               (cons (var X) (var Xs))
               (var Ys)) )) )

Note the following conventions in this encoding:

LP-AST Functional Interface

Variables Representation and Access

A frequent operation in the interpreter consists of retrieving a rule from a program, and renaming all the variables in the rule. To facilitate this operation, variables can be encoded in two ways:

For example, the rule:

member(X, Ys) :- append(Zs, [X|Xs], Ys).

is encoded originally as follows (within the member/2 procedure):

      ((member (var X) (var Ys))
       (append (var Zs)
               (cons (var X) (var Xs))
               (var Ys)))

When this rule is retrieved from the program (using the RSel procedure) - it will be renamed as:

      ((member (var X 1) (var Ys 1))
       (append (var Zs 1)
               (cons (var X 1) (var Xs 1))
               (var Ys 1)))

and the variable counter (1) will be incremented each time the rule is retrieved.

To support the renaming operation, each compound AST type has a method that computes the list of variable terms it contains.

All of these return a list of variables without repetition.

Substitutions ADT

The Substitution ADT is a direct adaptation of the substitution-ADT module from the type inference system we saw in Chapter 3. It is translated in Scheme and adapted to the datatypes of LP: a substitution is a finite mapping from Variable to Term with occur-check.

The ADT consists of:

Operations:

  1. extend-sub(sub, var, term) which extends sub with the binding var=term
  2. Application of a substitution to LP terms, atomic formulas and queries:
    • sub-apply(sub, term)
    • sub-apply-atomic-formula
    • sub-apply-query
    • sub-apply-rule
  3. Restriction of a substitution: sub-restrict(sub, vars) (also called projection)
  4. Substitution combination: sub-combine(sub1, sub2)

Examples:

;; {T7 = Number, T8 = f(m(T5, Number), T3)} o {T5 = T7, T8 = Boolean}
;; => {T5 = T7, T7 = Number, T8 = f(m(T7, Number), T3)}
> (sub-combine
    (make-sub '((var T7) (var T8))
              '(Number [f (m (var T5) Number) (var T3)]))
    (make-sub '((var T5) (var T8))
              '((var T7) Boolean)))

'(sub ((var T5) (var T7) (var T8))
 ((var T7) Number (f (m (var T7) Number) (var T3))))

;; f(X) o {X = 1} => f(1)
> (sub-apply (make-sub '((var X)) '(1))
             (make-compound-term 'f '((var X))))

'(f 1)

Terms Equations ADT

The unifier is implemented as a term equations solver - that is, to unify 2 atomic-formulas, we split the atomic-formulas into a sequence of equations which pair the terms of each atomic formula one by one, and then solve the equations iteratively. When complex terms are met, they are split into more equations.

The Terms Equations ADT is just a pair data structure for two terms:

Unify

The unification operation operates over atomic formulas and terms. This is an adaptation of the solve module from the type inference system. The unification algorithm uses the equation solving method:

  1. For atomic elements – either compares if equal, different, or can create a substitution (non-circular);
  2. For compound arguments with the same predicate or functor and the same arity, creates equations from corresponding elements, and repeats unification.

Examples

(test (unify-formulas 'true 'true) 
=> 
'(sub () ()))

;; unify( member(f(X1), cons(f(2), [])
;;        member(X, L) )
;; => {L = cons(f(2), []), X = f(X1)}
(test (unify-formulas
 '(member (f (var X1)) (cons (f 2) empty))
 '(member (var X) (var L)))
 =>
 '(sub ((var L) (var X))
       ((cons (f 2) empty) (f (var X1)))))

Lazy-Tree-ADT

A lazy tree is represented as a lazy tree-list whose head is the root-node and whose tail is a regular list of lazy-trees: (root (lambda () (list lzt1 lzt2 ... lztn)))

This is a lazy representation for labeled trees with finite branching, but possibly infinite depth.

The ADT consists of:

The key operation to understand is expand-lzt:

(define expand-lzt
 (lambda (root node-expander)
   (let ((child-nodes (node-expander root)))
     (make-lzt root
       (lambda ()
         (map (lambda (node)
                (expand-lzt node node-expander))
              child-nodes))))))

expand-lzt is the natural way to construct lazy trees. Consider the following example of a finite lazy tree:

(define ft01 
  (expand-lzt '() 
    (lambda (node)
      (if (> (length node) 2) 
          empty-lzt 
          (map (lambda (n) (cons n node)) 
               '(0 1))))))

If we print all the node labels in this tree, we obtain:

'(() (0) (0 0) (0 0 0) (1 0 0) (1 0) (0 1 0) (1 1 0) (1) (0 1) (0 0 1) (1 0 1) (1 1) (0 1 1) (1 1 1))

expand-lzt describes a tree by providing a function that computes the direct children given a root node. Given this function, expand-lzt produces a lazy tree which generates the nodes level by level.

This method can be used to produce infinite trees naturally. Consider the following simple variation:

(define it01 
  (expand-lzt '() 
    (lambda (node)
      (map (lambda (n) (cons n node)) 
           '(0 1)))))

This generates an infinite tree with all binary digits as nodes.

LZT Operations

There are three procedures for scanning a lazy tree:

For example:

(lzt-find-first it01 (lambda (node) (> (length node) 4)))
'(0 0 0 0 0)

(lzt-filter it01 (lambda (node) (< (length node) 4)))
--> Infinite loop

(lzt-filter->lzl it01 (lambda (node) (= (length node) 4)))
--> A lazy list which returns 16 elements then loops for ever

Answer-Query Algorithm

We are now ready to review the implementation of the Answer-Query algorithm in the LP Interpreter: The main functions of the answer-query.rkt module are:

answer-query creates a proof tree as a lazy tree, whose nodes are labeled by a list of:

The substitution is the combination of all substitutions on the tree branches. The nodes of the proof tree are defined in the data structure PT-node, with the getters:

The proof tree is created using the expand-lzt constructor of lazy trees, using the procedure LP-node-expander, which performs the main actions of the LP interpreter:

  1. Applying Gsel on the query.
  2. Applying Rsel on the selected goal.
  3. Creating the new queries for the child node.
  4. Creating the new combined substitutions for the child nodes.
(define LP-node-expander
 (lambda (PT-node program)
   (let ((query (PT-node->query PT-node))
         (sub (PT-node->sub PT-node)))
     (if (success-query? query)
         empty
         (let* ((selected-goal (Gsel query))
                (rule-subs (Rsel selected-goal program))
                (new-queries 
                  (map (lambda (rule-sub)
                         (expand-query query selected-goal rule-sub))
                       rule-subs))
                (new-subs
                  (map (lambda (rule-sub)
                         (sub-combine sub (rule-sub->sub rule-sub)))
                       rule-subs)))
     (map make-PT-node new-queries new-subs))))))
; Signature: expand-query(query, goal, rule-sub)
; Type: [Query * AtomicFormula * RuleSub -> Query]
; Purpose: Given a rule-sub (rule sub)
; and a query (G1 ... Gi-1 Goal Gi+1 ... Gn)
; where rule is ( Head -> Body )
; and Unify(Goal, Head) = sub
; compute [G1 ... Gi-1 Body Gi+1 ... Gn] o sub
(define expand-query 
  (lambda (query goal rule-sub)
    (let ((prefix-suffix (split-list (query->goals query) goal))) 
       (sub-apply-query (rule-sub->sub rule-sub)
                        (make-query (append (car prefix-suffix)
                                            (rule->body (rule-sub->rule rule-sub))
                                            (cdr prefix-suffix)))))
    ))

The key functions of GSel and RSel rely on the ADTs for queries and rules defined in the AST:

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Signature: Gsel(query)
; Type: [Query -> Predication]
; Purpose: select one goal in the query that is not true.
; Pre-conditions: (and (not (success-query? query)) (not (empty? (query->goals query))))
; Tests: -
(define Gsel
  (lambda (query)
    (first (filter (lambda (goal) (not (eq? goal 'true))) 
                   (query->goals query)))))


;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Signature: Rsel(goal, program)
; Type: [AtomicFormula * Program -> List(Rule-Sub)]
; Purpose: Compute the list of pairs (rule, sub) that match goal in the program.
; Pre-conditions: -
; Tests: -
(define Rsel 
  (lambda (goal program)
    (let* ((predicate (predication->predicate goal))   ;; Predicate is a list (pred arity)
           (rules (map rename-rule (program->procedure->rules program predicate)))
           (rule-subs (map (lambda (rule) (make-rule-sub 
                                           rule
                                           (unify-formulas goal (rule->head rule))))
                           rules)))
      (filter (lambda (rule-sub) (not (eq? 'fail (rule-sub->sub rule-sub))))
              rule-subs))))

Observe that RSel performs renaming of the rules when they are retrieved from the program. This is made easy by the encoding of renamed-variables as (var X <n>) and by the accessors rule->vars defined in the AST module.

; Signature: rename-rule(rule)
; Type: [Rule -> Rule]
; Purpose: Return a copy of rule with all variables renamed consistently
; Pre-conditions: -
; Tests: 
; (rename-rule (make-rule '(member (var X) (cons (var H) (var L))) '((member (var X) (var L)))))
; => '((member (var X 2) (cons (var H 2) (var L 2))) ((member (var X 2) (var L 2))))
(define rename-rule
  (let ((n 0))
    (lambda (rule)
      (set! n (+ n 1))
      (let ((vars (rule->vars rule)))
        (let ((new-vars (map (lambda (var) (rename-variable var n)) vars )))
          (sub-apply-rule (make-sub vars new-vars) rule))))))

Computing Answers given a Proof-Tree

Eventually, given a lazy-tree data structure representing a proof-tree, we read off the answers to a query by producing a lazy-list of substitutions:

; Signature: answer-query-lzl(query, program)
; Type: [Query * Program -> LZL(Sub)]
; Purpose: Return a LZL of answers
; Pre-conditions: -
; Tests: -
(define answer-query-lzl
  (lambda (query program)
    (letrec ((node-expander (lambda (node)
                              (LP-node-expander node program))))
      (let* ((pt (expand-lzt (make-PT-node query empty-sub) node-expander))
             (answer-PT-nodes-lzl (lzt-filter->lzl pt LP-success-leaf?)))
        (lzl-map (lambda (PT-node)
                   (sub-restrict (PT-node->sub PT-node)
                                 (query->vars query)))
                 answer-PT-nodes-lzl)))))

The combination of Lazy-lists and Lazy-Trees allows a compact description of the operational semantics of LP which can operate on infinite trees.

Testing the LP Interpreter

Since we did not implement a parser, we encode programs using AST constructors:

(define app1 (make-rule '(append empty (var Xs) (var Xs)) (list 'true)))
(define app2 (make-rule 
              '(append (cons (var X) (var Xs)) (var Y) (cons (var X) (var Zs)))
              '((append (var Xs) (var Y) (var Zs))) ))
(define app-proc (make-procedure (list app1 app2)))

(define mem1 (make-rule '(member (var X) (var Ys)) 
                        '((append (var Zs) (cons (var X) (var Xs)) (var Ys))) ))
(define mem-proc (make-procedure (list mem1)))

(define prog-app-mem
  (make-program (list app-proc mem-proc)))

Queries are submitted as follows:

; ?- member(X, [1])
(define query-member1 (make-query (list '(member (var X) (cons 1 empty)))))

(test (answer-query query-member1 prog-app-mem)
       =>
       '((sub ((var X)) (1))))
; ?- append([1],[2],X)
(define query-append12X (make-query (list '(append (cons 1 empty) (cons 2 empty) (var X)))))

(test (answer-query query-append12X prog-app-mem)
        =>
        '((sub ((var X)) ((cons 1 (cons 2 empty))))))