From 8a9c07414120e92a2d1785680810cf3c22259eea Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 25 Apr 2026 18:08:27 +0000 Subject: [PATCH] prolog: compile clauses to SX closures (+17) --- lib/prolog/compiler.sx | 157 +++++++++++++++++++++++++++++ lib/prolog/conformance.sh | 4 +- lib/prolog/runtime.sx | 31 ++++-- lib/prolog/tests/compiler.sx | 185 +++++++++++++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 lib/prolog/compiler.sx create mode 100644 lib/prolog/tests/compiler.sx diff --git a/lib/prolog/compiler.sx b/lib/prolog/compiler.sx new file mode 100644 index 00000000..c3c80a5f --- /dev/null +++ b/lib/prolog/compiler.sx @@ -0,0 +1,157 @@ +;; lib/prolog/compiler.sx — clause compiler: parse-AST clauses → SX closures +;; +;; Each compiled clause is a lambda (fn (goal trail db cut-box k) bool) +;; that creates fresh vars, builds the instantiated head/body, and calls +;; pl-unify! + pl-solve! directly — no AST walk at solve time. +;; +;; Usage: +;; (pl-db-load! db (pl-parse src)) +;; (pl-compile-db! db) +;; ; pl-solve-user! in runtime.sx automatically prefers compiled clauses +;; (pl-solve-once! db goal trail) + +;; Collect unique variable names from a parse-AST clause into a dict. +(define + pl-cmp-vars-into! + (fn + (ast seen) + (cond + ((not (list? ast)) nil) + ((empty? ast) nil) + ((= (first ast) "var") + (let + ((name (nth ast 1))) + (when + (and (not (= name "_")) (not (dict-has? seen name))) + (dict-set! seen name true)))) + ((= (first ast) "compound") + (for-each (fn (a) (pl-cmp-vars-into! a seen)) (nth ast 2))) + ((= (first ast) "clause") + (begin + (pl-cmp-vars-into! (nth ast 1) seen) + (pl-cmp-vars-into! (nth ast 2) seen)))))) + +;; Return list of unique var names in a clause (head + body, excluding _). +(define + pl-cmp-collect-vars + (fn + (clause) + (let ((seen {})) (pl-cmp-vars-into! clause seen) (keys seen)))) + +;; Create a fresh runtime var for each name in the list; return name->var dict. +(define + pl-cmp-make-var-map + (fn + (var-names) + (let + ((m {})) + (for-each + (fn (name) (dict-set! m name (pl-mk-rt-var name))) + var-names) + m))) + +;; Instantiate a parse-AST term using a pre-built var-map. +;; ("var" "_") always gets a fresh anonymous var. +(define + pl-cmp-build-term + (fn + (ast var-map) + (cond + ((pl-var? ast) ast) + ((not (list? ast)) ast) + ((empty? ast) ast) + ((= (first ast) "var") + (let + ((name (nth ast 1))) + (if (= name "_") (pl-mk-rt-var "_") (dict-get var-map name)))) + ((or (= (first ast) "atom") (= (first ast) "num") (= (first ast) "str")) + ast) + ((= (first ast) "compound") + (list + "compound" + (nth ast 1) + (map (fn (a) (pl-cmp-build-term a var-map)) (nth ast 2)))) + ((= (first ast) "clause") + (list + "clause" + (pl-cmp-build-term (nth ast 1) var-map) + (pl-cmp-build-term (nth ast 2) var-map))) + (true ast)))) + +;; Compile one parse-AST clause to a lambda. +;; Pre-computes var names at compile time; creates fresh vars per call. +(define + pl-compile-clause + (fn + (clause) + (let + ((var-names (pl-cmp-collect-vars clause)) + (head-ast (nth clause 1)) + (body-ast (nth clause 2))) + (fn + (goal trail db cut-box k) + (let + ((var-map (pl-cmp-make-var-map var-names))) + (let + ((fresh-head (pl-cmp-build-term head-ast var-map)) + (fresh-body (pl-cmp-build-term body-ast var-map))) + (let + ((mark (pl-trail-mark trail))) + (if + (pl-unify! goal fresh-head trail) + (let + ((r (pl-solve! db fresh-body trail cut-box k))) + (if r true (begin (pl-trail-undo-to! trail mark) false))) + (begin (pl-trail-undo-to! trail mark) false))))))))) + +;; Try a list of compiled clause lambdas — same cut semantics as pl-try-clauses!. +(define + pl-try-compiled-clauses! + (fn + (db + goal + trail + compiled-clauses + outer-cut-box + outer-was-cut + inner-cut-box + k) + (cond + ((empty? compiled-clauses) false) + (true + (let + ((r ((first compiled-clauses) goal trail db inner-cut-box k))) + (cond + (r true) + ((dict-get inner-cut-box :cut) false) + ((and (not outer-was-cut) (dict-get outer-cut-box :cut)) false) + (true + (pl-try-compiled-clauses! + db + goal + trail + (rest compiled-clauses) + outer-cut-box + outer-was-cut + inner-cut-box + k)))))))) + +;; Compile all clauses in DB and store in :compiled table. +;; After this call, pl-solve-user! will dispatch via compiled lambdas. +;; Note: clauses assert!-ed after this call are not compiled. +(define + pl-compile-db! + (fn + (db) + (let + ((src-table (dict-get db :clauses)) (compiled-table {})) + (for-each + (fn + (key) + (dict-set! + compiled-table + key + (map pl-compile-clause (dict-get src-table key)))) + (keys src-table)) + (dict-set! db :compiled compiled-table) + db))) diff --git a/lib/prolog/conformance.sh b/lib/prolog/conformance.sh index 4f840cf9..da9da278 100755 --- a/lib/prolog/conformance.sh +++ b/lib/prolog/conformance.sh @@ -41,13 +41,15 @@ SUITES=( "assert_rules:lib/prolog/tests/assert_rules.sx:pl-assert-rules-tests-run!" "string_agg:lib/prolog/tests/string_agg.sx:pl-string-agg-tests-run!" "advanced:lib/prolog/tests/advanced.sx:pl-advanced-tests-run!" + "compiler:lib/prolog/tests/compiler.sx:pl-compiler-tests-run!" ) SCRIPT='(epoch 1) (load "lib/prolog/tokenizer.sx") (load "lib/prolog/parser.sx") (load "lib/prolog/runtime.sx") -(load "lib/prolog/query.sx")' +(load "lib/prolog/query.sx") +(load "lib/prolog/compiler.sx")' for entry in "${SUITES[@]}"; do IFS=: read -r _ file _ <<< "$entry" SCRIPT+=$'\n(load "'"$file"$'")' diff --git a/lib/prolog/runtime.sx b/lib/prolog/runtime.sx index f9a1342f..257894a0 100644 --- a/lib/prolog/runtime.sx +++ b/lib/prolog/runtime.sx @@ -2704,15 +2704,28 @@ ((inner-cut-box {:cut false})) (let ((outer-was-cut (dict-get outer-cut-box :cut))) - (pl-try-clauses! - db - goal - trail - (pl-db-lookup-goal db goal) - outer-cut-box - outer-was-cut - inner-cut-box - k))))) + (let + ((compiled (when (dict-has? db :compiled) (dict-get db :compiled)))) + (if + (and compiled (dict-has? compiled (pl-goal-key goal))) + (pl-try-compiled-clauses! + db + goal + trail + (dict-get compiled (pl-goal-key goal)) + outer-cut-box + outer-was-cut + inner-cut-box + k) + (pl-try-clauses! + db + goal + trail + (pl-db-lookup-goal db goal) + outer-cut-box + outer-was-cut + inner-cut-box + k))))))) (define pl-try-clauses! diff --git a/lib/prolog/tests/compiler.sx b/lib/prolog/tests/compiler.sx new file mode 100644 index 00000000..cf85dd29 --- /dev/null +++ b/lib/prolog/tests/compiler.sx @@ -0,0 +1,185 @@ +;; lib/prolog/tests/compiler.sx — compiled clause dispatch tests + +(define pl-cmp-test-count 0) +(define pl-cmp-test-pass 0) +(define pl-cmp-test-fail 0) +(define pl-cmp-test-failures (list)) + +(define + pl-cmp-test! + (fn + (name got expected) + (set! pl-cmp-test-count (+ pl-cmp-test-count 1)) + (if + (= got expected) + (set! pl-cmp-test-pass (+ pl-cmp-test-pass 1)) + (begin + (set! pl-cmp-test-fail (+ pl-cmp-test-fail 1)) + (append! pl-cmp-test-failures name))))) + +;; Load src, compile, return DB. +(define + pl-cmp-mk + (fn + (src) + (let + ((db (pl-mk-db))) + (pl-db-load! db (pl-parse src)) + (pl-compile-db! db) + db))) + +;; Run goal string against compiled DB; return bool (instantiates vars). +(define + pl-cmp-once + (fn + (db src) + (pl-solve-once! + db + (pl-instantiate (pl-parse-goal src) {}) + (pl-mk-trail)))) + +;; Count solutions for goal string against compiled DB. +(define + pl-cmp-count + (fn + (db src) + (pl-solve-count! + db + (pl-instantiate (pl-parse-goal src) {}) + (pl-mk-trail)))) + +;; ── 1. Simple facts ────────────────────────────────────────────── + +(define pl-cmp-db1 (pl-cmp-mk "color(red). color(green). color(blue).")) + +(pl-cmp-test! "compiled fact hit" (pl-cmp-once pl-cmp-db1 "color(red)") true) +(pl-cmp-test! + "compiled fact miss" + (pl-cmp-once pl-cmp-db1 "color(yellow)") + false) +(pl-cmp-test! "compiled fact count" (pl-cmp-count pl-cmp-db1 "color(X)") 3) + +;; ── 2. Recursive rule: append ──────────────────────────────────── + +(define + pl-cmp-db2 + (pl-cmp-mk "append([], L, L). append([H|T], L, [H|R]) :- append(T, L, R).")) + +(pl-cmp-test! + "compiled append build" + (pl-cmp-once pl-cmp-db2 "append([1,2],[3],[1,2,3])") + true) +(pl-cmp-test! + "compiled append fail" + (pl-cmp-once pl-cmp-db2 "append([1,2],[3],[1,2])") + false) +(pl-cmp-test! + "compiled append split count" + (pl-cmp-count pl-cmp-db2 "append(X, Y, [a,b])") + 3) + +;; ── 3. Cut ─────────────────────────────────────────────────────── + +(define + pl-cmp-db3 + (pl-cmp-mk "first(X, [X|_]) :- !. first(X, [_|T]) :- first(X, T).")) + +(pl-cmp-test! + "compiled cut: only one solution" + (pl-cmp-count pl-cmp-db3 "first(X, [a,b,c])") + 1) + +(let + ((db pl-cmp-db3) (trail (pl-mk-trail)) (env {})) + (let + ((x (pl-mk-rt-var "X"))) + (dict-set! env "X" x) + (pl-solve-once! + db + (pl-instantiate (pl-parse-goal "first(X, [a,b,c])") env) + trail) + (pl-cmp-test! + "compiled cut: correct binding" + (pl-atom-name (pl-walk x)) + "a"))) + +;; ── 4. member ──────────────────────────────────────────────────── + +(define + pl-cmp-db4 + (pl-cmp-mk "member(X, [X|_]). member(X, [_|T]) :- member(X, T).")) + +(pl-cmp-test! + "compiled member hit" + (pl-cmp-once pl-cmp-db4 "member(b, [a,b,c])") + true) +(pl-cmp-test! + "compiled member miss" + (pl-cmp-once pl-cmp-db4 "member(d, [a,b,c])") + false) +(pl-cmp-test! + "compiled member count" + (pl-cmp-count pl-cmp-db4 "member(X, [a,b,c])") + 3) + +;; ── 5. Arithmetic in body ──────────────────────────────────────── + +(define pl-cmp-db5 (pl-cmp-mk "double(X, Y) :- Y is X * 2.")) + +(let + ((db pl-cmp-db5) (trail (pl-mk-trail)) (env {})) + (let + ((y (pl-mk-rt-var "Y"))) + (dict-set! env "Y" y) + (pl-solve-once! + db + (pl-instantiate (pl-parse-goal "double(5, Y)") env) + trail) + (pl-cmp-test! "compiled arithmetic in body" (pl-num-val (pl-walk y)) 10))) + +;; ── 6. Transitive ancestor ─────────────────────────────────────── + +(define + pl-cmp-db6 + (pl-cmp-mk + (str + "parent(a,b). parent(b,c). parent(c,d)." + "ancestor(X,Y) :- parent(X,Y)." + "ancestor(X,Y) :- parent(X,Z), ancestor(Z,Y)."))) + +(pl-cmp-test! + "compiled ancestor direct" + (pl-cmp-once pl-cmp-db6 "ancestor(a,b)") + true) +(pl-cmp-test! + "compiled ancestor 3-step" + (pl-cmp-once pl-cmp-db6 "ancestor(a,d)") + true) +(pl-cmp-test! + "compiled ancestor fail" + (pl-cmp-once pl-cmp-db6 "ancestor(d,a)") + false) + +;; ── 7. Fallback: uncompiled predicate calls compiled sub-predicate + +(define + pl-cmp-db7 + (let + ((db (pl-mk-db))) + (pl-db-load! db (pl-parse "q(1). q(2).")) + (pl-compile-db! db) + (pl-db-load! db (pl-parse "r(X) :- q(X).")) + db)) + +(pl-cmp-test! + "uncompiled predicate resolves" + (pl-cmp-once pl-cmp-db7 "r(1)") + true) +(pl-cmp-test! + "uncompiled calls compiled sub-pred count" + (pl-cmp-count pl-cmp-db7 "r(X)") + 2) + +;; ── Runner ─────────────────────────────────────────────────────── + +(define pl-compiler-tests-run! (fn () {:failed pl-cmp-test-fail :passed pl-cmp-test-pass :total pl-cmp-test-count :failures pl-cmp-test-failures}))