Building a Neural-Symbolic Knowledge Graph Engine in Scala
Artificial intelligence has two main traditions. The symbolic tradition stores knowledge as explicit facts and rules, then reasons over them with logic. It is precise, and it can show every step of an inference. Its weakness is coverage: it knows only what someone has told it. The neural tradition learns patterns from data. A large language model can answer a question it has never seen, yet it cannot prove its answer, and it states a wrong fact with the same confidence as a right one.
A neural-symbolic system joins the two. It keeps a symbolic store for the facts it holds for certain, and it calls a neural model for the gaps. This chapter builds one such system, a knowledge graph engine named NSK. NSK stores facts as triples, answers queries by logical unification, and falls back to a local language model when the symbolic store has no answer.
Dear reader, this engine is a port of a Common Lisp program in my Common Lisp book. The Lisp version leans on reader
macros for a compact query syntax. We recreate that syntax in Scala 3, and along
the way we meet a set of Scala features worth knowing: enums for the
data model, Option for search that can fail, context parameters for ambient
configuration, and a string interpolator that stands in for a reader macro. The
whole core uses the standard library and no outside dependency.
The example code is in the directory source-code/neural-symbolic-graph.
When you later run the example, use the Makefile target:
1 $ make repl
What we are going to build
By the end of the chapter you will have a program that:
- stores subject-predicate-object triples in memory, with two hash indices for fast lookup;
- saves every change to an append-only log and rebuilds its state on restart;
- answers Datalog-style queries by unification, joining several patterns into one result;
- asks a local Ollama model to fill a missing fact when a query marks a predicate as neural;
- runs as an interactive REPL and as a small REST server.
We start with the ideas, then read the code file by file.
Background: three ideas you need first
Knowledge as triples
The oldest and most durable way to store facts is the triple: a subject, a
predicate, and an object. The triple (:mark :wrote :nsk) reads as the sentence
“Mark wrote NSK”. This is the
model behind RDF and behind most knowledge graphs in production today. Connect
enough triples and you have a graph, where the subjects and objects are nodes and
the predicates are labeled edges.
A triple is easy to store, easy to index, and easy to match. NSK writes a
keyword atom with a leading colon, so :mark is the atom named mark. Names
are held in upper case, the way a Common Lisp reader upcases a symbol, so the
atom prints as :MARK.
Querying by unification
A query is a triple with holes in it. We write a hole as a logic variable, a
name with a leading question mark. The pattern [?who :wrote :nsk] asks “who
wrote NSK?”. The pattern is not a fact; it is a request to find every fact that
fits its shape.
To match a pattern against a fact is to find a substitution, a map
from variables to terms, that makes the two triples equal:

For [?who :wrote :nsk] against the fact [:mark :wrote :nsk], the substitution
is
. The algorithm that
finds the most general such
, or reports that none exists, is
unification. It is the engine inside Prolog, inside type inference, and inside
NSK.
A real query has more than one pattern. The query “who wrote NSK and also codes in Lisp?” is a conjunction of two patterns:
1 (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
We solve a conjunction by threading substitutions forward: solve the first
pattern, and for each substitution it yields, carry those bindings into the
second pattern. Written as a recurrence over a list of clauses, with
the substitution so far:

The base case, an empty clause list, returns the current substitution as the one solution. This recurrence maps line for line onto the Scala we will read in the query engine.
The neural fallback
Symbolic matching answers only from stored facts. NSK adds one move. A query may
mark a predicate as neural with a leading tilde, as in [:mark ~:codes-in ?lang]. NSK first tries a plain symbolic match on the bare predicate. If the
store has no answer, and only then, it asks a language model to infer the
missing object. The model returns text like “Common Lisp”; NSK turns that text
into the keyword :COMMON-LISP and binds it to the variable.
This is the neural-symbolic join in one sentence: symbolic first for precision, neural second for coverage.
How the pieces connect
The program is small. Each file holds one idea.
1 project.scala scala-cli build config (Scala version, main class)
2 src/Term.scala terms and triples, the data model
3 src/Unify.scala Norvig-style unification over Option
4 src/Graph.scala the triplestore: indices and the append-only log
5 src/Reader.scala the NSK reader: text to an abstract syntax tree
6 src/Query.scala pattern matching, conjunction, and the neural fallback
7 src/Neural.scala the Ollama client and text-to-triples
8 src/Json.scala a self-contained JSON model, parser, and writer
9 src/Dsl.scala the nsk"..." interpolator and the native Scala DSL
10 src/Repl.scala the interactive read-eval-print loop
11 src/Server.scala the REST server on the JDK HTTP server
12 src/Main.scala the command-line entry point
13 Tests.scala the test suite
Data flows one way for a query. Text enters through the REPL, the interpolator,
or an HTTP request. The reader turns text into a Form. The query engine turns
a Form into a search over the graph. Unification decides each match. If a
neural predicate finds nothing, the neural layer calls the model. The result
prints as a small table or as JSON.
We read the files in that order, so each one builds on the last.
The build file
The project uses scala-cli, which needs no separate build tool. A single
directive file names the Scala version and the entry point.
1 //> using scala 3.8.4
2 //> using mainClass nsk.main
3
4 // NSK: Neural-Symbolic Knowledge Graph engine, ported from Common Lisp.
5 //
6 // The core has no external dependencies. JSON is hand-rolled, the Ollama
7 // client uses the JDK HttpClient, and the REST server uses the JDK's
8 // built-in com.sun.net.httpserver. Build and run with scala-cli:
9 //
10 // scala-cli run . start the REPL
11 // scala-cli run . -- --serve --port 8800 start the REST server
12 // scala-cli run . --main-class nsk.test run the test suite
The //> using lines are scala-cli directives. They pin the compiler to Scala
3.8.4 and set the default main class. Nothing else is needed to compile or run.
The data model: terms and triples
Everything in NSK is a Term or a Triple. A term is one of five things: a
logic variable, a keyword atom, a string, an integer, or a neural wrapper around
another term. The neural wrapper is how a tilde predicate carries its “ask the
model” flag through the engine.
Before the code, here is what each term looks like on the page:
1 ?who a logic variable
2 :mark a keyword atom (stored upper case as MARK)
3 "hello" a string value
4 42 an integer
5 ~:codes-in a neural predicate wrapping the atom :codes-in
A Term is a closed set of five cases, so it is a natural Scala 3 enum. The
compiler then checks that every match covers all five, a guarantee the Lisp
ecase gives only at run time. The type carries three views of itself, one per
output format the engine needs: toString for the readable form that the reader
can parse back, display for a plain form without the colon, and jsonText
for lower-case JSON output. These three mirror the Common Lisp ~S, ~A, and
JSON renderings.
1 package nsk
2
3 /** A term in a triple or a query pattern.
4 *
5 * Keyword names are stored upper-case, the way the Common Lisp reader
6 * upcases `:mark` to `:MARK`. The three `toString`/`display`/`jsonText`
7 * views mirror Common Lisp's `~S`, `~A`, and the JSON rendering.
8 */
9 enum Term:
10 case Var(name: String) // logic variable: ?who
11 case Kw(name: String) // symbolic atom: :mark (name is UPPER-CASE)
12 case Str(value: String) // string literal: "hello"
13 case Num(value: Long) // integer literal: 42
14 case Neural(inner: Term) // neural predicate: ~:codes-in
15
16 /** Readable form (Common Lisp `~S`). Round-trips back through the reader. */
17 override def toString: String = this match
18 case Var(n) => s"?$n"
19 case Kw(n) => s":$n"
20 case Str(s) => "\"" + s + "\""
21 case Num(n) => n.toString
22 case Neural(t) => s"~$t"
23
24 /** Plain form (Common Lisp `~A`): a keyword without its colon. */
25 def display: String = this match
26 case Kw(n) => n
27 case Str(s) => s
28 case Num(n) => n.toString
29 case Var(n) => s"?$n"
30 case Neural(t) => s"~${t.display}"
31
32 /** Lower-case text used for JSON output. */
33 def jsonText: String = this match
34 case Kw(n) => n.toLowerCase
35 case Str(s) => s
36 case Num(n) => n.toString
37 case Var(n) => s"?$n"
38 case Neural(t) => t.jsonText
39
40 object Term:
41 /** True when TERM is a concrete value usable as an index key. */
42 def concrete(t: Term): Boolean = t match
43 case _: Var | _: Neural => false
44 case _ => true
45
46 /** A subject-predicate-object triple. */
47 case class Triple(s: Term, p: Term, o: Term):
48 def toList: List[Term] = List(s, p, o)
49 override def toString: String = s"[$s $p $o]"
Two details pay off later. First, Term.concrete returns true only for terms
that can serve as an index key, so a variable and a neural wrapper are not
concrete. The graph uses this to decide which index to consult. Second, the
toString of a term prints the exact surface syntax the reader reads back. That
round-trip is what lets the log file and the string interpolator reuse one
parser.
Unification
Unification is the central operation of the query engine. It takes two terms and an
environment, and it returns either a larger environment that makes the terms
equal or a signal that no such environment exists. The environment is a map from
variable to value,
.
Common Lisp represents failure with a special +FAIL+ sentinel, because in Lisp
NIL already means the empty successful environment and cannot double as
failure. Scala has a cleaner tool for “a value or nothing”: Option. Here
Some(env) is success and None is failure. The whole engine then chains with
flatMap and reads as a short pipeline.
1 package nsk
2
3 /** Unification in the Norvig/PAIP style.
4 *
5 * The environment is a map from logic variable to value. Common Lisp uses a
6 * `+FAIL+` sentinel so that `NIL` can stand for the empty successful
7 * environment; Scala models the same idea more directly with `Option`, where
8 * `None` is failure and `Some(env)` is success.
9 */
10 object Unify:
11 type Env = Map[Term.Var, Term]
12
13 val empty: Env = Map.empty
14
15 def unify(x: Term, y: Term, env: Option[Env] = Some(empty)): Option[Env] =
16 env.flatMap: e =>
17 (x, y) match
18 case _ if x == y => Some(e)
19 case (v: Term.Var, _) => unifyVar(v, y, e)
20 case (_, v: Term.Var) => unifyVar(v, x, e)
21 case _ => None
22
23 private def unifyVar(v: Term.Var, x: Term, e: Env): Option[Env] =
24 e.get(v) match
25 case Some(bound) => unify(bound, x, Some(e))
26 case None => Some(e + (v -> x))
27
28 /** Unify a pattern triple against a stored triple, position by position. */
29 def unifyTriple(pat: Triple, tr: Triple, env: Env): Option[Env] =
30 unify(pat.s, tr.s, Some(env))
31 .flatMap(e => unify(pat.p, tr.p, Some(e)))
32 .flatMap(e => unify(pat.o, tr.o, Some(e)))
33
34 /** Replace bound variables in X with their values, recursively. */
35 def resolve(x: Term, e: Env): Term = x match
36 case v: Term.Var => e.get(v).map(resolve(_, e)).getOrElse(v)
37 case _ => x
Read unify case by case. If the two terms are already equal, the environment
is unchanged. If either term is a variable, unifyVar handles it: a variable
already bound must unify with its stored value, and an unbound variable takes the
other term as its new value. Anything else fails. unifyTriple runs unify
three times, once per position, and threads the growing environment through with
flatMap, so a failure at any position stops the chain. resolve walks a chain
of bindings to the final value, which is how we read an answer out of an
environment.
The whole file is under forty lines. That economy comes straight from using
Option instead of a sentinel: the failure case never needs a name, because
None and flatMap carry it.
The triplestore
The graph holds triples and answers “which triples could match this pattern?”.
It keeps two hash indices, one keyed by subject and one keyed by object, so a
query with a known subject never scans the whole store. A concrete subject makes
subject lookup average
, and we then unify against only the triples that
share that subject rather than all
of them.
The graph is also durable. Every add and every delete appends one line to a log
file. On startup NSK replays the log to rebuild state. The log uses the NSK
surface syntax, so the same reader that serves the REPL replays it. There is no
eval, so a log file cannot run code, which makes replay safe by construction.
Here is a sample log, the input the replay code consumes:
1 (:add :mark :wrote :nsk)
2 (:add :jane :wrote :book)
3 (:add :mark :codes-in :lisp)
4 (:del :jane :wrote :book)
Replaying those four lines yields a graph with two live triples: Jane’s line was added and then deleted. The code below keeps the indices and the log in step and provides the replay.
1 package nsk
2
3 import java.io.{BufferedReader, File, FileReader, FileWriter, PrintWriter}
4 import scala.collection.mutable
5
6 /** The triplestore: two hash indices plus an append-only log.
7 *
8 * Triples index by subject and by object for fast pattern narrowing. State
9 * survives restarts through a log of forms that is replayed on startup. The
10 * log uses the NSK surface syntax, so [[Reader]] replays it and no `eval` is
11 * involved, which makes replay safe by construction.
12 */
13 final class Graph private (val logPath: Option[String]):
14 private val spo = mutable.Map.empty[Term, List[Triple]] // subject -> triples
15 private val osp = mutable.Map.empty[Term, List[Triple]] // object -> triples
16 private var live = List.empty[Triple] // every live triple, newest first
17 private var n = 0
18 private var log = Option.empty[PrintWriter]
19
20 def count: Int = n
21 def all: List[Triple] = live.reverse // insertion order
22 def spoIndex(k: Term): List[Triple] = spo.getOrElse(k, Nil)
23 def ospIndex(k: Term): List[Triple] = osp.getOrElse(k, Nil)
24
25 private def index(tr: Triple): Unit =
26 spo(tr.s) = tr :: spoIndex(tr.s)
27 osp(tr.o) = tr :: ospIndex(tr.o)
28 live = tr :: live
29 n += 1
30
31 private def unindex(tr: Triple): Unit =
32 spo(tr.s) = spoIndex(tr.s).filterNot(_ == tr)
33 osp(tr.o) = ospIndex(tr.o).filterNot(_ == tr)
34 live = live.filterNot(_ == tr)
35 n -= 1
36
37 def present(tr: Triple): Boolean = spoIndex(tr.s).contains(tr)
38
39 private def writeLog(op: String, tr: Triple): Unit =
40 log.foreach { w => w.println(Graph.entry(op, tr)); w.flush() }
41
42 /** Add a triple and append it to the log. Duplicates are ignored. */
43 def add(s: Term, p: Term, o: Term): Triple =
44 val tr = Triple(s, p, o)
45 if !present(tr) then { index(tr); writeLog("add", tr) }
46 tr
47
48 /** Remove a triple and record the deletion in the log. */
49 def remove(s: Term, p: Term, o: Term): Triple =
50 val tr = Triple(s, p, o)
51 if present(tr) then { unindex(tr); writeLog("del", tr) }
52 tr
53
54 /** Triples that could match PATTERN, narrowed by the indices. */
55 def candidates(pat: Triple): List[Triple] =
56 if Term.concrete(pat.s) then spoIndex(pat.s)
57 else if Term.concrete(pat.o) then ospIndex(pat.o)
58 else live
59
60 private def applyEntry(form: Form): Unit = form match
61 case Form.Cmd("add", List(s, p, o)) =>
62 val tr = Triple(s, p, o); if !present(tr) then index(tr)
63 case Form.Cmd("del", List(s, p, o)) =>
64 val tr = Triple(s, p, o); if present(tr) then unindex(tr)
65 case _ => ()
66
67 private def replay(path: String): Unit =
68 val f = File(path)
69 if f.exists then
70 val in = BufferedReader(FileReader(f))
71 try
72 var line = in.readLine()
73 while line != null do
74 val t = line.trim
75 if t.nonEmpty then applyEntry(Reader.readForm(t))
76 line = in.readLine()
77 finally in.close()
78
79 private def openLog(path: String): Unit =
80 log = Some(PrintWriter(FileWriter(path, /* append = */ true)))
81
82 def flush(): Unit = log.foreach(_.flush())
83
84 def close(): Unit =
85 log.foreach { w => w.flush(); w.close() }
86 log = None
87
88 object Graph:
89 /** A fresh in-memory graph with no backing log. */
90 def inMemory: Graph = new Graph(None)
91
92 /** Open (or create) the store at PATH, replay its log, keep it open. */
93 def open(path: String): Graph =
94 val g = new Graph(Some(path))
95 g.replay(path)
96 g.openLog(path)
97 g
98
99 private def entry(op: String, tr: Triple): String =
100 s"(:$op ${source(tr.s)} ${source(tr.p)} ${source(tr.o)})"
101
102 private def source(t: Term): String = t match
103 case Term.Kw(name) => ":" + name.toLowerCase
104 case Term.Str(s) => "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
105 case Term.Num(x) => x.toString
106 case other => other.toString
The key method for queries is candidates. Given a pattern, it picks the
narrowest index it can: the subject index if the subject is concrete, else the
object index if the object is concrete, else the full live list. The query
engine then unifies the pattern against only those candidates. Note the two
constructors on the companion: inMemory makes a graph with no log, which the
tests use, and open replays a log then keeps it open for appends.
The source helper serializes a term for the log. It writes keywords in lower
case with a colon and escapes quotes in strings, which produces the exact syntax
the reader reads back on replay.
The reader
The reader turns text into an abstract syntax tree. In Common Lisp this job belongs to reader macros, small functions the reader runs when it meets a trigger character. Scala has no reader macros, so we write a small recursive descent parser and expose it later through a string interpolator, which is the closest Scala analog.
The reader accepts three kinds of top-level form. Here is a sample of each, the input the parser consumes:
1 (:add :mark :wrote :nsk) a command
2 [?who :wrote :nsk] a single pattern
3 (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp]) a conjunctive query
Inside a form, the reader recognizes the term prefixes from the data model:
?name for a variable, :name for a keyword, ~term for a neural wrapper, a
double quote for a string, and a digit or minus sign for a number. A Form is
another closed set, so it too is an enum.
The parser raises IncompleteInput when the text ends in the middle of a form.
The REPL treats that as “read another line”, the way a Lisp listener waits for a
closing paren. A separate readForms reads as many complete forms as it can and
reports how far it got, which lets the REPL buffer a partial form across lines.
1 package nsk
2
3 import scala.collection.mutable.ListBuffer
4
5 /** A parsed top-level form. */
6 enum Form:
7 case Cmd(name: String, args: List[Term]) // (:add s p o) or a bare :count
8 case Pat(triple: Triple) // [s p o]
9 case Ask(vars: List[Term.Var], clauses: List[Triple]) // (ask (?a) [..] [..])
10
11 /** Thrown when the input runs out in the middle of a form. The REPL treats
12 * this as "read another line", the way a Lisp reader waits for more input. */
13 final class IncompleteInput extends RuntimeException("incomplete input")
14
15 /** Thrown on malformed input. */
16 final class ReadError(msg: String) extends RuntimeException(msg)
17
18 /** The NSK reader.
19 *
20 * This is the Scala answer to the Common Lisp reader macros. It turns the
21 * same surface syntax into an abstract syntax tree:
22 *
23 * ?name -> Term.Var(name)
24 * :name -> Term.Kw(NAME) (upper-cased, as in Common Lisp)
25 * ~term -> Term.Neural(term)
26 * [s p o] -> Form.Pat(Triple(...))
27 * (:op ..) -> Form.Cmd(op, args)
28 * (ask ..) -> Form.Ask(vars, clauses)
29 */
30 object Reader:
31 /** Read exactly one form from S (trailing text is ignored). */
32 def readForm(s: String): Form =
33 val c = Cursor(s)
34 c.form()
35
36 /** Read as many complete forms as possible; return them and the index of
37 * the first unconsumed character (an incomplete trailing form is left). */
38 def readForms(s: String): (List[Form], Int) =
39 val c = Cursor(s)
40 val out = ListBuffer.empty[Form]
41 var more = true
42 while more do
43 c.skipWs()
44 if c.atEnd then more = false
45 else
46 val mark = c.pos
47 try out += c.form()
48 catch
49 case _: IncompleteInput =>
50 c.pos = mark
51 more = false
52 (out.toList, c.pos)
53
54 private final class Cursor(s: String):
55 var pos = 0
56
57 def atEnd: Boolean = pos >= s.length
58 def peek: Char = s(pos)
59
60 def skipWs(): Unit = while pos < s.length && s(pos).isWhitespace do pos += 1
61
62 private def expect(ch: Char): Unit =
63 skipWs()
64 if atEnd then throw IncompleteInput()
65 if s(pos) == ch then pos += 1 else throw ReadError(s"expected '$ch' at $pos")
66
67 private def tokenChars(): String =
68 val start = pos
69 while pos < s.length && !s(pos).isWhitespace && !"()[]".contains(s(pos)) do pos += 1
70 if pos == start then throw ReadError(s"empty token at $pos")
71 s.substring(start, pos)
72
73 /** A single term. */
74 def term(): Term =
75 skipWs()
76 if atEnd then throw IncompleteInput()
77 peek match
78 case '?' => pos += 1; Term.Var(tokenChars())
79 case ':' => pos += 1; Term.Kw(tokenChars().toUpperCase)
80 case '~' => pos += 1; Term.Neural(term())
81 case '"' => Term.Str(stringLiteral())
82 case c if c == '-' || c.isDigit => number()
83 case c => throw ReadError(s"unexpected term char '$c' at $pos")
84
85 private def number(): Term =
86 val start = pos
87 if peek == '-' then pos += 1
88 while pos < s.length && s(pos).isDigit do pos += 1
89 Term.Num(s.substring(start, pos).toLong)
90
91 private def stringLiteral(): String =
92 expect('"')
93 val sb = StringBuilder()
94 var done = false
95 while !done do
96 if atEnd then throw IncompleteInput()
97 val ch = s(pos); pos += 1
98 ch match
99 case '"' => done = true
100 case '\\' =>
101 if atEnd then throw IncompleteInput()
102 val e = s(pos); pos += 1
103 sb.append(e match
104 case 'n' => '\n'
105 case 't' => '\t'
106 case 'r' => '\r'
107 case other => other
108 )
109 case c => sb.append(c)
110 sb.toString
111
112 /** A [s p o] triple pattern. */
113 def triple(): Triple =
114 expect('[')
115 val terms = termsUntil(']')
116 if terms.length != 3 then
117 throw ReadError(s"triple pattern needs exactly three terms, got ${terms.length}")
118 Triple(terms(0), terms(1), terms(2))
119
120 private def termsUntil(close: Char): List[Term] =
121 val out = ListBuffer.empty[Term]
122 var done = false
123 while !done do
124 skipWs()
125 if atEnd then throw IncompleteInput()
126 if peek == close then { pos += 1; done = true }
127 else out += term()
128 out.toList
129
130 private def clausesUntil(close: Char): List[Triple] =
131 val out = ListBuffer.empty[Triple]
132 var done = false
133 while !done do
134 skipWs()
135 if atEnd then throw IncompleteInput()
136 peek match
137 case c if c == close => pos += 1; done = true
138 case '[' => out += triple()
139 case _ => throw ReadError(s"ask clause is not a [triple] at $pos")
140 out.toList
141
142 private def bareWord(): String =
143 skipWs()
144 val start = pos
145 while pos < s.length && !s(pos).isWhitespace && !"()[]".contains(s(pos)) do pos += 1
146 s.substring(start, pos)
147
148 /** A top-level form. */
149 def form(): Form =
150 skipWs()
151 if atEnd then throw IncompleteInput()
152 peek match
153 case '(' => listForm()
154 case '[' => Form.Pat(triple())
155 case ':' => pos += 1; Form.Cmd(tokenChars().toLowerCase, Nil)
156 case c => throw ReadError(s"unexpected char '$c' at $pos")
157
158 private def listForm(): Form =
159 expect('(')
160 skipWs()
161 if atEnd then throw IncompleteInput()
162 if peek == ':' then
163 pos += 1
164 val name = tokenChars().toLowerCase
165 Form.Cmd(name, termsUntil(')'))
166 else
167 bareWord() match
168 case "ask" =>
169 skipWs(); expect('(')
170 val vars = termsUntil(')').collect { case v: Term.Var => v }
171 val clauses = clausesUntil(')')
172 Form.Ask(vars, clauses)
173 case head => throw ReadError(s"unknown list head '$head'")
The term method is the whole surface syntax in one match. It reads the first
character and dispatches: a question mark starts a variable, a colon starts a
keyword upcased to match the Lisp reader, a tilde recursively reads the wrapped
term, a quote reads a string, and a digit or minus reads a number. The
termsUntil and clausesUntil helpers read a run of items up to a closing
bracket. form handles the three top-level shapes: a parenthesized list, a
bracketed pattern, and a bare keyword command such as :count.
The one subtle point is readForms. It reads forms until it hits end of input
or an incomplete form. On IncompleteInput it rewinds the cursor to the start
of the partial form and stops, returning the complete forms and the cursor
position. The REPL keeps the leftover text and prepends the next line to it.
The query engine
Now the pieces combine. The query engine takes a Form, runs the pattern search
over the graph, and applies the neural fallback. This is a direct transcription
of the
recurrence from the background section.
The active graph and the neural configuration arrive as using parameters. This
is the Scala stand-in for the Common Lisp special variables *graph* and
*ollama-url*. In Lisp a caller rebinds a special with let; in Scala a caller
supplies a local given, and the compiler threads it through every call that
declares a matching using clause. The benefit over a global variable is static
safety: a function that needs a graph will not compile without one in scope.
1 package nsk
2
3 import Unify.{Env, empty, unify, unifyTriple, resolve}
4
5 /** The result of a query: one row of `(name, value)` bindings per solution.
6 *
7 * The `toString` view mirrors the Common Lisp `query-result` printer so the
8 * REPL shows a readable table.
9 */
10 case class QueryResult(solutions: List[List[(String, Term)]]):
11 override def toString: String =
12 if solutions.isEmpty then "#<no solutions>"
13 else if solutions == List(Nil) then "yes"
14 else
15 solutions
16 .map(sol => sol.map((k, v) => s"$k=$v").mkString(", "))
17 .mkString("\n")
18
19 /** Pattern matching, conjunction, and the neural fallback.
20 *
21 * The active graph and the neural configuration arrive as `using` parameters.
22 * They are the Scala stand-in for the Common Lisp special variables `*graph*`
23 * and `*ollama-url*`: a caller supplies a local `given` to rebind them, just
24 * as Lisp code rebinds a special with `let`.
25 */
26 object Query:
27
28 /** Replace bound variables in a pattern with their values, for indexing. */
29 private def ground(pat: Triple, env: Env): Triple =
30 def g(t: Term): Term = t match
31 case v: Term.Var => resolve(v, env)
32 case _ => t
33 Triple(g(pat.s), g(pat.p), g(pat.o))
34
35 /** Every environment that satisfies PAT under ENV. A neural predicate first
36 * tries a plain symbolic match on its bare name, then asks the model. */
37 def matchPattern(pat: Triple, env: Env)(using graph: Graph, cfg: NeuralConfig): List[Env] =
38 val neuralp = pat.p.isInstanceOf[Term.Neural]
39 val spat = pat.p match
40 case Term.Neural(inner) => pat.copy(p = inner)
41 case _ => pat
42 val gpat = ground(spat, env)
43 val results = graph.candidates(gpat).flatMap(tr => unifyTriple(spat, tr, env))
44 if results.isEmpty && neuralp then neuralMatch(gpat, spat, env).toList
45 else results
46
47 private def neuralMatch(grounded: Triple, spat: Triple, env: Env)(using
48 cfg: NeuralConfig
49 ): Option[Env] =
50 (grounded.s, spat.o) match
51 case (subject, target: Term.Var) if Term.concrete(subject) =>
52 Neural
53 .queryFallback(subject, grounded.p)
54 .flatMap(answer => unify(target, Neural.sanitizeToKeyword(answer), Some(env)))
55 case _ => None
56
57 /** Prove PATTERNS as a conjunction, threading environments forward. */
58 def prove(patterns: List[Triple], env: Env)(using Graph, NeuralConfig): List[Env] =
59 patterns match
60 case Nil => List(env)
61 case pat :: rest => matchPattern(pat, env).flatMap(e => prove(rest, e))
62
63 def runQuery(patterns: List[Triple], vars: List[Term.Var])(using
64 Graph,
65 NeuralConfig
66 ): QueryResult =
67 val sols = prove(patterns, empty).map(env => vars.map(v => v.name -> resolve(v, env)))
68 QueryResult(sols.distinct)
69
70 def matchTriple(pat: Triple)(using Graph, NeuralConfig): QueryResult =
71 runQuery(List(pat), collectVars(pat))
72
73 def matchTriple(s: Term, p: Term, o: Term)(using Graph, NeuralConfig): QueryResult =
74 matchTriple(Triple(s, p, o))
75
76 /** Run a query form produced by the reader or the `nsk` interpolator. */
77 def run(form: Form)(using Graph, NeuralConfig): QueryResult = form match
78 case Form.Pat(t) => matchTriple(t)
79 case Form.Ask(vs, cs) => runQuery(cs, vs)
80 case Form.Cmd(n, _) => throw IllegalArgumentException(s"not a query: $n")
81
82 private def collectVars(pat: Triple): List[Term.Var] =
83 pat.toList.flatMap {
84 case v: Term.Var => List(v)
85 case Term.Neural(inner) => inner match { case v: Term.Var => List(v); case _ => Nil }
86 case _ => Nil
87 }.distinct
Follow one query through matchPattern. It first checks whether the predicate
is neural and, if so, strips the tilde to get a plain symbolic pattern spat. It
grounds that pattern by substituting any already-bound variables, so the graph
can use its indices. It asks the graph for candidates and unifies the pattern
against each. If the symbolic search finds nothing and the predicate was neural,
only then does it call neuralMatch.
neuralMatch fires only in the useful shape: a concrete subject and a variable
object, “given this subject and predicate, what is the object?”. It calls the
model through Neural.queryFallback, turns the answer into a keyword, and unifies
that keyword with the target variable.
prove is the recurrence made literal. An empty pattern list yields one
solution, the current environment. Otherwise it matches the first pattern and,
for each resulting environment, recurses on the rest with flatMap. runQuery
runs prove from the empty environment, then reads each requested variable out
of each solution with resolve, and drops duplicates. The QueryResult printer
renders one row per solution, so [?who :wrote :nsk] with two authors prints two
lines.
The neural layer
The neural layer is the only part that reaches outside the process. It speaks to
a local Ollama daemon over HTTP through the JDK’s own HttpClient, so the core
still carries no external dependency.
Two prompts drive it. One asks the model to infer a single object for a subject and predicate. The other asks the model to extract triples from free text. Both demand a JSON reply, which keeps parsing simple and predictable.
Here is the request NSK sends to infer an object, the data the model receives:
1 {
2 "model": "qwen3.5:4b",
3 "system": "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\"}.",
4 "prompt": "Subject: mark. Predicate: favorite-language. What is the Object?",
5 "format": "json",
6 "stream": false
7 }
Ollama wraps the model’s answer in an envelope. The field we want is response,
which itself holds a JSON string:
1 {
2 "model": "qwen3.5:4b",
3 "created_at": "2026-07-28T12:00:00Z",
4 "response": "{\"result\": \"Common Lisp\"}",
5 "done": true
6 }
So NSK parses twice: once to pull out response, and once to parse that inner
string and read result. The value “Common Lisp” then becomes the keyword
:COMMON-LISP. The code below does exactly that, and guards every network call,
so a missing daemon degrades to “no answer” rather than a crash.
1 package nsk
2
3 import java.net.URI
4 import java.net.http.{HttpClient, HttpRequest, HttpResponse}
5 import java.time.Duration
6
7 /** Configuration for the neural layer.
8 *
9 * Passed as a `given`, this replaces the Common Lisp specials `*ollama-url*`,
10 * `*ollama-model*`, and `*ollama-timeout*`. A test rebinds it with a local
11 * `given`, the way Lisp code rebinds a special with `let`.
12 */
13 case class NeuralConfig(
14 url: String = "http://localhost:11434",
15 model: String = "qwen3.5:4b",
16 timeoutSeconds: Int = 60
17 )
18
19 object NeuralConfig:
20 given default: NeuralConfig = NeuralConfig()
21
22 /** The neural integration layer (a local Ollama daemon).
23 *
24 * When a symbolic query fails on a `~` predicate, NSK asks the model to infer
25 * the missing object. The same layer turns free text into triples. HTTP goes
26 * through the JDK's own `HttpClient`, so the core keeps no dependency on an
27 * external HTTP library.
28 */
29 object Neural:
30 private val systemInference =
31 "You are a graph database inference node. Given a Subject and a Predicate, " +
32 "infer the single most likely Object. Reply ONLY as JSON: {\"result\": \"value\"}."
33
34 private val systemExtraction =
35 "You extract knowledge-graph triples from text. Reply ONLY as JSON of the form " +
36 "{\"triples\": [{\"subject\": \"..\", \"predicate\": \"..\", \"object\": \"..\"}]}. " +
37 "Use short lower-case tokens."
38
39 /** Readable label for a subject or predicate term. */
40 def termLabel(t: Term): String = t match
41 case Term.Neural(inner) => termLabel(inner)
42 case Term.Kw(name) => name.toLowerCase
43 case Term.Str(s) => s
44 case other => other.display
45
46 /** Convert an LLM string such as "Common Lisp" into the keyword `:COMMON-LISP`. */
47 def sanitizeToKeyword(s: String): Term.Kw =
48 val trimSet = " \t\n\r.,".toSet
49 val trimmed = s.dropWhile(trimSet).reverse.dropWhile(trimSet).reverse
50 Term.Kw(trimmed.toUpperCase.replace(' ', '-'))
51
52 private def post(url: String, body: String, cfg: NeuralConfig): String =
53 val client = HttpClient
54 .newBuilder()
55 .connectTimeout(Duration.ofSeconds(cfg.timeoutSeconds))
56 .build()
57 val req = HttpRequest
58 .newBuilder(URI.create(url))
59 .timeout(Duration.ofSeconds(cfg.timeoutSeconds))
60 .header("Content-Type", "application/json")
61 .POST(HttpRequest.BodyPublishers.ofString(body))
62 .build()
63 client.send(req, HttpResponse.BodyHandlers.ofString()).body()
64
65 /** Send a /api/generate request and return the model's raw response string. */
66 private def generate(prompt: String, system: String)(using cfg: NeuralConfig): String =
67 val payload = Json
68 .obj(
69 "model" -> Json.Str(cfg.model),
70 "system" -> Json.Str(system),
71 "prompt" -> Json.Str(prompt),
72 "format" -> Json.Str("json"),
73 "stream" -> Json.Bool(false)
74 )
75 .render
76 val raw = post(s"${cfg.url}/api/generate", payload, cfg)
77 Json.parse(raw)("response").flatMap(_.str).getOrElse("")
78
79 /** Ask the model to infer the object for (SUBJECT PREDICATE). Returns the
80 * answer string, or None when the daemon is unreachable or gives nothing. */
81 def queryFallback(subject: Term, predicate: Term)(using NeuralConfig): Option[String] =
82 try
83 val prompt =
84 s"Subject: ${termLabel(subject)}. Predicate: ${termLabel(predicate)}. What is the Object?"
85 val response = generate(prompt, systemInference)
86 val inner = try Json.parse(response) catch case _: Throwable => Json.Null
87 inner("result").flatMap(_.str).filter(_.nonEmpty).orElse(Some(response).filter(_.nonEmpty))
88 catch
89 case e: Throwable =>
90 Console.err.println(s"; neural fallback unavailable: ${e.getMessage}")
91 None
92
93 /** Use the model to parse TEXT into a list of keyword triples. */
94 def textToTriples(text: String)(using NeuralConfig): List[Triple] =
95 try
96 val response = generate(text, systemExtraction)
97 val rows = Json.parse(response)("triples").collect { case Json.Arr(items) => items }
98 .getOrElse(Nil)
99 rows.flatMap { row =>
100 for
101 s <- row("subject").flatMap(_.str)
102 p <- row("predicate").flatMap(_.str)
103 o <- row("object").flatMap(_.str)
104 yield Triple(sanitizeToKeyword(s), sanitizeToKeyword(p), sanitizeToKeyword(o))
105 }
106 catch
107 case e: Throwable =>
108 Console.err.println(s"; extraction unavailable: ${e.getMessage}")
109 Nil
110
111 /** Extract triples from TEXT and add them to the graph. */
112 def ingestText(text: String)(using g: Graph, cfg: NeuralConfig): List[Triple] =
113 val triples = textToTriples(text)
114 triples.foreach(tr => g.add(tr.s, tr.p, tr.o))
115 triples
sanitizeToKeyword is the small conversion between the neural and symbolic
sides. It trims
whitespace and trailing punctuation, upcases, and turns spaces into hyphens, so
the model’s “Common Lisp” becomes the atom :COMMON-LISP that the symbolic side
can store and match. queryFallback wraps the whole call in a try, so when the
daemon is down it prints one diagnostic to standard error and returns None. A
neural query then simply yields no solution, which is the behavior we want in a
system that must keep running without a model.
ingestText closes the loop the other way: it asks the model to read free text
into triples and adds each one to the graph. Text goes in, structured facts come
out, and from then on the symbolic engine can query them.
Supporting infrastructure
Four files carry the plumbing: JSON, the REPL, the REST server, and the command-line entry point. They hold no new AI ideas, but the program does not run without them, so we read each in turn.
JSON
NSK keeps its own JSON so the core needs no library, the same choice the Lisp
version makes. JSON is a closed set of six shapes, so once again an enum fits.
The parser is a small recursive descent reader; the writer walks the tree and
escapes strings.
1 package nsk
2
3 /** A small, self-contained JSON model, parser, and writer.
4 *
5 * NSK keeps its own JSON code so the core has no external dependency, the
6 * same choice the Common Lisp version makes. The Lisp writer takes a tagged
7 * list and the reader returns alists; here JSON is a proper algebraic data
8 * type, which is the idiomatic Scala model.
9 */
10 enum Json:
11 case Str(value: String)
12 case Num(value: Double)
13 case Bool(value: Boolean)
14 case Null
15 case Arr(items: List[Json])
16 case Obj(fields: List[(String, Json)])
17
18 def render: String =
19 val sb = StringBuilder()
20 Json.write(this, sb)
21 sb.toString
22
23 /** Look up KEY in an object, the analog of Common Lisp `json-get`. */
24 def apply(key: String): Option[Json] = this match
25 case Obj(fs) => fs.collectFirst { case (k, v) if k == key => v }
26 case _ => None
27
28 def str: Option[String] = this match { case Str(s) => Some(s); case _ => None }
29 def double: Option[Double] = this match { case Num(n) => Some(n); case _ => None }
30
31 object Json:
32 def obj(fields: (String, Json)*): Json = Obj(fields.toList)
33 def arr(items: Json*): Json = Arr(items.toList)
34
35 final class JsonError(msg: String) extends RuntimeException(msg)
36
37 def parse(input: String): Json =
38 val p = Parser(input)
39 val v = p.value()
40 p.skipWs()
41 v
42
43 // ----- writer -----------------------------------------------------------
44
45 private def write(v: Json, sb: StringBuilder): Unit = v match
46 case Str(s) => writeString(s, sb)
47 case Num(n) => if n == n.toLong.toDouble then sb.append(n.toLong) else sb.append(n)
48 case Bool(b) => sb.append(if b then "true" else "false")
49 case Null => sb.append("null")
50 case Arr(items) =>
51 sb.append('[')
52 items.zipWithIndex.foreach { (it, i) => if i > 0 then sb.append(','); write(it, sb) }
53 sb.append(']')
54 case Obj(fields) =>
55 sb.append('{')
56 fields.zipWithIndex.foreach { case ((k, vv), i) =>
57 if i > 0 then sb.append(',')
58 writeString(k, sb); sb.append(':'); write(vv, sb)
59 }
60 sb.append('}')
61
62 private def writeString(s: String, sb: StringBuilder): Unit =
63 sb.append('"')
64 s.foreach {
65 case '"' => sb.append("\\\"")
66 case '\\' => sb.append("\\\\")
67 case '\n' => sb.append("\\n")
68 case '\r' => sb.append("\\r")
69 case '\t' => sb.append("\\t")
70 case '\b' => sb.append("\\b")
71 case '\f' => sb.append("\\f")
72 case c if c < ' ' => sb.append("\\u%04x".format(c.toInt))
73 case c => sb.append(c)
74 }
75 sb.append('"')
76
77 // ----- reader -----------------------------------------------------------
78
79 private final class Parser(s: String):
80 private var pos = 0
81
82 private def fail(msg: String): Nothing = throw JsonError(s"$msg at $pos")
83 private def atEnd: Boolean = pos >= s.length
84 private def peek: Char = s(pos)
85 private def next(): Char = { val c = s(pos); pos += 1; c }
86
87 def skipWs(): Unit =
88 while pos < s.length && " \t\n\r".contains(s(pos)) do pos += 1
89
90 def value(): Json =
91 skipWs()
92 if atEnd then fail("unexpected end of input")
93 peek match
94 case '{' => obj()
95 case '[' => arr()
96 case '"' => Str(string())
97 case 't' => literal("true", Bool(true))
98 case 'f' => literal("false", Bool(false))
99 case 'n' => literal("null", Null)
100 case c if c == '-' || c.isDigit => number()
101 case c => fail(s"unexpected char $c")
102
103 private def literal(text: String, v: Json): Json =
104 text.foreach(e => if atEnd || next() != e then fail(s"bad literal, expected $text"))
105 v
106
107 private def obj(): Json =
108 next() // {
109 skipWs()
110 if !atEnd && peek == '}' then { next(); return Obj(Nil) }
111 val fields = List.newBuilder[(String, Json)]
112 var more = true
113 while more do
114 skipWs()
115 val key = string()
116 skipWs()
117 if atEnd || next() != ':' then fail("expected : after object key")
118 fields += (key -> value())
119 skipWs()
120 next() match
121 case ',' => ()
122 case '}' => more = false
123 case _ => fail("expected , or } in object")
124 Obj(fields.result())
125
126 private def arr(): Json =
127 next() // [
128 skipWs()
129 if !atEnd && peek == ']' then { next(); return Arr(Nil) }
130 val items = List.newBuilder[Json]
131 var more = true
132 while more do
133 items += value()
134 skipWs()
135 next() match
136 case ',' => ()
137 case ']' => more = false
138 case _ => fail("expected , or ] in array")
139 Arr(items.result())
140
141 private def string(): String =
142 if atEnd || next() != '"' then fail("expected a string")
143 val sb = StringBuilder()
144 var done = false
145 while !done do
146 if atEnd then fail("unterminated string")
147 next() match
148 case '"' => done = true
149 case '\\' =>
150 next() match
151 case '"' => sb.append('"')
152 case '\\' => sb.append('\\')
153 case '/' => sb.append('/')
154 case 'b' => sb.append('\b')
155 case 'f' => sb.append('\f')
156 case 'n' => sb.append('\n')
157 case 'r' => sb.append('\r')
158 case 't' => sb.append('\t')
159 case 'u' => sb.append(hex(4).toChar)
160 case _ => fail("bad string escape")
161 case c => sb.append(c)
162 sb.toString
163
164 private def hex(n: Int): Int =
165 var v = 0
166 for _ <- 0 until n do
167 val d = Character.digit(next(), 16)
168 if d < 0 then fail("bad \\u escape")
169 v = v * 16 + d
170 v
171
172 private def number(): Json =
173 val start = pos
174 if peek == '-' then next()
175 while !atEnd && (peek.isDigit || "+-.eE".contains(peek)) do next()
176 Num(s.substring(start, pos).toDouble)
The apply(key) method gives the neat json("response") lookup the neural layer
relies on, and str and double pull a typed value out of a node. Because
apply returns an Option, a chain of lookups uses flatMap and never throws
on a missing field.
The REPL
The read-eval-print loop reads a form and interprets it. Common Lisp reads a form
and then calls eval; Scala has no eval, so the loop dispatches on the Form
type instead. That is a little more code and a much smaller attack surface, since
the only things the loop can do are the ones we wrote.
1 package nsk
2
3 import java.io.{BufferedReader, ByteArrayOutputStream, PrintStream}
4 import java.nio.charset.StandardCharsets.UTF_8
5
6 /** The interactive NSK read-eval-print loop.
7 *
8 * Common Lisp reads a form and then `eval`s it. Scala has no `eval`, so the
9 * loop instead reads a [[Form]] with [[Reader]] and interprets it directly.
10 * That is a little more code but keeps the language surface small and safe.
11 */
12 object Repl:
13 val banner: String =
14 "NSK: Neural-Symbolic Knowledge Graph Engine\nType :help for commands, :quit to exit."
15
16 val help: String =
17 """Commands:
18 | :help show this help
19 | :facts list every triple
20 | :count show the triple count
21 | :add s p o add a triple, e.g. (:add :mark :wrote :nsk)
22 | :del s p o remove a triple
23 | :ingest "text" extract triples from text with the model
24 | :save flush the log to disk
25 | :quit leave the REPL
26 |
27 |Queries use the NSK syntax:
28 | [?who :wrote :nsk] one pattern
29 | (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
30 | [:mark ~:codes-in ?lang] ~ falls back to the model""".stripMargin
31
32 /** Evaluate one form. Returns true to keep looping, false to quit. */
33 def eval(form: Form, out: PrintStream)(using Graph, NeuralConfig): Boolean =
34 form match
35 case Form.Cmd(name, args) => command(name, args, out)
36 case Form.Pat(_) | Form.Ask(_, _) =>
37 out.println(Query.run(form)); true
38
39 private def command(name: String, args: List[Term], out: PrintStream)(using
40 g: Graph,
41 cfg: NeuralConfig
42 ): Boolean =
43 name match
44 case "quit" | "exit" => false
45 case "help" => out.println(help); true
46 case "count" => out.println(s"${g.count} triples"); true
47 case "save" => g.flush(); out.println("saved."); true
48 case "facts" =>
49 g.all.foreach(tr => out.println(s" ${tr.s.display} ${tr.p.display} ${tr.o.display}"))
50 out.println(s"(${g.count} triples)"); true
51 case "add" =>
52 args match
53 case List(s, p, o) =>
54 g.add(s, p, o); out.println(s"added ${s.display} ${p.display} ${o.display}")
55 case _ => out.println("usage: (:add s p o)")
56 true
57 case "del" =>
58 args match
59 case List(s, p, o) =>
60 g.remove(s, p, o); out.println(s"removed ${s.display} ${p.display} ${o.display}")
61 case _ => out.println("usage: (:del s p o)")
62 true
63 case "ingest" =>
64 args match
65 case List(Term.Str(text)) =>
66 val added = Neural.ingestText(text)
67 out.println(s"ingested ${added.length} triple${if added.length == 1 then "" else "s"}")
68 case _ => out.println("usage: (:ingest \"text\")")
69 true
70 case other => out.println(s"unknown command: $other"); true
71
72 /** Process every complete form in INPUT, returning captured output. Used by
73 * the tests and by anyone who wants to script the REPL. */
74 def feed(input: String)(using Graph, NeuralConfig): String =
75 val buf = ByteArrayOutputStream()
76 val out = PrintStream(buf, true, UTF_8)
77 val (forms, _) = Reader.readForms(input + "\n")
78 forms.foreach(f => eval(f, out))
79 buf.toString(UTF_8)
80
81 /** The interactive loop, reading line by line and buffering partial forms. */
82 def run(in: BufferedReader, out: PrintStream)(using Graph, NeuralConfig): Unit =
83 out.println(banner)
84 var buffer = ""
85 var running = true
86 while running do
87 out.print("nsk> "); out.flush()
88 val line = in.readLine()
89 if line == null then running = false
90 else
91 buffer += line + "\n"
92 val (forms, consumed) = Reader.readForms(buffer)
93 buffer = buffer.substring(consumed)
94 val it = forms.iterator
95 while running && it.hasNext do
96 try if !eval(it.next(), out) then running = false
97 catch case e: Throwable => out.println(s"; error: ${e.getMessage}")
98 out.println("Bye.")
feed is worth noting: it runs a whole script and returns the captured output as
a string. The tests use it to drive the REPL without a terminal, and it is what
makes the loop testable. The interactive run buffers text across
lines, so a query split over several lines still parses once it is complete.
The REST server
The --serve flag starts a small HTTP server on the JDK’s built-in
com.sun.net.httpserver, so there is still no outside dependency. It exposes two
routes: POST /query and GET /health.
A query request is a JSON object with three optional fields. A field that is
null or missing means “any”, so it becomes a variable. A field that starts with
a question mark is a named variable. Any other string is a keyword. Here is a
sample request and the reply it produces:
1 {"subject": null, "predicate": "wrote", "object": "nsk"}
1 {"count":1,"results":[{"subject":"mark"}]}
The server maps the JSON fields to terms, runs the same Query.matchTriple the
REPL uses, and renders the solutions back to JSON.
1 package nsk
2
3 import com.sun.net.httpserver.{HttpExchange, HttpServer}
4 import java.net.InetSocketAddress
5 import java.nio.charset.StandardCharsets.UTF_8
6
7 /** An optional REST server for the `--serve` flag.
8 *
9 * Common Lisp loads Hunchentoot on demand; here the server uses the JDK's own
10 * `com.sun.net.httpserver`, so it needs no external dependency at all.
11 */
12 object Server:
13 @volatile private var server: Option[HttpServer] = None
14
15 /** A request field maps to a graph term: null or missing means "any", a
16 * value starting with `?` is a variable, any other string is a keyword. */
17 def fieldToTerm(value: Option[Json], role: String): Term = value match
18 case None | Some(Json.Null) => Term.Var(role)
19 case Some(Json.Str(s)) if s.startsWith("?") => Term.Var(s.drop(1))
20 case Some(Json.Str(s)) => Neural.sanitizeToKeyword(s)
21 case Some(other) => Term.Str(other.render)
22
23 def termJson(t: Term): String = t match
24 case Term.Kw(name) => name.toLowerCase
25 case Term.Str(s) => s
26 case Term.Var(n) => s"?$n"
27 case Term.Num(x) => x.toString
28 case Term.Neural(i) => termJson(i)
29
30 def solutionToJson(solution: List[(String, Term)]): Json =
31 Json.Obj(solution.map((k, v) => k -> Json.Str(termJson(v))))
32
33 private def body(ex: HttpExchange): String =
34 new String(ex.getRequestBody.readAllBytes(), UTF_8)
35
36 private def respond(ex: HttpExchange, json: String): Unit =
37 val bytes = json.getBytes(UTF_8)
38 ex.getResponseHeaders.set("Content-Type", "application/json")
39 ex.sendResponseHeaders(200, bytes.length.toLong)
40 val os = ex.getResponseBody
41 os.write(bytes)
42 os.close()
43
44 private def handleQuery(ex: HttpExchange)(using Graph, NeuralConfig): Unit =
45 val req = try Json.parse(body(ex)) catch case _: Throwable => Json.Null
46 val s = fieldToTerm(req("subject"), "subject")
47 val p = fieldToTerm(req("predicate"), "predicate")
48 val o = fieldToTerm(req("object"), "object")
49 val sols = Query.matchTriple(s, p, o).solutions
50 respond(
51 ex,
52 Json
53 .obj(
54 "count" -> Json.Num(sols.length),
55 "results" -> Json.Arr(sols.map(solutionToJson))
56 )
57 .render
58 )
59
60 private def handleHealth(ex: HttpExchange)(using g: Graph, cfg: NeuralConfig): Unit =
61 respond(
62 ex,
63 Json
64 .obj(
65 "status" -> Json.Str("ok"),
66 "triples" -> Json.Num(g.count),
67 "model" -> Json.Str(cfg.model)
68 )
69 .render
70 )
71
72 /** Start serving /query and /health on PORT. */
73 def start(port: Int)(using Graph, NeuralConfig): HttpServer =
74 stop()
75 val s = HttpServer.create(InetSocketAddress(port), 0)
76 s.createContext("/query", (ex: HttpExchange) => handleQuery(ex))
77 s.createContext("/health", (ex: HttpExchange) => handleHealth(ex))
78 s.setExecutor(null)
79 s.start()
80 server = Some(s)
81 s
82
83 /** Stop the running server, if any. */
84 def stop(): Unit =
85 server.foreach(_.stop(0))
86 server = None
The createContext calls pass a lambda where an HttpHandler is expected. Scala
turns the lambda into the single-method interface, so the handler stays a one
liner. The rest of the file is straight translation between JSON and terms.
The entry point
Main.scala parses flags, opens the store, and then either serves or drops into
the REPL. It sets up the two given values, the graph and the neural config,
that every query function needs.
1 package nsk
2
3 import java.io.{BufferedReader, InputStreamReader}
4
5 /** Command-line entry point. Parse flags, then serve or drop into the REPL.
6 * The object is named `App` (not `Main`) so its class file does not collide
7 * with the `main.class` from `@main` on a case-insensitive filesystem.
8 */
9 @main def main(args: String*): Unit = App.run(args.toArray)
10
11 object App:
12 val version = "NSK 1.0.0"
13
14 def run(args: Array[String]): Unit =
15 if args.contains("--help") then
16 println(usage)
17 else
18 val dbPath = flagValue(args, "--db").getOrElse("nsk-graph.log")
19 given Graph = Graph.open(dbPath)
20 given NeuralConfig = NeuralConfig()
21 try
22 if args.contains("--serve") then
23 val port = flagValue(args, "--port").map(_.toInt).getOrElse(8800)
24 Server.start(port)
25 println(s"$version serving on http://localhost:$port (Ctrl-C to stop)")
26 while true do Thread.sleep(3600000)
27 else
28 Repl.run(BufferedReader(InputStreamReader(System.in)), System.out)
29 catch case e: Throwable => Console.err.println(s"fatal: ${e.getMessage}")
30 finally summon[Graph].close()
31
32 private def flagValue(args: Array[String], flag: String): Option[String] =
33 val i = args.indexOf(flag)
34 Option.when(i >= 0 && i + 1 < args.length)(args(i + 1))
35
36 def usage: String =
37 s"""$version
38 |
39 |Usage: nsk [options]
40 |
41 | (no options) start the interactive REPL
42 | --serve start the REST server instead of the REPL
43 | --port N server port (default 8800)
44 | --db PATH transaction log path (default nsk-graph.log)
45 | --help show this message
46 |
47 |Wrap the REPL with rlwrap for history and line editing:
48 | rlwrap scala-cli run .""".stripMargin
The two given lines put the graph and config in scope for the whole try
block, so every call to Repl.run or Server.start finds them through their
using clauses. The finally closes the log so the last writes reach disk.
The embedded DSL: recreating reader macros in Scala
The Common Lisp original uses macros twice: reader macros for ?var,
[s p o], and ~pred, and a defmacro for ask. Scala has no reader macros
and no defmacro, yet the port can offer the same syntax. This section is the
most interesting part for Scala programmers, because it shows five features
working together to give a Lisp-like surface.
First, the target. From Scala code we want to write a query two ways: as the
original Lisp text, and as native Scala combinators. Both should produce the same
Form or QueryResult:
1 import nsk.dsl.{*, given}
2
3 given Graph = Graph.inMemory
4 summon[Graph].add("mark", "wrote", "nsk")
5
6 nsk"[?who :wrote :nsk]".run // the Lisp text
7 ask(v.who)(t(v.who, "wrote", "nsk")) // native Scala
Here is the DSL that makes both lines legal.
1 package nsk
2
3 import scala.language.dynamics
4 import scala.language.implicitConversions
5
6 /** An embedded Scala DSL that recreates the Common Lisp reader-macro syntax.
7 *
8 * Import it with `import nsk.dsl.*`. It offers two ways to write queries:
9 *
10 * - the `nsk"..."` string interpolator, which parses the exact Lisp
11 * surface syntax (this is the closest analog of a reader macro);
12 * - native Scala combinators: `v.who` for a logic variable, `~pred` for a
13 * neural predicate, a `String -> keyword` conversion, and `ask`.
14 */
15 object dsl:
16
17 /** `?who` becomes `v.who`. `scala.Dynamic` turns the field name into a
18 * logic variable at compile time, which is how Common Lisp's `?` reader
19 * macro turns the following symbol into `(logic-var 'who)`. */
20 object v extends Dynamic:
21 def selectDynamic(name: String): Term.Var = Term.Var(name)
22 def applyDynamic(name: String)(): Term.Var = Term.Var(name)
23
24 extension (sc: StringContext)
25 /** `:mark` becomes `kw"mark"`. */
26 def kw(args: Any*): Term.Kw =
27 Term.Kw(sc.s(args*).trim.toUpperCase.replace(' ', '-'))
28
29 /** The NSK reader as a string interpolator: `nsk"[?who :wrote :nsk]"`.
30 * A spliced `Term` round-trips through its `toString`, which the reader
31 * parses back. */
32 def nsk(args: Any*): Form = Reader.readForm(sc.s(args*))
33
34 /** `~pred` wraps a term as a neural predicate. Scala allows `unary_~` as a
35 * prefix operator, so this reads exactly like the Lisp `~`. */
36 extension (t: Term) def unary_~ : Term.Neural = Term.Neural(t)
37
38 /** A plain Scala string is an atom in query positions, so `"wrote"` means
39 * the keyword `:wrote`. Use `Term.Str(...)` for a real string value. */
40 given Conversion[String, Term] = s => Term.Kw(s.toUpperCase)
41
42 /** Build a triple pattern: `t(v.who, "wrote", "nsk")`. */
43 def t(s: Term, p: Term, o: Term): Triple = Triple(s, p, o)
44
45 /** Datalog-style query written in native Scala. */
46 def ask(vars: Term.Var*)(clauses: Triple*)(using Graph, NeuralConfig): QueryResult =
47 Query.runQuery(clauses.toList, vars.toList)
48
49 /** Run a parsed query form. */
50 extension (f: Form) def run(using Graph, NeuralConfig): QueryResult = Query.run(f)
Five features carry the syntax:
A string interpolator as the reader macro.
nsk"[?who :wrote :nsk]"calls aStringContextextension that runs the sameReaderthe REPL uses. One parser serves the REPL, log replay, and this interpolator. A splicedTermround-trips, because a term’stoStringprints the exact surface syntax the reader reads back.scala.Dynamicfor?var. The Lisp?macro reads the next symbol and wraps it.object v extends Dynamicdoes the same with a field access, sov.whobecomesTerm.Var("who")at compile time.unary_~for the neural predicate. Scala lets a type defineunary_~, so~reads as a prefix operator, exactly like the Lisp~. Now~kw"codes-in"is a neural predicate.given Conversionfor keyword atoms. Lisp writes:markfor an atom. Scala has no atom literal, so agiven Conversionlets a plain string stand in for one in query positions. A wildcardimport nsk.dsl.*does not bring in agiven, so callers import withimport nsk.dsl.{*, given}.usingparameters for the special variables. Lisp threads the active graph and the Ollama settings through*graph*and*ollama-url*. Scala’s context parameters do the same job with static safety, as we saw in the query engine.
Two more choices complete the idiom: every closed data model is an enum, which
gives exhaustive match checking the Lisp ecase gives only at run time; and
unification returns Option in place of the +FAIL+ sentinel, which lets the
whole engine chain with flatMap.
Running the engine
The project ships a Makefile that wraps scala-cli. It passes --jvm system so
scala-cli uses the JDK already on your path rather than downloading one.
1 make compile # type-check every source file
2 make test # run the test suite
3 make repl # start the interactive REPL
4 make serve # start the REST server on port 8800
Start the REPL and add a few facts. The session below shows the exact output you will see. Recall that keyword names are stored upper case, so a fact echoes back in upper case, and a query result prints the atom with its colon.
1 $ make repl
2 NSK: Neural-Symbolic Knowledge Graph Engine
3 Type :help for commands, :quit to exit.
4 nsk> (:add :mark :wrote :nsk)
5 added MARK WROTE NSK
6 nsk> (:add :jane :wrote :nsk)
7 added JANE WROTE NSK
8 nsk> (:add :mark :codes-in :lisp)
9 added MARK CODES-IN LISP
10 nsk> [?who :wrote :nsk]
11 who=:MARK
12 who=:JANE
13 nsk> (ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
14 a=:MARK
15 nsk> :count
16 3 triples
17 nsk> :quit
18 Bye.
The single pattern [?who :wrote :nsk] returns two rows, because two people
wrote NSK. The conjunctive ask returns one row, because only Mark both wrote
NSK and codes in Lisp. That narrowing from two rows to one is the join at work.
If you have Ollama running with the qwen3.5:4b model, a neural predicate fills
a fact the store does not hold:
1 nsk> [:mark ~:favorite-language ?lang]
2 lang=:COMMON-LISP
NSK found no :favorite-language triple, so it asked the model, which answered
“Common Lisp”, and NSK bound the sanitized keyword to ?lang. With no daemon
running, the same query prints one diagnostic line to standard error and returns
no rows:
1 nsk> [:mark ~:favorite-language ?lang]
2 ; neural fallback unavailable: Connection refused
3 #<no solutions>
The REST server runs the same engine over HTTP:
1 $ make serve
2 NSK 1.0.0 serving on http://localhost:8800 (Ctrl-C to stop)
1 $ curl -s http://localhost:8800/query \
2 -H 'Content-Type: application/json' \
3 -d '{"subject": null, "predicate": "wrote", "object": "nsk"}'
4 {"count":2,"results":[{"subject":"mark"},{"subject":"jane"}]}
5
6 $ curl -s http://localhost:8800/health
7 {"status":"ok","triples":3,"model":"qwen3.5:4b"}
Testing
The suite lives in one file and uses a tiny hand-rolled harness, in the spirit of
the Common Lisp check macro. A check runs a boolean and records a pass, a
fail, or an error. There is no test framework and so no dependency.
1 package nsk
2
3 import nsk.dsl.{*, given} // the `given` selector imports the String -> Term conversion
4 import scala.language.implicitConversions
5 import java.nio.file.{Files, Path}
6
7 /** A tiny zero-dependency test harness, in the spirit of the Common Lisp
8 * `check` macro. Run with: scala-cli run . --main-class nsk.test
9 */
10 object T:
11 var pass = 0
12 var fail = 0
13
14 def section(title: String): Unit = println(s"\n== $title ==")
15
16 def check(label: String)(cond: => Boolean): Unit =
17 try
18 if cond then { pass += 1; println(s" ok $label") }
19 else { fail += 1; println(s" FAIL $label") }
20 catch case e: Throwable => { fail += 1; println(s" ERR $label <$e>") }
21
22 @main def test(): Unit =
23 import T.*
24
25 section("unification")
26 check("a and b do not unify")(Unify.unify(kw"a", kw"b").isEmpty)
27 check("a unifies with a")(Unify.unify(kw"a", kw"a").isDefined)
28 check("variable binds to value") {
29 val x = v.x
30 Unify.unify(x, kw"mark").flatMap(_.get(x)).contains(kw"mark")
31 }
32 check("logic vars are equal by name")(v.x == v.x)
33 check("an already-bound var will not rebind") {
34 val x = v.x
35 val env = Unify.unify(x, kw"mark")
36 Unify.unify(x, kw"jane", env).isEmpty
37 }
38 check("resolve follows a binding") {
39 val x = v.x
40 Unify.resolve(x, Unify.unify(x, kw"mark").get) == kw"mark"
41 }
42
43 section("store")
44 locally {
45 val g = Graph.inMemory
46 g.add("mark", "wrote", "nsk")
47 g.add("jane", "wrote", "book")
48 g.add("mark", "codes-in", "lisp")
49 g.add("mark", "wrote", "nsk") // duplicate, ignored
50 check("duplicates are ignored")(g.count == 3)
51 check("subject index works")(g.spoIndex(kw"mark").length == 2)
52 check("object index works")(g.ospIndex(kw"book").length == 1)
53 check("insertion order kept")(g.all.head == t("mark", "wrote", "nsk"))
54 g.remove("mark", "wrote", "nsk")
55 check("remove updates the count")(g.count == 2)
56 check("remove clears the index")(!g.spoIndex(kw"mark").contains(t("mark", "wrote", "nsk")))
57 }
58
59 section("persistence (replay)")
60 locally {
61 val path = "nsk-test-tmp.log"
62 Files.deleteIfExists(Path.of(path))
63 locally {
64 val g = Graph.open(path)
65 g.add("a", "b", "c")
66 g.add("d", "e", "f")
67 g.remove("a", "b", "c")
68 g.close()
69 }
70 val g2 = Graph.open(path)
71 check("replay reaches the right count")(g2.count == 1)
72 check("replay keeps live triples")(g2.all.head == t("d", "e", "f"))
73 check("replayed deletion sticks")(!g2.present(t("a", "b", "c")))
74 g2.close()
75 Files.deleteIfExists(Path.of(path))
76 }
77
78 section("reader macros")
79 nsk"[?person :wrote :nsk]" match
80 case Form.Pat(tr) =>
81 check("[ ] reads as a pattern")(true)
82 check("?x reads as a logic var")(tr.s == v.person)
83 check("keyword reads upper-case")(tr.p == Term.Kw("WROTE"))
84 check("third slot")(tr.o == Term.Kw("NSK"))
85 case _ => check("[ ] reads as a pattern")(false)
86 nsk"[?a ~:codes-in ?l]" match
87 case Form.Pat(tr) =>
88 check("~pred reads as a neural predicate")(tr.p == Term.Neural(Term.Kw("CODES-IN")))
89 case _ => check("~pred reads as a neural predicate")(false)
90
91 section("query engine")
92 locally {
93 given Graph = Graph.inMemory
94 summon[Graph].add("mark", "wrote", "nsk")
95 summon[Graph].add("jane", "wrote", "nsk")
96 summon[Graph].add("mark", "codes-in", "lisp")
97 val one = nsk"[?who :wrote :nsk]".run.solutions
98 check("two authors wrote nsk")(one.length == 2)
99 check("mark is one")(one.exists(_.exists((_, value) => value == kw"mark")))
100 val joined = nsk"(ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])".run.solutions
101 check("join narrows to one author")(joined.length == 1)
102 check("the author is mark")(joined.head.head._2 == kw"mark")
103 }
104
105 section("json")
106 check("strings are escaped")(Json.Str("a\"b").render == "\"a\\\"b\"")
107 check("objects parse to fields") {
108 Json.parse("""{"x":1,"y":true}""") == Json.Obj(List("x" -> Json.Num(1), "y" -> Json.Bool(true)))
109 }
110 check("arrays parse to lists") {
111 Json.parse("[1,2,3]") == Json.Arr(List(Json.Num(1), Json.Num(2), Json.Num(3)))
112 }
113 locally {
114 val inner = """{"result": "Common Lisp"}"""
115 val outer = Json
116 .obj("model" -> Json.Str("m"), "response" -> Json.Str(inner), "done" -> Json.Bool(true))
117 .render
118 val response = Json.parse(outer)("response").flatMap(_.str).get
119 val result = Json.parse(response)("result").flatMap(_.str).get
120 check("extract result from an Ollama-style reply")(result == "Common Lisp")
121 check("sanitize to keyword")(Neural.sanitizeToKeyword(result) == Term.Kw("COMMON-LISP"))
122 }
123 check("sanitize trims and upcases") {
124 Neural.sanitizeToKeyword(" Common Lisp. ") == Term.Kw("COMMON-LISP")
125 }
126
127 section("server helpers")
128 check("null field becomes a variable")(Server.fieldToTerm(None, "subject").isInstanceOf[Term.Var])
129 check("?who field becomes a variable")(Server.fieldToTerm(Some(Json.Str("?who")), "subject") == v.who)
130 check("plain field becomes a keyword")(Server.fieldToTerm(Some(Json.Str("mark")), "subject") == kw"mark")
131 check("keyword renders as lower-case text")(Server.termJson(kw"mark") == "mark")
132 locally {
133 given Graph = Graph.inMemory
134 summon[Graph].add("mark", "wrote", "nsk")
135 val sol = Query.matchTriple(kw"mark", kw"wrote", v.o).solutions.head
136 val json = Server.solutionToJson(sol).render
137 check("solution renders to json")(json.contains("nsk"))
138 }
139
140 section("neural fallback (no daemon)")
141 locally {
142 given Graph = Graph.inMemory
143 given NeuralConfig = NeuralConfig(url = "http://127.0.0.1:9", timeoutSeconds = 2)
144 summon[Graph].add("mark", "wrote", "nsk")
145 val sols = nsk"(ask (?l) [:mark ~:codes-in ?l])".run.solutions
146 check("a ~ query fails cleanly when Ollama is down")(sols.isEmpty)
147 }
148
149 section("repl (scripted)")
150 locally {
151 given Graph = Graph.inMemory
152 val out = Repl.feed(
153 """(:add :mark :wrote :nsk)
154 |(:add :mark :codes-in :lisp)
155 |[?who :wrote :nsk]
156 |(ask (?a) [?a :wrote :nsk] [?a :codes-in :lisp])
157 |:count""".stripMargin
158 )
159 check("repl :add reports the addition")(out.contains("added"))
160 check("repl runs a single pattern")(out.contains("who=:MARK"))
161 check("repl runs an ask join")(out.contains("a=:MARK"))
162 check("repl :count is correct")(out.contains("2 triples"))
163 check("repl mutated the graph")(summon[Graph].count == 2)
164 }
165
166 println("\n==================================")
167 println(s"NSK tests: $pass passed, $fail failed")
168 println("==================================")
169 if fail > 0 then System.exit(1)
Run it with make test. The harness prints one line per check and a summary at
the end. The neural section points the config at a dead port, so it runs with no
model and confirms that a neural query fails cleanly. You will see:
1 $ make test
2
3 == unification ==
4 ok a and b do not unify
5 ok a unifies with a
6 ok variable binds to value
7 ok logic vars are equal by name
8 ok an already-bound var will not rebind
9 ok resolve follows a binding
10
11 == store ==
12 ok duplicates are ignored
13 ok subject index works
14 ok object index works
15 ok insertion order kept
16 ok remove updates the count
17 ok remove clears the index
18
19 == persistence (replay) ==
20 ok replay reaches the right count
21 ok replay keeps live triples
22 ok replayed deletion sticks
23
24 == reader macros ==
25 ok [ ] reads as a pattern
26 ok ?x reads as a logic var
27 ok keyword reads upper-case
28 ok third slot
29 ok ~pred reads as a neural predicate
30
31 == query engine ==
32 ok two authors wrote nsk
33 ok mark is one
34 ok join narrows to one author
35 ok the author is mark
36
37 == json ==
38 ok strings are escaped
39 ok objects parse to fields
40 ok arrays parse to lists
41 ok extract result from an Ollama-style reply
42 ok sanitize to keyword
43 ok sanitize trims and upcases
44
45 == server helpers ==
46 ok null field becomes a variable
47 ok ?who field becomes a variable
48 ok plain field becomes a keyword
49 ok keyword renders as lower-case text
50 ok solution renders to json
51
52 == neural fallback (no daemon) ==
53 ok a ~ query fails cleanly when Ollama is down
54
55 == repl (scripted) ==
56 ok repl :add reports the addition
57 ok repl runs a single pattern
58 ok repl runs an ask join
59 ok repl :count is correct
60 ok repl mutated the graph
61
62 ==================================
63 NSK tests: 41 passed, 0 failed
64 ==================================
Interpreting the results
Read the output as a set of claims about the engine, not just a list of ticks.
The unification checks prove the core is sound. Two different atoms do not
unify, an atom unifies with itself, a variable binds once and will not silently
rebind to a second value, and resolve reads a binding back out. If any of these
failed, every query would be suspect, so they come first.
The store and persistence checks prove the graph is correct and durable. Duplicates do not inflate the count, both indices return the right triples, and, after a write then a delete then a restart, replay reaches exactly the surviving triple. This is the guarantee that lets you stop and restart NSK without losing or double-counting facts.
The reader and query checks prove the surface syntax and the search agree
with the theory. The join check is the important one: [?who :wrote :nsk] yields
two authors, and adding the second clause [?a :codes-in :lisp] narrows the
answer to one. That is the conjunctive
recurrence returning
exactly the intersection, which is the whole point of a Datalog-style query.
The neural fallback check is the neural-symbolic contract in one line. With
no model reachable, a ~ query returns no solutions and does not throw. The
system stays up without a model and gains coverage with one. That is the property
that makes a neural-symbolic design safe to deploy: the neural part is an
enhancement, never a dependency for basic function.
A green suite of 41 checks means the symbolic engine is correct on its own, the persistence layer is sound, the reader accepts the intended syntax, and the neural layer fails safe. With Ollama present, the same engine also answers questions no one entered, which is the capability a purely symbolic store can never have.
Wrap Up
We built a working neural-symbolic knowledge graph engine and kept the whole core
on the standard library. The symbolic half stores triples, indexes them two ways,
and answers conjunctive queries by unification, with an append-only log for
durability. The neural half calls a local model, but only to fill a gap the
symbolic store cannot, and it fails safe when the model is absent. The two halves
come together in matchPattern, where “symbolic first, neural second” is four
lines of Scala.
Along the way the port showed how Scala 3 matches the expressive syntax of Common
Lisp macros without macros: a StringContext interpolator stands in for a reader
macro, scala.Dynamic and unary_~ give the ?var and ~pred prefixes, a
given Conversion supplies keyword literals, and using parameters replace Lisp
special variables with static safety. Closed data models became enums with
exhaustive matching, and search that can fail returned Option instead of a
sentinel. The result is a program that reads like a short pipeline and that the
compiler checks at every step.
The design scales to real use. Swap the in-memory indices for a database, point the neural layer at a larger model, and the query surface does not change. The pattern, a trusted symbolic store with a neural fallback for coverage, is one of the most useful approaches in applied AI today, and you now have a small, complete example of it to build on.
Optional Practice Problems
These exercises extend the engine you just read. Each names the file to start in. They rise in difficulty, from a warm-up to a small research project.
A predicate index.
Graph.candidatesnarrows by subject or object but never by predicate, so a query like[?s :wrote ?o]scans every live triple. Add a third index keyed by predicate inGraph.scala, and extendcandidatesto use it when only the predicate is concrete. Add a test inTests.scalathat proves the new index returns the right triples.List every predicate. Add a REPL command
:predstoRepl.scalathat prints each distinct predicate in the graph, one per line. ReuseTerm.displayfor the output, and match the style of the existing:factscommand.Wildcard delete. The current
(:del s p o)removes one exact triple. Add a(:del-where ...)command that accepts variables and deletes every triple that matches the pattern. Hint: run the pattern throughQuery.matchTriplefirst, resolve each solution back to a ground triple, then callGraph.remove. Be careful to collect the matches before you start deleting.Write-through neural facts. Right now a neural answer is used once and thrown away. Change the neural path so that when the model infers an object, NSK also adds the new triple to the graph, so the next identical query is a fast symbolic hit. Decide where this belongs:
Query.neuralMatchhas the answer and the graph is ausingparameter. Add a test that a repeated~query finds the cached fact on the second run without the model.A
notclause. Extend the reader and the query engine with negation as failure, so(ask (?a) [?a :wrote :nsk] (not [?a :codes-in :lisp]))returns authors of NSK who do not code in Lisp. InReader.scalaaccept a(not [..])clause; inQuery.scalaanotclause succeeds with the current environment when its inner pattern has no solution, and fails otherwise. Think about why the inner pattern should be ground by the time you test it.Provenance for neural facts. A fact from the model is less certain than one a user typed. Extend the store so a triple can carry a source tag,
:useror:neural, and add a REPL command that lists only the neural-derived facts. TheTerm.Neuralwrapper already marks neural terms; decide whether provenance belongs on the term, on the triple, or in a side table, and defend your choice in a comment.A batch query endpoint. Add a
POST /batchroute toServer.scalathat accepts a JSON array of query objects and returns an array of result objects in the same order. ReusefieldToTermandsolutionToJson. Add a health field that reports whether the Ollama daemon answered a probe request, so a caller can tell in advance whether neural queries will work.Rules, not just facts. This is the large one. Add Datalog rules with a head and a body, so a user can state
(:rule [?x :ancestor ?z] [?x :parent ?y] [?y :ancestor ?z])and have:ancestorcomputed by forward or backward chaining. Store rules apart from facts, extendQuery.proveto expand a rule when a pattern matches its head, and guard against infinite recursion on a cyclic graph. Compare your approach to the neural fallback: one derives new facts by logic, the other by a model, and a mature engine offers both.