Building a Neural-Symbolic Knowledge Graph Engine in Common Lisp
This chapter builds NSK, a small knowledge graph engine that reasons in two ways at once. It answers questions by exact logic when the facts are present, and it asks a local language model when they are not. The engine loads under a bare LispWorks or SBCL image, and runs either as an interactive prompt or as a small web service.
By the end you will understand unification, a triplestore backed by an append-only log, reader macros that add a query syntax to Lisp, and a neural fallback path that turns a missing fact into a prompt for a language model.
Two ways to know things
A knowledge graph stores facts as triples. Each triple has a subject, a
predicate, and an object: (:mark :wrote :nsk) reads as “Mark wrote NSK”.
Enough triples form a graph, where subjects and objects are nodes and
predicates are the labeled edges between them.
Two traditions answer questions about such a graph.
The symbolic tradition treats a query as a logic problem. You give a pattern with holes in it, such as “who wrote NSK?”, and the engine searches the stored triples for every way to fill the holes. The match is exact. If the fact is not stored, the answer is “no solutions”. This is the model behind Prolog and Datalog. It is precise, fast, and it never invents an answer. Its weakness is that it knows only what you told it.
The neural tradition treats a query as a prediction. A language model has read a large body of text and can guess a plausible object for a subject and a predicate it has never seen stored anywhere. Ask it for the capital of Japan and it answers Tokyo, even though no one wrote that triple into your database. Its strength is coverage. Its weakness is that it can be wrong, and it cannot tell you which facts you actually recorded.
A neural-symbolic system joins the two. It tries the exact symbolic match
first, because stored facts are authoritative. Only when the symbolic search
returns nothing, and only for predicates you mark as neural, does it fall back
to the model. NSK draws this line with a single character in the query syntax:
a predicate written ~:capital may consult the model, while a plain :capital
never does.
The rest of the chapter builds this engine one layer at a time. We start with the matching rule that makes symbolic queries work.
What we will build
NSK is a stack of small, focused files. Each one adds a single capability, and each depends only on the ones below it in this list”
1 main.lisp command-line flags, then serve or start the REPL
2 server.lisp optional REST API (Hunchentoot, loaded on demand)
3 repl.lisp the interactive nsk> prompt
4 query.lisp pattern matching, conjunctions, the neural fallback hook
5 neural.lisp the Ollama client and text-to-triples extraction
6 reader.lisp ?var, [triple], and ~neural reader macros
7 store.lisp the triplestore: two indices and an append-only log
8 unify.lisp logic variables, neural predicates, unification
9 json.lisp a dependency-free JSON reader and writer
10 packages.lisp the package and its exported names
A query flows down through these layers and back up:
1 you type: [?who :wrote :nsk]
2 reader -> (match-triple (logic-var 'who) :wrote :nsk)
3 eval -> run-query
4 -> prove (thread bindings across clauses)
5 -> match-triple-pattern
6 -> candidate-triples (narrow by index)
7 -> unify each candidate
8 -> neural fallback if the predicate is ~ and nothing matched
9 -> collect bindings for the result variables
10 result -> printed as who=:MARK
The core carries no external dependencies. The neural layer uses an HTTP client only when one is present, and the server loads Hunchentoot on demand. So the graph, the query language, and persistence all run with nothing else installed.
The package
One package holds the whole engine. It uses only :cl and optionally the Hunchentoot library and exports the names
that callers and the REPL need. Here is the complete src/packages.lisp.
1 ;;;; packages.lisp --- Package definition for the NSK engine.
2
3 (in-package :cl-user)
4
5 (defpackage :nsk
6 (:use :cl)
7 (:documentation "NSK: Neural-Symbolic Knowledge Graph engine.")
8 (:export
9 ;; logic variables and neural predicates
10 #:logic-var #:logic-var-p #:logic-var-name
11 #:neural-predicate #:neural-predicate-p #:neural-predicate-name
12 ;; unification
13 #:unify #:resolve #:+fail+
14 ;; storage
15 #:graph #:graph-p #:make-graph #:*graph* #:*log-path*
16 #:open-store #:close-store #:add-triple #:remove-triple
17 #:all-triples #:triple-count #:candidate-triples
18 ;; query engine
19 #:match-triple #:match-triple-pattern #:prove #:run-query
20 #:ask #:solutions #:query-result #:query-result-p #:query-result-solutions
21 ;; reader syntax
22 #:*nsk-readtable* #:install-nsk-syntax #:enable-nsk-syntax
23 #:nsk-read-from-string
24 ;; json helpers
25 #:json-encode #:json-parse #:json-get
26 ;; neural layer
27 #:*ollama-url* #:*ollama-model* #:query-neural-fallback
28 #:sanitize-to-keyword #:text->triples #:ingest-text
29 ;; server
30 #:start-server #:stop-server
31 ;; entry points
32 #:repl #:main #:version))
The export list doubles as a table of contents. Read it top to bottom and you see the shape of the whole system: variables and predicates, then unification, then storage, query, reader syntax, JSON, the neural layer, the server, and the entry points.
Unification: matching by structure
Unification is the rule that lets a pattern with holes match a concrete fact. A logic variable is a hole. A substitution (NSK calls it an environment) is a set of bindings from variables to values. To unify two terms means to find a substitution that makes them identical.
Write
for the result of replacing every variable in a term
with
its value under a substitution
. Then unifying two terms
and
means finding a
for which

For example, unifying the pattern (?who :wrote :nsk) with the stored triple
(:mark :wrote :nsk) succeeds with
binding ?who to :mark. The
predicate and object already match, so no extra binding is needed.
The algorithm walks both terms together. When it meets a variable, it binds it, unless the variable already has a binding, in which case it unifies the old value against the new term. This is the style Peter Norvig uses in Paradigms of Artificial Intelligence Programming. NSK follows it closely.
Two design choices make the code short. First, logic variables are interned by
name, so two reads of ?who return the same object and simple equality works.
Second, nil serves as the empty, successful environment, so a distinct
sentinel +fail+ marks failure. Here is the complete src/unify.lisp.
1 ;;;; unify.lisp --- Logic variables, neural predicates, and unification.
2
3 (in-package :nsk)
4
5 ;;; Logic variables are interned by name so that two occurrences of the same
6 ;;; name share one object. This lets ASSOC use structure equality safely and
7 ;;; keeps printed output readable.
8
9 (defvar *logic-vars* (make-hash-table :test 'eq)
10 "Interns logic variables by name.")
11
12 (defstruct (logic-var (:constructor %make-logic-var (name))
13 (:predicate logic-var-p)
14 (:copier nil))
15 (name nil :read-only t))
16
17 (defun logic-var (name)
18 "Return the canonical logic variable named NAME (a symbol)."
19 (or (gethash name *logic-vars*)
20 (setf (gethash name *logic-vars*) (%make-logic-var name))))
21
22 (defmethod print-object ((v logic-var) stream)
23 (format stream "?~a" (logic-var-name v)))
24
25 ;;; A neural predicate marks a relation that should fall back to the language
26 ;;; model when no exact symbolic match exists.
27
28 (defstruct (neural-predicate (:constructor %make-neural-predicate (name))
29 (:predicate neural-predicate-p)
30 (:copier nil))
31 (name nil :read-only t))
32
33 (defun neural-predicate (name)
34 "Wrap NAME as a neural (LLM fallback) predicate."
35 (%make-neural-predicate name))
36
37 (defmethod print-object ((p neural-predicate) stream)
38 (format stream "~~~a" (neural-predicate-name p)))
39
40 ;;; Unification, in the Norvig/PAIP style. The environment is an alist of
41 ;;; (logic-var . value). +FAIL+ is a distinct sentinel so that NIL can serve
42 ;;; as the empty (successful) environment.
43
44 (defconstant +fail+ 'fail "Sentinel returned by UNIFY on failure.")
45
46 (defun unify (x y &optional (env nil))
47 "Unify X and Y under ENV, returning an extended environment or +FAIL+."
48 (cond ((eq env +fail+) +fail+)
49 ((eql x y) env)
50 ((logic-var-p x) (unify-var x y env))
51 ((logic-var-p y) (unify-var y x env))
52 ((and (consp x) (consp y))
53 (unify (cdr x) (cdr y)
54 (unify (car x) (car y) env)))
55 ((and (stringp x) (stringp y) (string= x y)) env)
56 (t +fail+)))
57
58 (defun unify-var (var x env)
59 "Unify logic variable VAR against X, following any existing binding."
60 (let ((binding (assoc var env :test #'equalp)))
61 (if binding
62 (unify (cdr binding) x env)
63 (acons var x env))))
64
65 (defun resolve (x env)
66 "Replace bound variables in X with their values from ENV, recursively."
67 (cond ((logic-var-p x)
68 (let ((b (assoc x env :test #'equalp)))
69 (if b (resolve (cdr b) env) x)))
70 ((consp x) (cons (resolve (car x) env) (resolve (cdr x) env)))
71 (t x)))
A few points reward a second look.
unify short-circuits on +fail+ in its first clause. That lets you nest
calls: the cons clause unifies the cars first, then feeds that result straight
into the unification of the cdrs. If the car step failed, the cdr step sees
+fail+ and passes it through untouched.
unify-var is where a variable earns a binding. If the variable is free, it
adds a pair with acons and returns the longer environment. If the variable
already points at a value, it unifies that value against the new term. This is
what stops a variable from meaning two different things in one query. The tests
check exactly this: once ?x binds to :mark, unifying it against :jane
fails.
resolve walks a term and swaps each bound variable for its value, following
chains of bindings to the end. The query engine calls it at the finish to turn
an environment into the answer you see.
The neural-predicate struct carries no behavior. It is a tag. Wrapping a name
in it is how the reader records that you typed ~:capital rather than
:capital, and the query engine reads that tag later to decide whether the
model may be consulted.
The triplestore and its durable log
The store keeps every triple in memory for speed and mirrors every change to disk for durability. On disk the format is plain: one Lisp list per line, an append-only log of what happened. Here is a sample log after three additions and one deletion.
1 (:ADD :MARK :WROTE :NSK)
2 (:ADD :JANE :WROTE :BOOK)
3 (:ADD :MARK :CODES-IN :LISP)
4 (:DEL :JANE :WROTE :BOOK)
Nothing in this file is ever rewritten. To change a fact you append a new line. To rebuild the graph you read the lines in order and apply each one. After replaying the log above, the graph holds two triples: Jane’s was added and then removed.
In memory the store keeps three views of the same triples. A list preserves
insertion order. Two hash tables index triples by subject and by object, so a
pattern with a known subject or object narrows to a short candidate list in
time instead of scanning everything. Here is the complete
src/store.lisp.
1 ;;;; store.lisp --- The triplestore: in-memory indices plus a disk log.
2 ;;;;
3 ;;;; Triples are (subject predicate object). Two hash tables index triples by
4 ;;;; subject and by object for fast pattern narrowing. State survives restarts
5 ;;;; through an append-only log of s-expressions that is replayed on startup.
6
7 (in-package :nsk)
8
9 (defparameter *log-path* #p"nsk-graph.log"
10 "Default path of the append-only transaction log.")
11
12 (defstruct (graph (:constructor %make-graph) (:copier nil))
13 (spo (make-hash-table :test 'equalp)) ; subject -> list of triples
14 (osp (make-hash-table :test 'equalp)) ; object -> list of triples
15 (triples '()) ; every live triple, newest first
16 (count 0)
17 (log-stream nil)
18 (log-path nil))
19
20 (defvar *graph* nil "The active knowledge graph.")
21
22 (defun make-graph ()
23 "Create an empty in-memory graph with no backing log."
24 (%make-graph))
25
26 ;;; Indexing primitives (no logging)
27
28 (defun %index (graph triple)
29 (destructuring-bind (s p o) triple
30 (declare (ignore p))
31 (push triple (gethash s (graph-spo graph)))
32 (push triple (gethash o (graph-osp graph)))
33 (push triple (graph-triples graph))
34 (incf (graph-count graph)))
35 triple)
36
37 (defun %unindex (graph triple)
38 (destructuring-bind (s p o) triple
39 (declare (ignore p))
40 (setf (gethash s (graph-spo graph))
41 (remove triple (gethash s (graph-spo graph)) :test #'equalp))
42 (setf (gethash o (graph-osp graph))
43 (remove triple (gethash o (graph-osp graph)) :test #'equalp))
44 (setf (graph-triples graph)
45 (remove triple (graph-triples graph) :test #'equalp))
46 (decf (graph-count graph)))
47 triple)
48
49 (defun triple-present-p (graph triple)
50 (member triple (gethash (first triple) (graph-spo graph)) :test #'equalp))
51
52 ;;; Logging
53
54 (defun %log (graph entry)
55 (let ((s (graph-log-stream graph)))
56 (when s
57 (let ((*package* (find-package :nsk))
58 (*print-readably* nil)
59 (*print-pretty* nil))
60 (prin1 entry s)
61 (terpri s)
62 (finish-output s)))))
63
64 ;;; Public mutation API
65
66 (defun add-triple (s p o &optional (graph *graph*))
67 "Add triple (S P O) to GRAPH and append it to the log. Duplicates are ignored."
68 (let ((triple (list s p o)))
69 (unless (triple-present-p graph triple)
70 (%index graph triple)
71 (%log graph (list :add s p o)))
72 triple))
73
74 (defun remove-triple (s p o &optional (graph *graph*))
75 "Remove triple (S P O) from GRAPH and record the deletion in the log."
76 (let ((triple (list s p o)))
77 (when (triple-present-p graph triple)
78 (%unindex graph triple)
79 (%log graph (list :del s p o)))
80 triple))
81
82 (defun all-triples (&optional (graph *graph*))
83 "Return every live triple in insertion order."
84 (reverse (graph-triples graph)))
85
86 (defun triple-count (&optional (graph *graph*))
87 (graph-count graph))
88
89 ;;; Index-driven candidate selection
90
91 (defun indexable-p (term)
92 "True when TERM is a concrete value usable as an index key."
93 (not (or (logic-var-p term) (neural-predicate-p term))))
94
95 (defun candidate-triples (graph pattern)
96 "Return the triples that could match PATTERN, narrowed by the indices."
97 (destructuring-bind (s p o) pattern
98 (declare (ignore p))
99 (cond ((indexable-p s) (gethash s (graph-spo graph)))
100 ((indexable-p o) (gethash o (graph-osp graph)))
101 (t (graph-triples graph)))))
102
103 ;;; Persistence: replay, open, close
104
105 (defun apply-log-entry (graph entry)
106 (destructuring-bind (op s p o) entry
107 (ecase op
108 (:add (let ((tr (list s p o)))
109 (unless (triple-present-p graph tr) (%index graph tr))))
110 (:del (let ((tr (list s p o)))
111 (when (triple-present-p graph tr) (%unindex graph tr)))))))
112
113 (defun replay-log (graph path)
114 "Rebuild GRAPH by replaying the log at PATH in order."
115 (with-open-file (in path :direction :input :if-does-not-exist nil
116 :external-format :utf-8)
117 (when in
118 ;; *read-eval* is disabled so a stray #. in the log cannot run code.
119 (let ((*read-eval* nil)
120 (*package* (find-package :nsk)))
121 (loop for entry = (read in nil :eof)
122 until (eq entry :eof)
123 do (apply-log-entry graph entry))))))
124
125 (defun open-store (&optional (path *log-path*))
126 "Open (or create) the store at PATH, replay its log, and keep it open for
127 appending. Returns the graph."
128 (let ((graph (%make-graph :log-path path)))
129 (when (probe-file path)
130 (replay-log graph path))
131 (setf (graph-log-stream graph)
132 (open path :direction :output :if-exists :append
133 :if-does-not-exist :create :external-format :utf-8))
134 graph))
135
136 (defun close-store (&optional (graph *graph*))
137 "Flush and close the log stream backing GRAPH."
138 (when (and graph (graph-log-stream graph))
139 (finish-output (graph-log-stream graph))
140 (close (graph-log-stream graph))
141 (setf (graph-log-stream graph) nil))
142 graph)
Notice the split between indexing and logging. %index and %unindex only
touch memory. add-triple and remove-triple change memory and then append
one line to the log. Replay reuses %index and %unindex through
apply-log-entry, so the code that rebuilds from disk and the code that runs a
live command share the same primitives.
add-triple ignores duplicates, so adding the same fact twice writes one line,
not two. That keeps the log honest and the count correct. The tests confirm it:
three distinct adds plus one repeat leave a count of three.
Two safety details are worth naming. %log binds *print-readably* to nil
and *print-pretty* to nil so each entry writes as one clean line.
replay-log binds *read-eval* to nil while reading, so a log file cannot
run code through the #. reader macro. A knowledge base you load from disk
should never execute; this one line guarantees it will not.
candidate-triples is the payoff of the two indices. Given a pattern, it
chooses the smallest starting set it can. A known subject uses the subject
index. Failing that, a known object uses the object index. Only a pattern with
a variable subject and a variable object scans the full list. A pattern whose
sole concrete term is the predicate also scans the full list, because the store
keeps no predicate index. Adding one is a good exercise, and you will find it
among the practice problems.
A natural query syntax with reader macros
We want to write queries that read like logic, not like string manipulation. The goal is this:
1 [?who :wrote :nsk] ; one pattern
2 (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp]) ; a conjunction
3 [:mark ~:codes-in ?lang] ; ~ may consult the model
Common Lisp lets us add this syntax at read time with reader macros. Three
characters get new meanings. A ? turns the next symbol into a logic variable.
A ~ turns the next form into a neural predicate. Square brackets collect
exactly three terms into a triple pattern. Each of these expands into ordinary
Lisp:
1 ?person -> (logic-var 'person)
2 ~:codes-in -> (neural-predicate ':codes-in)
3 [s p o] -> (match-triple s p o)
The macros live in their own readtable so ordinary source files keep the
standard syntax. The REPL switches the readtable on for you; other code calls
enable-nsk-syntax. Here is the complete src/reader.lisp.
1 ;;;; reader.lisp --- Reader macros for the NSK query syntax.
2 ;;;;
3 ;;;; ?name -> (logic-var 'name) a logic variable
4 ;;;; ~pred -> (neural-predicate 'pred) an LLM fallback relation
5 ;;;; [s p o] -> (match-triple s p o) a triple pattern
6 ;;;;
7 ;;;; The macros live in their own readtable so ordinary source files keep the
8 ;;;; standard syntax. The REPL binds *readtable* to *nsk-readtable*; other code
9 ;;;; can call ENABLE-NSK-SYNTAX to add them to the current readtable.
10
11 (in-package :nsk)
12
13 (defvar *nsk-readtable* (copy-readtable nil)
14 "A readtable that adds ?var, [triple], and ~neural syntax.")
15
16 (defun install-nsk-syntax (&optional (rt *readtable*))
17 "Install the NSK reader macros into readtable RT and return it."
18 ;; ?name -> (logic-var 'name); non-terminating so foo?bar stays one symbol.
19 (set-macro-character #\?
20 (lambda (stream char)
21 (declare (ignore char))
22 (list 'logic-var (list 'quote (read stream t nil t))))
23 t rt)
24 ;; ~pred -> (neural-predicate 'pred)
25 (set-macro-character #\~
26 (lambda (stream char)
27 (declare (ignore char))
28 (list 'neural-predicate (list 'quote (read stream t nil t))))
29 t rt)
30 ;; ] closes a triple exactly like ) closes a list.
31 (set-macro-character #\] (get-macro-character #\) nil) nil rt)
32 ;; [s p o] -> (match-triple s p o)
33 (set-macro-character #\[
34 (lambda (stream char)
35 (declare (ignore char))
36 (let ((triple (read-delimited-list #\] stream t)))
37 (unless (= (length triple) 3)
38 (error "NSK triple pattern needs exactly three elements: ~s" triple))
39 (cons 'match-triple triple)))
40 nil rt)
41 rt)
42
43 (install-nsk-syntax *nsk-readtable*)
44
45 (defun enable-nsk-syntax ()
46 "Copy the current *readtable* and add NSK syntax to it."
47 (setf *readtable* (copy-readtable *readtable*))
48 (install-nsk-syntax *readtable*))
49
50 (defun nsk-read-from-string (string)
51 "Read one form from STRING using the NSK readtable."
52 (let ((*readtable* *nsk-readtable*))
53 (read-from-string string)))
The ? and ~ macros are non-terminating, the t argument to
set-macro-character. That means the character keeps a symbol together when it
appears inside one, so a name like foo?bar still reads as a single symbol.
Only a ? at the start of a token triggers the macro.
The bracket pair is a small trick. ] gets the same reader as ), so it
closes a form. [ reads terms up to the matching ] with
read-delimited-list, checks that it got exactly three, and builds a
match-triple call. A pattern with two or four terms signals an error at read
time, before evaluation ever begins.
Each macro produces a form, not a value. [?who :wrote :nsk] becomes the list
(match-triple (logic-var 'who) :wrote :nsk). What that form does when
evaluated is the job of the next section.
The query engine
Now we connect patterns to the store. A single triple pattern yields a list of environments, one for each triple it matches. A conjunction runs the patterns left to right and passes each environment forward, so a variable bound by the first clause carries its value into the next. When a neural predicate finds no symbolic match, the engine asks the model for the missing object.
Here is the complete src/query.lisp.
1 ;;;; query.lisp --- Pattern matching, neural fallback, and the ASK macro.
2 ;;;;
3 ;;;; A single triple pattern yields a list of environments (one per match).
4 ;;;; Conjunctions thread environments forward with MAPCAN. When a ~ predicate
5 ;;;; finds no symbolic match, the engine asks the model for the missing object.
6
7 (in-package :nsk)
8
9 (defun ground-pattern (pattern env)
10 "Replace bound variables in PATTERN with their values from ENV."
11 (mapcar (lambda (term)
12 (if (logic-var-p term)
13 (let ((b (assoc term env :test #'equalp)))
14 (if b (cdr b) term))
15 term))
16 pattern))
17
18 (defun match-triple-pattern (pattern env graph)
19 "Return every environment that satisfies PATTERN under ENV. A neural
20 predicate first tries a plain symbolic match on its bare name, then falls
21 back to the model."
22 (let* ((pred (second pattern))
23 (neuralp (neural-predicate-p pred))
24 ;; For symbolic matching, unwrap a neural predicate to its bare name.
25 (spat (if neuralp
26 (list (first pattern) (neural-predicate-name pred) (third pattern))
27 pattern))
28 (gpat (ground-pattern spat env))
29 (results '()))
30 (dolist (tr (candidate-triples graph gpat))
31 (let ((e (unify spat tr env)))
32 (unless (eq e +fail+) (push e results))))
33 (when (and (null results) neuralp)
34 (let ((e (neural-match gpat spat env)))
35 (when (and e (not (eq e +fail+))) (push e results))))
36 (nreverse results)))
37
38 (defun neural-match (grounded spat env)
39 "Resolve a neural predicate by asking the model for the unknown object."
40 (destructuring-bind (subject predicate object) grounded
41 (declare (ignore object))
42 (when (indexable-p subject) ; subject must be concrete
43 (let ((target (third spat)))
44 (when (logic-var-p target)
45 (let ((answer (query-neural-fallback subject predicate)))
46 (when answer
47 (unify target (sanitize-to-keyword answer) env))))))))
48
49 (defun prove (patterns env graph)
50 "Prove PATTERNS as a conjunction, threading environments forward."
51 (if (null patterns)
52 (list env)
53 (mapcan (lambda (e) (prove (cdr patterns) e graph))
54 (match-triple-pattern (car patterns) env graph))))
55
56 (defun collect-vars (form &optional acc)
57 "Collect the distinct logic variables appearing in FORM."
58 (cond ((logic-var-p form)
59 (if (member form acc :test #'equalp) acc (cons form acc)))
60 ((consp form) (collect-vars (cdr form) (collect-vars (car form) acc)))
61 (t acc)))
62
63 ;;; A query result wraps the raw solutions so the REPL can print a readable
64 ;;; table while callers can still pull the data out with SOLUTIONS.
65
66 (defstruct (query-result (:constructor make-query-result (solutions)))
67 solutions)
68
69 (defmethod print-object ((r query-result) stream)
70 (let ((sols (query-result-solutions r)))
71 (cond ((null sols) (format stream "#<no solutions>"))
72 ((equal sols '(())) (format stream "yes"))
73 (t (format stream "~{~a~^~%~}"
74 (mapcar (lambda (sol)
75 (format nil "~{~a=~s~^, ~}"
76 (loop for (k . v) in sol
77 append (list (string-downcase (symbol-name k)) v))))
78 sols))))))
79
80 (defun solutions (result)
81 "Return the raw list of solutions from a query result (or a plain list)."
82 (if (query-result-p result) (query-result-solutions result) result))
83
84 (defun run-query (patterns result-vars &optional (graph *graph*))
85 "Prove PATTERNS and return a QUERY-RESULT binding each of RESULT-VARS."
86 (let ((sols (mapcar (lambda (env)
87 (mapcar (lambda (v)
88 (cons (logic-var-name v) (resolve v env)))
89 result-vars))
90 (prove patterns nil graph))))
91 (make-query-result (remove-duplicates sols :test #'equalp :from-end t))))
92
93 (defun match-triple (s p o &optional (graph *graph*))
94 "Run one triple pattern, returning a QUERY-RESULT for its variables."
95 (let* ((pattern (list s p o))
96 (vars (reverse (collect-vars pattern))))
97 (run-query (list pattern) vars graph)))
98
99 (defmacro ask (result-vars &body clauses)
100 "Datalog-style query. RESULT-VARS is a list like (?a ?b); each clause is a
101 [s p o] triple pattern. Returns a QUERY-RESULT."
102 (let ((patterns
103 (mapcar (lambda (clause)
104 (unless (and (consp clause) (eq (first clause) 'match-triple))
105 (error "ASK clause is not a [triple] pattern: ~s" clause))
106 (cons 'list (rest clause)))
107 clauses)))
108 `(run-query (list ,@patterns) (list ,@result-vars))))
Work through the flow with one clause first. match-triple-pattern takes a
pattern, an incoming environment, and the graph. It grounds the pattern by
substituting any bound variables, asks the store for candidate triples, and
unifies the pattern against each candidate. Every success adds one environment
to the results.
The conjunction lives in prove, and it is short because mapcan does the
work. For the first pattern it gets a list of environments. For each of those
it proves the rest of the patterns, and mapcan splices the results together.
An empty pattern list means every clause has been proved, so it returns a list
holding the one surviving environment. This is a depth-first search over the
ways the clauses can all hold at once.
The neural hook sits inside match-triple-pattern. If the predicate is a
neural predicate and the symbolic search found nothing, neural-match runs.
It has strict preconditions: the subject must be concrete, since the model
needs something to reason about, and the object must be the variable we want
filled. Given both, it calls query-neural-fallback, turns the model’s string
into a keyword, and unifies that keyword with the target variable. A neural
predicate that does match symbolically never calls the model, because stored
facts win.
run-query finishes a query. It proves the patterns, then for each surviving
environment it reads off the values of the result variables and pairs each with
its name. remove-duplicates collapses identical rows, so two different proofs
of the same answer show once.
The ask macro is a thin front end. Each clause has already been read into a
(match-triple ...) form by the bracket macro. ask checks that shape, peels
off the match-triple head, and rebuilds each clause as a plain list of three
terms for run-query. It refuses any clause that is not a triple pattern, so a
typo fails at macro-expansion time with a clear message.
The query-result struct carries the raw solutions and prints them in a form a
person can read. An empty result prints #<no solutions>. A proof with no
result variables, the shape of a yes/no question, prints yes. Otherwise each
row prints as var=value pairs. The REPL leans on this printer, while code that
needs the data calls solutions to get the raw list.
Self-contained JSON
The neural layer and the REST server both speak JSON, so NSK carries its own JSON reader and writer. This keeps the core free of dependencies. The writer takes a tagged Lisp form and the reader returns plain Lisp data.
A request to the model looks like this on the wire:
1 {"model":"qwen3.5:4b","system":"...","prompt":"...","format":"json","stream":false}
The daemon answers with an envelope whose response field holds another JSON
string:
1 {"model":"qwen3.5:4b","response":"{\"result\": \"Tokyo\"}","done":true}
So the reader must parse the outer object, pull out response, and parse that
string again to reach {"result": "Tokyo"}. The reader returns an object as an
alist of (string-key . value), an array as a list, and the three literals as
:true, :false, and :null. Here is the complete src/json.lisp.
1 ;;;; json.lisp --- A small, self-contained JSON reader and writer.
2 ;;;;
3 ;;;; NSK keeps its own JSON code so the core has no external dependencies and
4 ;;;; can load and run under a bare LispWorks image. The writer takes a tagged
5 ;;;; Lisp form; the reader returns alists for objects and lists for arrays.
6
7 (in-package :nsk)
8
9 ;;; Writer
10
11 (defun json-write-string (string stream)
12 (write-char #\" stream)
13 (loop for ch across string do
14 (case ch
15 (#\" (write-string "\\\"" stream))
16 (#\\ (write-string "\\\\" stream))
17 (#\Newline (write-string "\\n" stream))
18 (#\Return (write-string "\\r" stream))
19 (#\Tab (write-string "\\t" stream))
20 (#\Backspace (write-string "\\b" stream))
21 (#\Page (write-string "\\f" stream))
22 (t (if (< (char-code ch) #x20)
23 (format stream "\\u~4,'0x" (char-code ch))
24 (write-char ch stream)))))
25 (write-char #\" stream))
26
27 (defun json-encode (value &optional stream)
28 "Encode VALUE as JSON. Objects are (:object (key . val) ...); arrays are
29 (:array val ...); literals are :true, :false, :null. With no STREAM,
30 return a string."
31 (if stream
32 (%json-encode value stream)
33 (with-output-to-string (s) (%json-encode value s))))
34
35 (defun %json-encode (value stream)
36 (cond
37 ((stringp value) (json-write-string value stream))
38 ((integerp value) (princ value stream))
39 ((floatp value) (format stream "~f" value))
40 ((eq value :true) (write-string "true" stream))
41 ((eq value :false) (write-string "false" stream))
42 ((eq value :null) (write-string "null" stream))
43 ((and (consp value) (eq (car value) :object))
44 (write-char #\{ stream)
45 (loop for (pair . more) on (cdr value) do
46 (json-write-string (string (car pair)) stream)
47 (write-char #\: stream)
48 (%json-encode (cdr pair) stream)
49 (when more (write-char #\, stream)))
50 (write-char #\} stream))
51 ((and (consp value) (eq (car value) :array))
52 (write-char #\[ stream)
53 (loop for (v . more) on (cdr value) do
54 (%json-encode v stream)
55 (when more (write-char #\, stream)))
56 (write-char #\] stream))
57 (t (json-write-string (princ-to-string value) stream))))
58
59 ;;; Reader
60
61 (define-condition json-error (error)
62 ((message :initarg :message :reader json-error-message))
63 (:report (lambda (c s) (format s "JSON parse error: ~a" (json-error-message c)))))
64
65 (defstruct (json-cursor (:constructor make-json-cursor (string)))
66 (string "" :type string)
67 (pos 0 :type fixnum))
68
69 (defun jc-peek (c)
70 (let ((s (json-cursor-string c)) (p (json-cursor-pos c)))
71 (when (< p (length s)) (char s p))))
72
73 (defun jc-next (c)
74 (prog1 (jc-peek c) (incf (json-cursor-pos c))))
75
76 (defun jc-skip-ws (c)
77 (loop for ch = (jc-peek c)
78 while (and ch (member ch '(#\Space #\Tab #\Newline #\Return)))
79 do (jc-next c)))
80
81 (defun json-parse (string)
82 "Parse a JSON document into Lisp data. Objects become alists of
83 (string-key . value); arrays become lists; strings stay strings; numbers
84 parse to numbers; true/false/null become :true/:false/:null."
85 (let ((c (make-json-cursor string)))
86 (prog1 (json-parse-value c) (jc-skip-ws c))))
87
88 (defun json-parse-value (c)
89 (jc-skip-ws c)
90 (let ((ch (jc-peek c)))
91 (cond ((null ch) (error 'json-error :message "unexpected end of input"))
92 ((char= ch #\{) (json-parse-object c))
93 ((char= ch #\[) (json-parse-array c))
94 ((char= ch #\") (json-parse-string c))
95 ((or (digit-char-p ch) (char= ch #\-)) (json-parse-number c))
96 ((char= ch #\t) (json-parse-literal c "true" :true))
97 ((char= ch #\f) (json-parse-literal c "false" :false))
98 ((char= ch #\n) (json-parse-literal c "null" :null))
99 (t (error 'json-error :message (format nil "unexpected char ~a" ch))))))
100
101 (defun json-parse-literal (c text value)
102 (loop for expected across text
103 for got = (jc-next c)
104 unless (and got (char= got expected))
105 do (error 'json-error :message (format nil "bad literal, expected ~a" text)))
106 value)
107
108 (defun json-parse-object (c)
109 (jc-next c) ; consume {
110 (jc-skip-ws c)
111 (if (eql (jc-peek c) #\})
112 (progn (jc-next c) '())
113 (let ((pairs '()))
114 (loop
115 (jc-skip-ws c)
116 (let ((key (json-parse-string c)))
117 (jc-skip-ws c)
118 (unless (eql (jc-next c) #\:)
119 (error 'json-error :message "expected : after object key"))
120 (push (cons key (json-parse-value c)) pairs))
121 (jc-skip-ws c)
122 (let ((ch (jc-next c)))
123 (cond ((eql ch #\,) nil)
124 ((eql ch #\}) (return (nreverse pairs)))
125 (t (error 'json-error :message "expected , or } in object"))))))))
126
127 (defun json-parse-array (c)
128 (jc-next c) ; consume [
129 (jc-skip-ws c)
130 (if (eql (jc-peek c) #\])
131 (progn (jc-next c) '())
132 (let ((items '()))
133 (loop
134 (push (json-parse-value c) items)
135 (jc-skip-ws c)
136 (let ((ch (jc-next c)))
137 (cond ((eql ch #\,) nil)
138 ((eql ch #\]) (return (nreverse items)))
139 (t (error 'json-error :message "expected , or ] in array"))))))))
140
141 (defun json-parse-string (c)
142 (unless (eql (jc-next c) #\")
143 (error 'json-error :message "expected a string"))
144 (let ((out (make-string-output-stream)))
145 (loop for ch = (jc-next c) do
146 (cond ((null ch) (error 'json-error :message "unterminated string"))
147 ((char= ch #\") (return))
148 ((char= ch #\\)
149 (let ((esc (jc-next c)))
150 (case esc
151 (#\" (write-char #\" out))
152 (#\\ (write-char #\\ out))
153 (#\/ (write-char #\/ out))
154 (#\b (write-char #\Backspace out))
155 (#\f (write-char #\Page out))
156 (#\n (write-char #\Newline out))
157 (#\r (write-char #\Return out))
158 (#\t (write-char #\Tab out))
159 (#\u (write-char (code-char (json-parse-hex c 4)) out))
160 (t (error 'json-error :message "bad string escape")))))
161 (t (write-char ch out))))
162 (get-output-stream-string out)))
163
164 (defun json-parse-hex (c n)
165 (let ((val 0))
166 (dotimes (i n val)
167 (let ((d (digit-char-p (jc-next c) 16)))
168 (unless d (error 'json-error :message "bad \\u escape"))
169 (setf val (+ (* val 16) d))))))
170
171 (defun json-parse-number (c)
172 (let ((start (json-cursor-pos c)))
173 (when (eql (jc-peek c) #\-) (jc-next c))
174 (loop for ch = (jc-peek c)
175 while (and ch (or (digit-char-p ch) (member ch '(#\. #\e #\E #\+ #\-))))
176 do (jc-next c))
177 (let ((token (subseq (json-cursor-string c) start (json-cursor-pos c))))
178 (if (find-if (lambda (ch) (member ch '(#\. #\e #\E))) token)
179 (let ((*read-default-float-format* 'double-float)
180 (*read-eval* nil))
181 (read-from-string token))
182 (parse-integer token)))))
183
184 (defun json-get (object key &optional default)
185 "Look up KEY (a string) in an object alist produced by JSON-PARSE."
186 (let ((pair (and (listp object) (assoc key object :test #'string=))))
187 (if pair (cdr pair) default)))
The reader is a hand-written recursive descent parser over a small cursor
struct. json-parse-value looks at the first character and dispatches: a brace
starts an object, a bracket an array, a quote a string, a digit or minus a
number, and the letters t, f, or n a literal. Each parser consumes its
own closing token and leaves the cursor ready for the next value.
json-get is the accessor the rest of the code uses. Give it a parsed object
and a string key and it returns the value or a default. The neural layer calls
it to reach into the model’s reply.
Two details keep the code safe and correct. The number parser binds
*read-eval* to nil before it calls read-from-string on a float token, so
a crafted number cannot run code. And the writer’s final clause prints any
value it does not recognize as a JSON string through princ-to-string, so a
keyword or symbol that slips in becomes a quoted string rather than an error.
The neural fallback layer
This layer turns a missing fact into a question for a language model. It talks
to a local Ollama daemon over HTTP, sends a strict prompt that demands JSON,
and converts the reply into a keyword the graph can store. The same layer reads
free text into triples for the :ingest command.
The engine keeps no hard dependency on an HTTP library. If Dexador is loaded it uses that. Otherwise, on LispWorks, it opens a raw socket and writes the HTTP request by hand. So the neural layer works on a stock LispWorks image with nothing added.
The inference prompt asks the model for one object and constrains the reply to
JSON of the form {"result": "value"}. The extraction prompt asks for a list
of triples as {"triples": [{"subject": "..", "predicate": "..", "object": ".."}]}.
Here is the complete src/neural.lisp.
1 ;;;; neural.lisp --- The neural integration layer (local Ollama daemon).
2 ;;;;
3 ;;;; When a symbolic query fails on a ~ predicate, NSK asks the model to infer
4 ;;;; the missing object. The same layer turns free text into triples. HTTP goes
5 ;;;; through dexador when it is loaded, otherwise through a native LispWorks
6 ;;;; socket, so the core keeps no hard dependency on an HTTP library.
7
8 (in-package :nsk)
9
10 (defparameter *ollama-url* "http://localhost:11434"
11 "Base URL of the local Ollama daemon.")
12
13 (defparameter *ollama-model* "qwen3.5:4b"
14 "Model used for inference and text extraction.")
15
16 (defparameter *ollama-timeout* 60
17 "Socket timeout, in seconds, for Ollama requests.")
18
19 (defparameter *inference-system*
20 "You are a graph database inference node. Given a Subject and a Predicate, infer the single most likely Object. Reply ONLY as JSON: {\"result\": \"value\"}."
21 "System prompt that constrains inference output to strict JSON.")
22
23 (defparameter *extraction-system*
24 "You extract knowledge-graph triples from text. Reply ONLY as JSON of the form {\"triples\": [{\"subject\": \"..\", \"predicate\": \"..\", \"object\": \"..\"}]}. Use short lower-case tokens."
25 "System prompt that constrains extraction output to strict JSON.")
26
27 ;;; Term helpers
28
29 (defun term-label (term)
30 "Readable label for a subject or predicate term."
31 (cond ((neural-predicate-p term) (term-label (neural-predicate-name term)))
32 ((keywordp term) (string-downcase (symbol-name term)))
33 ((symbolp term) (string-downcase (symbol-name term)))
34 ((stringp term) term)
35 (t (princ-to-string term))))
36
37 (defun sanitize-to-keyword (string)
38 "Convert an LLM string such as \"Common Lisp\" into the keyword :COMMON-LISP."
39 (let* ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return #\. #\,) string))
40 (clean (substitute #\- #\Space (string-upcase trimmed))))
41 (intern clean :keyword)))
42
43 ;;; HTTP transport
44
45 (defun parse-url (url)
46 "Return (values host port path) for a simple http URL."
47 (let* ((mark (search "://" url))
48 (rest (if mark (subseq url (+ mark 3)) url))
49 (slash (position #\/ rest))
50 (authority (if slash (subseq rest 0 slash) rest))
51 (path (if slash (subseq rest slash) "/"))
52 (colon (position #\: authority))
53 (host (if colon (subseq authority 0 colon) authority))
54 (port (if colon (parse-integer authority :start (1+ colon)) 80)))
55 (values host port path)))
56
57 (defun http-post-json (url body)
58 "POST BODY (a JSON string) to URL and return the response body string."
59 (let ((dex-post (and (find-package :dexador)
60 (find-symbol "POST" :dexador))))
61 (cond
62 (dex-post
63 (funcall dex-post url :content body
64 :headers '(("Content-Type" . "application/json"))))
65 ((and (find-package :comm) (find-symbol "OPEN-TCP-STREAM" :comm))
66 (native-http-post-json url body))
67 (t (error "No HTTP client available; load dexador or run on LispWorks.")))))
68
69 (defun native-http-post-json (url body)
70 "POST using a raw LispWorks TCP socket. Resolved dynamically so this file
71 compiles without the COMM package present."
72 (let ((open-fn (find-symbol "OPEN-TCP-STREAM" :comm))
73 (crlf (coerce (list #\Return #\Linefeed) 'string)))
74 (multiple-value-bind (host port path) (parse-url url)
75 (let ((stream (funcall open-fn host port
76 :read-timeout *ollama-timeout*
77 :element-type 'base-char)))
78 (unless stream (error "Cannot connect to ~a:~a" host port))
79 (unwind-protect
80 (progn
81 (write-string (format nil "POST ~a HTTP/1.1~a" path crlf) stream)
82 (write-string (format nil "Host: ~a:~a~a" host port crlf) stream)
83 (write-string (format nil "Content-Type: application/json~a" crlf) stream)
84 (write-string (format nil "Content-Length: ~a~a" (length body) crlf) stream)
85 (write-string (format nil "Connection: close~a~a" crlf crlf) stream)
86 (write-string body stream)
87 (force-output stream)
88 (read-http-body stream))
89 (close stream))))))
90
91 (defun read-http-body (stream)
92 "Read an HTTP response from STREAM and return only the body."
93 (read-line stream nil "") ; status line
94 (let ((chunked nil) (length nil))
95 (loop for line = (read-line stream nil nil)
96 while line
97 for trimmed = (string-right-trim '(#\Return) line)
98 until (string= trimmed "")
99 do (let ((low (string-downcase trimmed)))
100 (cond ((and (>= (length low) 18)
101 (string= "transfer-encoding:" low :end2 18)
102 (search "chunked" low))
103 (setf chunked t))
104 ((and (>= (length low) 15)
105 (string= "content-length:" low :end2 15))
106 (setf length (parse-integer low :start 15 :junk-allowed t))))))
107 (cond (chunked (read-chunked-body stream))
108 (length (read-n-chars stream length))
109 (t (read-to-eof stream)))))
110
111 (defun read-n-chars (stream n)
112 (let* ((buf (make-string n))
113 (got (read-sequence buf stream)))
114 (subseq buf 0 got)))
115
116 (defun read-to-eof (stream)
117 (with-output-to-string (out)
118 (loop for ch = (read-char stream nil nil)
119 while ch do (write-char ch out))))
120
121 (defun read-chunked-body (stream)
122 (with-output-to-string (out)
123 (loop
124 (let* ((line (string-right-trim '(#\Return) (read-line stream nil "")))
125 (semi (position #\; line))
126 (size (parse-integer line :radix 16
127 :end (or semi (length line))
128 :junk-allowed t)))
129 (when (or (null size) (zerop size)) (return))
130 (write-string (read-n-chars stream size) out)
131 (read-line stream nil ""))))) ; trailing CRLF after each chunk
132
133 ;;; Ollama calls
134
135 (defun ollama-generate (prompt system)
136 "Send a /api/generate request and return the model's raw response string."
137 (let* ((payload (json-encode
138 (list :object
139 (cons "model" *ollama-model*)
140 (cons "system" system)
141 (cons "prompt" prompt)
142 (cons "format" "json")
143 (cons "stream" :false))))
144 (raw (http-post-json (format nil "~a/api/generate" *ollama-url*) payload))
145 (outer (json-parse raw)))
146 (json-get outer "response")))
147
148 (defun query-neural-fallback (subject predicate)
149 "Ask the model to infer the object for (SUBJECT PREDICATE). Return a string,
150 or NIL if the daemon is unreachable or gives nothing."
151 (handler-case
152 (let* ((prompt (format nil "Subject: ~a. Predicate: ~a. What is the Object?"
153 (term-label subject) (term-label predicate)))
154 (response (ollama-generate prompt *inference-system*)))
155 (when (and response (stringp response))
156 (let* ((inner (ignore-errors (json-parse response)))
157 (result (and (consp inner) (json-get inner "result"))))
158 (cond ((and result (stringp result) (plusp (length result))) result)
159 ((plusp (length response)) response)
160 (t nil)))))
161 (error (e)
162 (format *error-output* "~&; neural fallback unavailable: ~a~%" e)
163 nil)))
164
165 (defun text->triples (text)
166 "Use the model to parse TEXT into a list of (S P O) keyword triples."
167 (handler-case
168 (let* ((response (ollama-generate text *extraction-system*))
169 (inner (and response (stringp response) (json-parse response)))
170 (rows (and (consp inner) (json-get inner "triples"))))
171 (loop for row in rows
172 for s = (json-get row "subject")
173 for p = (json-get row "predicate")
174 for o = (json-get row "object")
175 when (and (stringp s) (stringp p) (stringp o))
176 collect (list (sanitize-to-keyword s)
177 (sanitize-to-keyword p)
178 (sanitize-to-keyword o))))
179 (error (e)
180 (format *error-output* "~&; extraction unavailable: ~a~%" e)
181 nil)))
182
183 (defun ingest-text (text &optional (graph *graph*))
184 "Extract triples from TEXT and add them to GRAPH. Return the triples added."
185 (let ((triples (text->triples text)))
186 (dolist (tr triples triples)
187 (add-triple (first tr) (second tr) (third tr) graph))))
sanitize-to-keyword is the bridge from the model’s world of strings to the
graph’s world of keywords. It trims stray spaces and punctuation, upcases the
text, turns inner spaces into hyphens, and interns the result as a keyword. So
"Common Lisp" becomes :COMMON-LISP and " Tokyo. " becomes :TOKYO. This
is what lets a free-text answer join the same index as your hand-typed facts.
ollama-generate builds the request with the JSON writer, posts it, parses the
envelope, and returns the response field. Because the request sets
"format": "json", the daemon constrains the model to emit JSON, and the
response field holds that JSON as a string. query-neural-fallback parses it
a second time to reach the result value, and falls back to the raw response
if the inner shape is missing.
Both public functions wrap their work in handler-case. If the daemon is down,
or the reply is malformed, they print one short note to the error stream and
return nil. A neural query then simply yields no solutions. The engine never
crashes because a model is offline. The test suite proves this by pointing the
client at a dead port and checking that a ~ query fails cleanly.
The native HTTP code is a compact HTTP/1.1 client. It writes the request line
and headers, sends the body, and reads the response, handling both a
Content-Length body and a chunked transfer encoding. It resolves the LispWorks
comm:open-tcp-stream through find-symbol at call time, so the file compiles
even on an image where that package is absent.
The interactive REPL
The REPL is a read-eval-print loop with the NSK readtable switched on. So you
can type triple patterns and queries next to ordinary Lisp, and both work. A
keyword form at the start acts as a command: :facts, :count, :add, and so
on. Anything else is evaluated as Lisp. Here is the complete src/repl.lisp.
1 ;;;; repl.lisp --- The interactive NSK read-eval-print loop.
2 ;;;;
3 ;;;; The loop reads Common Lisp with the NSK readtable active, so triple and
4 ;;;; variable syntax works alongside normal evaluation. Keyword forms act as
5 ;;;; shell commands (:help, :add, :facts, and so on).
6
7 (in-package :nsk)
8
9 (defparameter *banner*
10 "NSK: Neural-Symbolic Knowledge Graph Engine
11 Type :help for commands, :quit to exit.")
12
13 (defparameter *bare-commands* '(:help :quit :exit :facts :count :save)
14 "Commands typed as a single keyword.")
15
16 (defparameter *list-commands* '(:add :del :ingest)
17 "Commands typed as a list whose head is a keyword.")
18
19 (defun repl-command-p (form)
20 (or (and (keywordp form) (member form *bare-commands*))
21 (and (consp form) (keywordp (car form)) (member (car form) *list-commands*))))
22
23 (defun print-help ()
24 ;; WRITE-STRING, not FORMAT: the help text shows ~ syntax literally, and FORMAT
25 ;; would read those tildes as directives.
26 (fresh-line)
27 (write-string "Commands:
28 :help show this help
29 :facts list every triple
30 :count show the triple count
31 :add s p o add a triple, e.g. (:add :mark :wrote :nsk)
32 :del s p o remove a triple
33 :ingest \"text\" extract triples from text with the model
34 :save flush the log to disk
35 :quit leave the REPL
36
37 Queries use the NSK syntax:
38 [?who :wrote :nsk] one pattern
39 (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
40 [:mark ~:codes-in ?lang] ~ falls back to the model
41 "))
42
43 (defun run-repl-command (form)
44 "Run a command FORM. Return :QUIT to leave the loop, otherwise NIL."
45 (let ((cmd (if (consp form) (car form) form))
46 (args (if (consp form) (cdr form) nil)))
47 (case cmd
48 ((:quit :exit) :quit)
49 (:help (print-help) nil)
50 (:count (format t "~&~d triples~%" (triple-count)) nil)
51 (:save (when *graph* (finish-output (graph-log-stream *graph*)))
52 (format t "~&saved.~%") nil)
53 (:facts
54 (dolist (tr (all-triples)) (format t "~& ~{~a~^ ~}~%" tr))
55 (format t "~&(~d triples)~%" (triple-count)) nil)
56 (:add (destructuring-bind (s p o) args
57 (add-triple s p o)
58 (format t "~&added ~a ~a ~a~%" s p o)) nil)
59 (:del (destructuring-bind (s p o) args
60 (remove-triple s p o)
61 (format t "~&removed ~a ~a ~a~%" s p o)) nil)
62 (:ingest (destructuring-bind (text) args
63 (let ((added (ingest-text text)))
64 (format t "~&ingested ~d triple~:p~%" (length added)))) nil)
65 (t (format t "~&unknown command: ~a~%" cmd) nil))))
66
67 (defun repl-print (value)
68 (if (query-result-p value)
69 (format t "~&~a~%" value)
70 (format t "~&=> ~s~%" value)))
71
72 (defun repl (&optional (graph *graph*))
73 "Start the interactive loop against GRAPH (or *GRAPH*)."
74 (let ((*graph* (or graph *graph* (make-graph)))
75 (*readtable* *nsk-readtable*)
76 (*package* (find-package :nsk)))
77 (format t "~&~a~%" *banner*)
78 (loop
79 (format t "~&nsk> ")
80 (finish-output)
81 (let ((form (handler-case (read *standard-input* nil :eof)
82 (end-of-file () :eof)
83 (error (e)
84 (format t "~&; read error: ~a~%" e)
85 (clear-input)
86 :skip))))
87 (cond
88 ((eq form :eof) (return))
89 ((eq form :skip) nil)
90 ((repl-command-p form)
91 (when (eq :quit (run-repl-command form)) (return)))
92 (t (handler-case (repl-print (eval form))
93 (error (e) (format t "~&; error: ~a~%" e)))))))
94 (format t "~&Bye.~%")))
The loop binds three variables for its duration. *graph* is the active graph,
*readtable* is the NSK readtable, and *package* is the nsk package. That
last binding means :add and friends read as keywords in the right package and
your bare symbols resolve to the engine’s names.
repl-print decides how to show a value. A query-result prints through its
own printer, so you see who=:MARK. Anything else prints after a => arrow, so
(+ 2 3) shows => 5. This is the seam that lets one prompt serve both queries
and plain Lisp.
Two handler-case forms keep the loop alive. A read error clears the input and
skips to the next prompt. An evaluation error prints a note and returns to the
prompt. A typo or a broken query annoys you for one line; it never drops you out
of the session.
print-help uses write-string rather than format on purpose. The help text
shows the literal ~ of the neural syntax, and format would read those
tildes as directives. The comment in the code says as much.
One caveat matters for persistence. (repl) with no graph makes a fresh
in-memory graph with no log, so a session started that way does not save. To
persist you open a store first, which the standalone binary does for you. The
running section shows both paths.
The optional REST server
The --serve flag turns NSK into a small web service. It exposes POST /query
for pattern queries and GET /health for status. The server uses Hunchentoot,
but only through find-symbol at call time, and it loads the library on demand
through Quicklisp. So this file compiles and loads on a bare image, and the
dependency appears only when you actually start the server. Here is the complete
src/server.lisp.
1 ;;;; server.lisp --- Optional REST server (Hunchentoot) for the --serve flag.
2 ;;;;
3 ;;;; Hunchentoot is referenced only through FIND-SYMBOL at call time, so this
4 ;;;; file compiles and loads under a bare image. START-SERVER loads the library
5 ;;;; on demand through Quicklisp.
6
7 (in-package :nsk)
8
9 (defvar *acceptor* nil "The running Hunchentoot acceptor, if any.")
10
11 (defun ensure-hunchentoot ()
12 "Make sure the HUNCHENTOOT package is loaded; load it via Quicklisp if not."
13 (unless (find-package :hunchentoot)
14 (let ((quickload (and (find-package :ql) (find-symbol "QUICKLOAD" :ql))))
15 (unless quickload
16 (error "Quicklisp is not available to load hunchentoot."))
17 (funcall quickload :hunchentoot :silent t)))
18 (or (find-package :hunchentoot)
19 (error "Could not load hunchentoot.")))
20
21 (defun hsym (name)
22 "Resolve an exported HUNCHENTOOT symbol by NAME at call time."
23 (or (find-symbol (string name) :hunchentoot)
24 (error "hunchentoot symbol ~a not found" name)))
25
26 (defun set-content-type-json ()
27 (funcall (fdefinition (list 'setf (hsym "CONTENT-TYPE*"))) "application/json"))
28
29 ;;; Request fields map to graph terms: "?x" is a variable, null means "any",
30 ;;; and any other string becomes a keyword.
31
32 (defun field->term (value role)
33 (cond ((or (null value) (eq value :null)) (logic-var role))
34 ((and (stringp value) (plusp (length value)) (char= (char value 0) #\?))
35 (logic-var (intern (string-upcase (subseq value 1)) :keyword)))
36 ((stringp value) (sanitize-to-keyword value))
37 (t value)))
38
39 (defun term->json (term)
40 (cond ((keywordp term) (string-downcase (symbol-name term)))
41 ((stringp term) term)
42 ((logic-var-p term) (format nil "?~a" (logic-var-name term)))
43 (t (princ-to-string term))))
44
45 (defun solution->json (solution)
46 (cons :object
47 (mapcar (lambda (binding)
48 (cons (string-downcase (symbol-name (car binding)))
49 (term->json (cdr binding))))
50 solution)))
51
52 (defun handle-query ()
53 "POST /query with a JSON body {\"subject\":..,\"predicate\":..,\"object\":..}.
54 Null or \"?name\" fields are variables. Returns the matching solutions."
55 (set-content-type-json)
56 (let* ((raw (funcall (hsym "RAW-POST-DATA") :force-text t))
57 (request (and raw (ignore-errors (json-parse raw))))
58 (s (field->term (json-get request "subject") :subject))
59 (p (field->term (json-get request "predicate") :predicate))
60 (o (field->term (json-get request "object") :object))
61 (sols (solutions (match-triple s p o))))
62 (json-encode
63 (list :object
64 (cons "count" (length sols))
65 (cons "results" (cons :array (mapcar #'solution->json sols)))))))
66
67 (defun handle-health ()
68 "GET /health returns basic engine status."
69 (set-content-type-json)
70 (json-encode (list :object
71 (cons "status" "ok")
72 (cons "triples" (triple-count))
73 (cons "model" *ollama-model*))))
74
75 (defun start-server (&optional (port 8800))
76 "Load Hunchentoot if needed and start serving /query and /health on PORT."
77 (ensure-hunchentoot)
78 (when *acceptor* (stop-server))
79 (let ((table (find-symbol "*DISPATCH-TABLE*" :hunchentoot))
80 (prefix (hsym "CREATE-PREFIX-DISPATCHER")))
81 (set table (list (funcall prefix "/query" 'handle-query)
82 (funcall prefix "/health" 'handle-health))))
83 (setf *acceptor* (make-instance (hsym "EASY-ACCEPTOR") :port port))
84 (funcall (hsym "START") *acceptor*)
85 *acceptor*)
86
87 (defun stop-server ()
88 "Stop the running acceptor, if any."
89 (when *acceptor*
90 (funcall (hsym "STOP") *acceptor*)
91 (setf *acceptor* nil))
92 t)
field->term is the rule that maps a JSON request field to a query term. A
null field, or a missing one, becomes a fresh variable named for its role, so
the server treats it as “any value”. A string that starts with ? becomes a
variable too. Any other string becomes a keyword through the same sanitizer the
neural layer uses. So a request naming a concrete subject and predicate with a
null object asks “for this subject and predicate, what objects are stored?”.
handle-query reads the body, builds the three terms, runs a single
match-triple, and encodes the solutions. Each result object lists only the
fields that were variables, since those are the ones the query bound. A request
with a null object returns objects like {"object": "nsk"}, one per match.
handle-health reports the live triple count and the configured model name,
which is a cheap way for a caller to confirm the service is up and see how many
facts it holds.
Command-line entry point and builds
main ties the pieces together for a standalone program. It parses flags,
opens the store so the session persists, and then either starts the server or
drops into the REPL. Here is the complete src/main.lisp.
1 ;;;; main.lisp --- Command-line entry point and argument parsing.
2
3 (in-package :nsk)
4
5 (defparameter *version* "1.0.0")
6
7 (defun version ()
8 (format nil "NSK ~a" *version*))
9
10 (defun quit-lisp (&optional (code 0))
11 #+lispworks (funcall (find-symbol "QUIT" :lispworks) :status code)
12 #+sbcl (funcall (find-symbol "EXIT" :sb-ext) :code code)
13 #-(or lispworks sbcl) (progn code (values)))
14
15 (defun command-line-args ()
16 "Return the user-supplied command-line arguments as strings."
17 #+lispworks
18 (let ((sym (find-symbol "*LINE-ARGUMENTS-LIST*" :system)))
19 (if (and sym (boundp sym)) (rest (symbol-value sym)) nil))
20 #-lispworks
21 (let ((uiop-fn (and (find-package :uiop)
22 (find-symbol "COMMAND-LINE-ARGUMENTS" :uiop))))
23 (if uiop-fn (funcall uiop-fn) nil)))
24
25 (defun flag-present-p (flag args)
26 (member flag args :test #'string=))
27
28 (defun flag-value (flag args &optional default)
29 (let ((pos (position flag args :test #'string=)))
30 (if (and pos (< (1+ pos) (length args)))
31 (nth (1+ pos) args)
32 default)))
33
34 (defun print-usage ()
35 (format t "~&~a
36
37 Usage: nsk [options]
38
39 (no options) start the interactive REPL
40 --serve start the REST server instead of the REPL
41 --port N server port (default 8800)
42 --db PATH transaction log path (default nsk-graph.log)
43 --help show this message
44
45 Wrap the REPL with rlwrap for history and line editing:
46 rlwrap nsk
47 " (version)))
48
49 (defun main ()
50 "Program entry point. Parse flags, then serve or drop into the REPL."
51 (let* ((args (command-line-args))
52 (log (or (flag-value "--db" args) (namestring *log-path*)))
53 (*log-path* (pathname log)))
54 (when (flag-present-p "--help" args)
55 (print-usage)
56 (quit-lisp 0))
57 (handler-case
58 (progn
59 (setf *graph* (open-store *log-path*))
60 (cond
61 ((flag-present-p "--serve" args)
62 (let ((port (parse-integer (or (flag-value "--port" args) "8800"))))
63 (start-server port)
64 (format t "~&~a serving on http://localhost:~a (Ctrl-C to stop)~%"
65 (version) port)
66 (loop (sleep 3600))))
67 (t (repl))))
68 (error (e)
69 (format *error-output* "~&fatal: ~a~%" e)))
70 (close-store *graph*)
71 (quit-lisp 0)))
main reads the log path from --db or falls back to the default, then calls
open-store, which replays any existing log and keeps the file open for
appending. This is the difference between the binary and a bare (repl): the
binary always runs against a real store, so every add and delete lands on disk.
When the loop ends, main closes the store and exits with status zero.
The #+lispworks and #+sbcl reader conditionals let one file target both
compilers. Argument access, quitting, and the build step each have two
spellings, chosen at read time.
Two files support loading and building. load.lisp loads the sources into a
running image with no ASDF cache, which is how you develop.
1 ;;;; load.lisp --- Load NSK into a running lw image for development.
2 ;;;;
3 ;;;; Usage:
4 ;;;; echo '(progn (load "load.lisp") (nsk:repl))' | lw
5 ;;;;
6 ;;;; This loads the source files directly (compiling in memory), which needs no
7 ;;;; ASDF cache on disk. For a production build see nsk.asd and build.lisp.
8
9 (in-package :cl-user)
10
11 (defparameter *nsk-source-files*
12 '("packages" "json" "unify" "store" "reader" "neural" "query" "repl" "server" "main"))
13
14 (let ((src (merge-pathnames
15 "src/"
16 (make-pathname :name nil :type nil
17 :defaults (or *load-truename* *load-pathname*)))))
18 ;; One compilation unit defers undefined-function reports to the end, so a
19 ;; forward reference between these files stays quiet. A missing function still
20 ;; warns. This keeps SBCL as quiet as LispWorks on load.
21 (with-compilation-unit (:override t)
22 (dolist (name *nsk-source-files*)
23 (load (merge-pathnames (concatenate 'string name ".lisp") src)))))
24
25 (format t "~&NSK loaded. Try (nsk:repl), or load tests/tests.lisp to run tests.~%")
The load order matters and matches the dependency stack: packages first, then
json and unify, then store, reader, neural, query, and finally the
repl, server, and main. The whole set loads inside one
with-compilation-unit so a forward reference between files, for example the
reader mentioning match-triple before query defines it, does not warn.
build.lisp writes the standalone nsk binary. It loads the sources, then
calls the compiler’s delivery step.
1 ;;;; build.lisp --- Build the standalone `nsk` executable.
2 ;;;;
3 ;;;; LispWorks:
4 ;;;; lw -build build.lisp
5 ;;;; (produces ./nsk; needs a LispWorks with delivery)
6 ;;;;
7 ;;;; SBCL:
8 ;;;; sbcl --script build.lisp
9 ;;;; (produces ./nsk via save-lisp-and-die)
10
11 (in-package :cl-user)
12
13 (load (merge-pathnames "load.lisp"
14 (or *load-truename* *load-pathname*)))
15
16 #+lispworks
17 (progn
18 ;; DELIVER writes a standalone console application. :multiprocessing is on so
19 ;; hunchentoot can run under --serve.
20 (funcall (find-symbol "DELIVER" :lispworks)
21 'nsk:main "nsk" 0
22 :console t
23 :multiprocessing t
24 :keep-symbols t))
25
26 #+sbcl
27 (sb-ext:save-lisp-and-die "nsk"
28 :toplevel #'nsk:main
29 :executable t
30 :compression t)
For a Quicklisp or ASDF build there is also nsk.asd, which lists the same
files in the same serial order. Any of the three routes, load.lisp,
build.lisp, or ql:quickload, produces the same engine.
Running NSK
A development session
Start LispWorks in the project directory, load the engine, and enter the REPL. Output from the compiler is trimmed here for clarity.
1 $ lw
2 CL-USER 1 > (load "load.lisp")
3 NSK loaded. Try (nsk:repl), or load tests/tests.lisp to run tests.
4 CL-USER 2 > (nsk:repl)
5 NSK: Neural-Symbolic Knowledge Graph Engine
6 Type :help for commands, :quit to exit.
7 nsk> (:add :mark :wrote :nsk)
8 added MARK WROTE NSK
9 nsk> (:add :jane :wrote :book)
10 added JANE WROTE BOOK
11 nsk> (:add :mark :codes-in :lisp)
12 added MARK CODES-IN LISP
13 nsk> :facts
14 MARK WROTE NSK
15 JANE WROTE BOOK
16 MARK CODES-IN LISP
17 (3 triples)
18 nsk> [?who :wrote :nsk]
19 who=:MARK
20 nsk> (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
21 a=:MARK
22 nsk> (ask () [:jane :wrote :book])
23 yes
24 nsk> [?who :wrote :manual]
25 #<no solutions>
26 nsk> (+ 2 3)
27 => 5
28 nsk> :quit
29 Bye.
A neural fallback
With the Ollama daemon running and the qwen3.5:4b model pulled, a ~
predicate can answer a fact you never stored. The graph holds nothing about
Japan, yet the query returns Tokyo:
1 nsk> [:japan ~:capital ?city]
2 city=:TOKYO
If the daemon is not running, the same query reports the outage and returns no solutions rather than failing:
1 nsk> [:japan ~:capital ?city]
2 ; neural fallback unavailable: Cannot connect to localhost:11434
3 #<no solutions>
The extraction path reads free text into triples. This too needs the daemon:
1 nsk> (:ingest "Ada Lovelace wrote the first program.")
2 ingested 1 triple
3 nsk> :facts
4 MARK WROTE NSK
5 JANE WROTE BOOK
6 MARK CODES-IN LISP
7 ADA-LOVELACE WROTE FIRST-PROGRAM
8 (4 triples)
Persistence and the REST server
A development REPL started with (nsk:repl) runs in memory and does not save.
The standalone binary opens a store, so it persists. Build it, add a fact, and
quit:
1 $ sbcl --script build.lisp # writes ./nsk
2 $ ./nsk
3 NSK: Neural-Symbolic Knowledge Graph Engine
4 Type :help for commands, :quit to exit.
5 nsk> (:add :mark :wrote :nsk)
6 added MARK WROTE NSK
7 nsk> :quit
8 Bye.
The log now holds one line:
1 $ cat nsk-graph.log
2 (:ADD :MARK :WROTE :NSK)
Start the server against that same log and query it over HTTP:
1 $ ./nsk --serve --port 8800
2 NSK 1.0.0 serving on http://localhost:8800 (Ctrl-C to stop)
From another terminal:
1 $ curl -s http://localhost:8800/query \
2 -H 'Content-Type: application/json' \
3 -d '{"subject": null, "predicate": "wrote", "object": "nsk"}'
4 {"count":1,"results":[{"subject":"mark"}]}
5
6 $ curl -s http://localhost:8800/health
7 {"status":"ok","triples":1,"model":"qwen3.5:4b"}
Interpreting the results
Each line of output above ties back to the theory.
who=:MARK is a symbolic answer. The pattern [?who :wrote :nsk] unified
against every stored triple. Only (:mark :wrote :nsk) matched, binding ?who
to :mark. Jane wrote a book, not NSK, so she did not appear. The engine
returned exactly what you stored, no more.
a=:MARK from the ask join shows the conjunction at work. The first clause
found two authors, Mark and Jane. The second clause, [?a :codes-in :lisp],
kept only the binding where that same author also codes in Lisp. Mark survived,
Jane did not. The shared variable ?a is what links the two clauses; prove
carried its binding from the first clause into the second.
yes answered a query with no result variables, (ask () [:jane :wrote :book]).
There was nothing to report back, only a fact to confirm, so the printer says
yes. Had the fact been absent, you would have seen #<no solutions>, which is
what [?who :wrote :manual] returned. In symbolic terms, absence of proof is a
plain “no”.
=> 5 is a reminder that the prompt is a full Lisp REPL. The query syntax sits
beside ordinary evaluation, not on top of it.
city=:TOKYO is the neural-symbolic idea in one line. The symbolic search for
(:japan :capital ?city) found nothing, because no such triple exists. The ~
mark permitted a fallback, so the engine asked the model, which answered
“Tokyo”, and sanitize-to-keyword turned that into :TOKYO. The value now
looks exactly like a stored fact and could join further queries. The line
between the two kinds of knowledge is the tilde, and nothing else.
The offline case, ; neural fallback unavailable ... followed by
#<no solutions>, shows the safety property. A missing model degrades the
neural predicate to an ordinary one that happens to have no match. The engine
keeps running.
The REST response {"count":1,"results":[{"subject":"mark"}]} deserves a close
read. The request set the predicate and object to concrete values and left the
subject null. The server read that null as a variable, so the one bound field
in each result is subject. The concrete fields do not repeat in the results,
because a query returns the values of its unknowns. The /health reply confirms
the server loaded the persisted log: one triple, matching the single line in
nsk-graph.log.
Testing the engine
The suite in tests/tests.lisp exercises every layer without a live daemon. It
uses a tiny check macro that counts passes and failures and prints one line
per assertion. Run it with:
1 $ echo '(progn (load "load.lisp") (load "tests/tests.lisp"))' | lw
The output walks through the sections and ends with a tally:
1 == unification ==
2 ok (EQ +FAIL+ (UNIFY :A :B))
3 ok (NOT (EQ +FAIL+ (UNIFY :A :A)))
4 ok variable binds to value
5 ok logic vars intern by name
6 ok an already-bound var will not rebind
7 ok resolve follows a binding
8
9 == store ==
10 ok duplicates are ignored
11 ok subject index works
12 ok object index works
13 ok insertion order kept
14 ok remove updates the count
15 ok remove clears the index
16
17 ... sections for persistence, reader macros, query engine,
18 json, server helpers, neural fallback, and the REPL ...
19
20 == neural fallback (no daemon) ==
21 ok a ~ query fails cleanly when Ollama is down
22
23 == repl (scripted) ==
24 ok repl :add reports the addition
25 ok repl runs a single pattern
26 ok repl runs an ask join
27 ok repl :count is correct
28 ok repl mutated the graph
29
30 ==================================
31 NSK tests: 41 passed, 0 failed
32 ==================================
The persistence section is worth calling out. It opens a store, adds two triples, removes one, and closes the store. Then it opens the same log again in a fresh graph and checks the replay: the right count, the surviving triple, and the fact that the deletion stuck. This is the durability claim, proved against a real file on disk.
The neural section points the client at a dead port and confirms a ~ query
returns no solutions without error. So you can run the whole suite offline and
still cover the fallback path.
Wrap up
NSK is small, but it shows a complete idea. Symbolic reasoning gives you exact, fast answers over the facts you recorded. A language model gives you plausible answers over the far larger set of facts it read during training. Put the model behind the symbolic search, gated by a single mark in the syntax, and you get an engine that prefers what it knows and reaches for a guess only when it must.
Along the way the code showed several Common Lisp techniques worth keeping.
Reader macros gave the query language a clean surface with no parser. A tagged
s-expression log gave durability in a few lines, with *read-eval* disabled for
safety. find-symbol at call time let the core stay free of Hunchentoot and
Dexador while still using them when present. And unification, the oldest idea
here, turned pattern matching into a dozen lines that the rest of the engine
builds on.
The design leaves clear room to grow. There is no predicate index, no negation, no way to store a neural answer back into the graph, and no confidence score on a guess. The practice problems take up several of these.
Optional practice problems
These build on the code in this chapter. Each names the files you will touch. Start with the store and query problems; they need no daemon and the test suite gives you a pattern to copy.
Add a predicate index. Today
candidate-triplesinstore.lispnarrows by subject or object but scans every triple when only the predicate is known, as in[?s :wrote ?o]. Add a third hash table,pso, keyed by predicate. Update%index,%unindex, andcandidate-triplesto use it. Add a test that stores many triples under different predicates and confirms the new index returns a short candidate list.Cache neural answers. When
neural-matchinquery.lispgets an answer from the model, add it to the graph as an ordinary triple so the next query for the same subject and predicate is a symbolic hit and costs no HTTP call. Decide whether the cached triple should use the neural predicate’s bare name. Confirm with a scripted test that a second identical~query does not call the model.Count solutions. Add a REPL command
(:query-count pattern)that prints how many solutions a pattern has rather than the bindings. Reusesolutionsandlength. For a stretch, add anask-level aggregate that returns the number of distinct rows.Negation as failure. Add a clause form
(not [s p o])to theaskmacro inquery.lispthat succeeds only when the inner pattern has no solutions. Thread the current environment in so the negated pattern sees existing bindings. Note the ordering rule: a negated clause should run after the variables it mentions are bound.A facts endpoint. Add
GET /factstoserver.lispthat returns every triple as a JSON array of three-element arrays, for example[["mark","wrote","nsk"]]. Reuseall-triplesandterm->json, and add a dispatcher entry instart-server.Extend unification to lists and numbers. The engine treats terms as atoms. Store a triple whose object is a list, such as
(:mark :knows (:lisp :scheme)), and confirm that a pattern with a variable in that position unifies against the list. Theunifyfunction already recurses into conses, so the work is mostly in the reader and the printer. Add tests that bind a variable to a list and resolve it back.A confidence field on neural answers. Change the inference prompt in
neural.lispto ask for{"result": "value", "confidence": 0.0}and parse the number withjson-get. Drop any answer below a threshold you choose. Decide how the REPL should show a low-confidence result: skip it, or mark it.Round-trip a saved graph. Write a script that opens a store, ingests a paragraph of text with
ingest-text, closes the store, reopens it, and prints the facts. This checks that model-extracted triples survive a restart the same way hand-typed ones do. Run it twice and confirm the log grows only by the new facts, since duplicates are ignored.