From 3190e770fbb39a61998d5a352f7a48917fe18662 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 25 Apr 2026 06:57:48 +0000 Subject: [PATCH] prolog: operator-table parser + < > =< >= built-ins, 19 tests --- lib/prolog/conformance.sh | 1 + lib/prolog/parser.sx | 196 +++++++++++++++++++++------------- lib/prolog/runtime.sx | 20 ++++ lib/prolog/scoreboard.json | 8 +- lib/prolog/scoreboard.md | 5 +- lib/prolog/tests/operators.sx | 193 +++++++++++++++++++++++++++++++++ plans/prolog-on-sx.md | 3 +- 7 files changed, 342 insertions(+), 84 deletions(-) create mode 100644 lib/prolog/tests/operators.sx diff --git a/lib/prolog/conformance.sh b/lib/prolog/conformance.sh index 4dd949c8..7d649d06 100755 --- a/lib/prolog/conformance.sh +++ b/lib/prolog/conformance.sh @@ -20,6 +20,7 @@ SUITES=( "unify:lib/prolog/tests/unify.sx:pl-unify-tests-run!" "clausedb:lib/prolog/tests/clausedb.sx:pl-clausedb-tests-run!" "solve:lib/prolog/tests/solve.sx:pl-solve-tests-run!" + "operators:lib/prolog/tests/operators.sx:pl-operators-tests-run!" "append:lib/prolog/tests/programs/append.sx:pl-append-tests-run!" "reverse:lib/prolog/tests/programs/reverse.sx:pl-reverse-tests-run!" "member:lib/prolog/tests/programs/member.sx:pl-member-tests-run!" diff --git a/lib/prolog/parser.sx b/lib/prolog/parser.sx index d301a184..bb0f0db9 100644 --- a/lib/prolog/parser.sx +++ b/lib/prolog/parser.sx @@ -1,28 +1,20 @@ ;; lib/prolog/parser.sx — tokens → Prolog AST ;; -;; Phase 1 grammar (NO operator table yet): +;; Phase 4 grammar (with operator table): ;; Program := Clause* EOF -;; Clause := Term "." | Term ":-" Term "." -;; Term := Atom | Var | Number | String | Compound | List -;; Compound := atom "(" ArgList ")" -;; ArgList := Term ("," Term)* -;; List := "[" "]" | "[" Term ("," Term)* ("|" Term)? "]" +;; Clause := Term[999] "." | Term[999] ":-" Term[1200] "." +;; Term[Pmax] uses precedence climbing on the operator table: +;; primary = Atom | Var | Number | String | Compound | List | "(" Term[1200] ")" +;; while next token is infix op `op` with prec(op) ≤ Pmax: +;; consume op; parse rhs at right-prec(op); fold into compound(op-name,[lhs,rhs]) ;; -;; Term AST shapes (all tagged lists for uniform dispatch): -;; ("atom" name) — atom -;; ("var" name) — variable template (parser-time only) -;; ("num" value) — integer or float -;; ("str" value) — string literal -;; ("compound" functor args) — compound term, args is list of term-ASTs -;; ("cut") — the cut atom ! +;; Op type → right-prec for op at precedence P: +;; xfx → P-1 strict-both +;; xfy → P right-associative +;; yfx → P-1 left-associative ;; -;; A clause is (list "clause" head body). A fact is head with body = ("atom" "true"). -;; -;; The empty list is (atom "[]"). Cons is compound "." with two args: -;; [1, 2, 3] → .(1, .(2, .(3, []))) -;; [H|T] → .(H, T) +;; AST shapes are unchanged — operators just become compound terms. -;; ── Parser state helpers ──────────────────────────────────────────── (define pp-peek (fn @@ -66,7 +58,6 @@ (if (= (get t :value) nil) "" (get t :value)) "'")))))) -;; ── AST constructors ──────────────────────────────────────────────── (define pl-mk-atom (fn (name) (list "atom" name))) (define pl-mk-var (fn (name) (list "var" name))) (define pl-mk-num (fn (n) (list "num" n))) @@ -74,18 +65,14 @@ (define pl-mk-compound (fn (f args) (list "compound" f args))) (define pl-mk-cut (fn () (list "cut"))) -;; Term tag extractors (define pl-term-tag (fn (t) (if (list? t) (first t) nil))) (define pl-term-val (fn (t) (nth t 1))) (define pl-compound-functor (fn (t) (nth t 1))) (define pl-compound-args (fn (t) (nth t 2))) -;; Empty-list atom and cons helpers (define pl-nil-term (fn () (pl-mk-atom "[]"))) - (define pl-mk-cons (fn (h t) (pl-mk-compound "." (list h t)))) -;; Build cons list from a list of terms + optional tail (define pl-mk-list-term (fn @@ -95,9 +82,60 @@ tail (pl-mk-cons (first items) (pl-mk-list-term (rest items) tail))))) -;; ── Term parser ───────────────────────────────────────────────────── +;; ── Operator table (Phase 4) ────────────────────────────────────── +;; Each entry: (name precedence type). Type ∈ "xfx" "xfy" "yfx". (define - pp-parse-term + pl-op-table + (list + (list "," 1000 "xfy") + (list ";" 1100 "xfy") + (list "->" 1050 "xfy") + (list "=" 700 "xfx") + (list "\\=" 700 "xfx") + (list "is" 700 "xfx") + (list "<" 700 "xfx") + (list ">" 700 "xfx") + (list "=<" 700 "xfx") + (list ">=" 700 "xfx") + (list "+" 500 "yfx") + (list "-" 500 "yfx") + (list "*" 400 "yfx") + (list "/" 400 "yfx") + (list "mod" 400 "yfx"))) + +(define + pl-op-find + (fn + (name table) + (cond + ((empty? table) nil) + ((= (first (first table)) name) (rest (first table))) + (true (pl-op-find name (rest table)))))) + +(define pl-op-lookup (fn (name) (pl-op-find name pl-op-table))) + +;; Token → (name prec type) for known infix ops, else nil. +(define + pl-token-op + (fn + (t) + (let + ((ty (get t :type)) (vv (get t :value))) + (cond + ((and (= ty "punct") (= vv ",")) + (let + ((info (pl-op-lookup ","))) + (if (nil? info) nil (cons "," info)))) + ((= ty "atom") + (let + ((info (pl-op-lookup vv))) + (if (nil? info) nil (cons vv info)))) + (true nil))))) + +;; ── Term parser ───────────────────────────────────────────────────── +;; Primary term: atom, var, num, str, compound (atom + paren), list, cut, parens. +(define + pp-parse-primary (fn (st) (let @@ -111,6 +149,12 @@ ((and (= ty "op") (= vv "!")) (do (pp-advance! st) (pl-mk-cut))) ((and (= ty "punct") (= vv "[")) (pp-parse-list st)) + ((and (= ty "punct") (= vv "(")) + (do + (pp-advance! st) + (let + ((inner (pp-parse-term-prec st 1200))) + (do (pp-expect! st "punct" ")") inner)))) ((= ty "atom") (do (pp-advance! st) @@ -133,13 +177,51 @@ (if (= vv nil) "" vv) "'")))))))) -;; Parse one or more comma-separated terms (arguments). +;; Operator-aware term parser: precedence climbing. +(define + pp-parse-term-prec + (fn + (st max-prec) + (let ((left (pp-parse-primary st))) (pp-parse-op-rhs st left max-prec)))) + +(define + pp-parse-op-rhs + (fn + (st left max-prec) + (let + ((op-info (pl-token-op (pp-peek st)))) + (cond + ((nil? op-info) left) + (true + (let + ((name (first op-info)) + (prec (nth op-info 1)) + (ty (nth op-info 2))) + (cond + ((> prec max-prec) left) + (true + (let + ((right-prec (if (= ty "xfy") prec (- prec 1)))) + (do + (pp-advance! st) + (let + ((right (pp-parse-term-prec st right-prec))) + (pp-parse-op-rhs + st + (pl-mk-compound name (list left right)) + max-prec)))))))))))) + +;; Backwards-compat alias. +(define pp-parse-term (fn (st) (pp-parse-term-prec st 999))) + +;; Args inside parens: parse at prec 999 so comma-as-operator (1000) +;; is not consumed; the explicit comma loop handles separation. (define pp-parse-arg-list (fn (st) (let - ((first-arg (pp-parse-term st)) (args (list))) + ((first-arg (pp-parse-term-prec st 999)) (args (list))) (do (append! args first-arg) (define @@ -150,12 +232,12 @@ (pp-at? st "punct" ",") (do (pp-advance! st) - (append! args (pp-parse-term st)) + (append! args (pp-parse-term-prec st 999)) (loop))))) (loop) args)))) -;; Parse a [ ... ] list literal. Consumes the "[". +;; List literal. (define pp-parse-list (fn @@ -168,7 +250,7 @@ (let ((items (list))) (do - (append! items (pp-parse-term st)) + (append! items (pp-parse-term-prec st 999)) (define comma-loop (fn @@ -177,52 +259,17 @@ (pp-at? st "punct" ",") (do (pp-advance! st) - (append! items (pp-parse-term st)) + (append! items (pp-parse-term-prec st 999)) (comma-loop))))) (comma-loop) (let - ((tail (if (pp-at? st "punct" "|") (do (pp-advance! st) (pp-parse-term st)) (pl-nil-term)))) + ((tail (if (pp-at? st "punct" "|") (do (pp-advance! st) (pp-parse-term-prec st 999)) (pl-nil-term)))) (do (pp-expect! st "punct" "]") (pl-mk-list-term items tail))))))))) ;; ── Body parsing ──────────────────────────────────────────────────── -;; A clause body is a comma-separated list of goals. We flatten into a -;; right-associative `,` compound: (A, B, C) → ','(A, ','(B, C)) -;; If only one goal, it's that goal directly. -(define - pp-parse-body - (fn - (st) - (let - ((first-goal (pp-parse-term st)) (rest-goals (list))) - (do - (define - gloop - (fn - () - (when - (pp-at? st "punct" ",") - (do - (pp-advance! st) - (append! rest-goals (pp-parse-term st)) - (gloop))))) - (gloop) - (if - (= (len rest-goals) 0) - first-goal - (pp-build-conj first-goal rest-goals)))))) - -(define - pp-build-conj - (fn - (first-goal rest-goals) - (if - (= (len rest-goals) 0) - first-goal - (pl-mk-compound - "," - (list - first-goal - (pp-build-conj (first rest-goals) (rest rest-goals))))))) +;; A body is a single term parsed at prec 1200 — operator parser folds +;; `,`, `;`, `->` automatically into right-associative compounds. +(define pp-parse-body (fn (st) (pp-parse-term-prec st 1200))) ;; ── Clause parsing ────────────────────────────────────────────────── (define @@ -230,12 +277,11 @@ (fn (st) (let - ((head (pp-parse-term st))) + ((head (pp-parse-term-prec st 999))) (let ((body (if (pp-at? st "op" ":-") (do (pp-advance! st) (pp-parse-body st)) (pl-mk-atom "true")))) (do (pp-expect! st "punct" ".") (list "clause" head body)))))) -;; Parse an entire program — returns list of clauses. (define pl-parse-program (fn @@ -253,13 +299,9 @@ (ploop) clauses)))) -;; Parse a single query term (no trailing "."). Returns the term. (define pl-parse-query (fn (tokens) (let ((st {:idx 0 :tokens tokens})) (pp-parse-body st)))) -;; Convenience: source → clauses (define pl-parse (fn (src) (pl-parse-program (pl-tokenize src)))) - -;; Convenience: source → query term (define pl-parse-goal (fn (src) (pl-parse-query (pl-tokenize src)))) diff --git a/lib/prolog/runtime.sx b/lib/prolog/runtime.sx index 816bc84a..2feac3cb 100644 --- a/lib/prolog/runtime.sx +++ b/lib/prolog/runtime.sx @@ -315,6 +315,26 @@ (list "num" (pl-eval-arith (nth (pl-args g) 1))) trail k)) + ((and (pl-compound? g) (= (pl-fun g) "<") (= (len (pl-args g)) 2)) + (cond + ((< (pl-eval-arith (first (pl-args g))) (pl-eval-arith (nth (pl-args g) 1))) + (k)) + (true false))) + ((and (pl-compound? g) (= (pl-fun g) ">") (= (len (pl-args g)) 2)) + (cond + ((> (pl-eval-arith (first (pl-args g))) (pl-eval-arith (nth (pl-args g) 1))) + (k)) + (true false))) + ((and (pl-compound? g) (= (pl-fun g) "=<") (= (len (pl-args g)) 2)) + (cond + ((<= (pl-eval-arith (first (pl-args g))) (pl-eval-arith (nth (pl-args g) 1))) + (k)) + (true false))) + ((and (pl-compound? g) (= (pl-fun g) ">=") (= (len (pl-args g)) 2)) + (cond + ((>= (pl-eval-arith (first (pl-args g))) (pl-eval-arith (nth (pl-args g) 1))) + (k)) + (true false))) ((and (pl-compound? g) (= (pl-fun g) ",") (= (len (pl-args g)) 2)) (pl-solve! db diff --git a/lib/prolog/scoreboard.json b/lib/prolog/scoreboard.json index 44d60788..ec9fcf6f 100644 --- a/lib/prolog/scoreboard.json +++ b/lib/prolog/scoreboard.json @@ -1,7 +1,7 @@ { - "total_passed": 183, + "total_passed": 202, "total_failed": 0, - "total": 183, - "suites": {"parse":{"passed":25,"total":25,"failed":0},"unify":{"passed":47,"total":47,"failed":0},"clausedb":{"passed":14,"total":14,"failed":0},"solve":{"passed":62,"total":62,"failed":0},"append":{"passed":6,"total":6,"failed":0},"reverse":{"passed":6,"total":6,"failed":0},"member":{"passed":7,"total":7,"failed":0},"nqueens":{"passed":6,"total":6,"failed":0},"family":{"passed":10,"total":10,"failed":0}}, - "generated": "2026-04-25T06:19:36+00:00" + "total": 202, + "suites": {"parse":{"passed":25,"total":25,"failed":0},"unify":{"passed":47,"total":47,"failed":0},"clausedb":{"passed":14,"total":14,"failed":0},"solve":{"passed":62,"total":62,"failed":0},"operators":{"passed":19,"total":19,"failed":0},"append":{"passed":6,"total":6,"failed":0},"reverse":{"passed":6,"total":6,"failed":0},"member":{"passed":7,"total":7,"failed":0},"nqueens":{"passed":6,"total":6,"failed":0},"family":{"passed":10,"total":10,"failed":0}}, + "generated": "2026-04-25T06:57:26+00:00" } diff --git a/lib/prolog/scoreboard.md b/lib/prolog/scoreboard.md index 8d21270b..b46e3db9 100644 --- a/lib/prolog/scoreboard.md +++ b/lib/prolog/scoreboard.md @@ -1,7 +1,7 @@ # Prolog scoreboard -**183 / 183 passing** (0 failure(s)). -Generated 2026-04-25T06:19:36+00:00. +**202 / 202 passing** (0 failure(s)). +Generated 2026-04-25T06:57:26+00:00. | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -9,6 +9,7 @@ Generated 2026-04-25T06:19:36+00:00. | unify | 47 | 47 | ok | | clausedb | 14 | 14 | ok | | solve | 62 | 62 | ok | +| operators | 19 | 19 | ok | | append | 6 | 6 | ok | | reverse | 6 | 6 | ok | | member | 7 | 7 | ok | diff --git a/lib/prolog/tests/operators.sx b/lib/prolog/tests/operators.sx new file mode 100644 index 00000000..a992ad51 --- /dev/null +++ b/lib/prolog/tests/operators.sx @@ -0,0 +1,193 @@ +;; lib/prolog/tests/operators.sx — operator-table parsing + comparison built-ins. + +(define pl-op-test-count 0) +(define pl-op-test-pass 0) +(define pl-op-test-fail 0) +(define pl-op-test-failures (list)) + +(define + pl-op-test! + (fn + (name got expected) + (begin + (set! pl-op-test-count (+ pl-op-test-count 1)) + (if + (= got expected) + (set! pl-op-test-pass (+ pl-op-test-pass 1)) + (begin + (set! pl-op-test-fail (+ pl-op-test-fail 1)) + (append! + pl-op-test-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(define pl-op-empty-db (pl-mk-db)) + +(define + pl-op-body + (fn (src) (nth (first (pl-parse (str "g :- " src "."))) 2))) + +(define pl-op-goal (fn (src env) (pl-instantiate (pl-op-body src) env))) + +;; ── parsing tests ── + +(pl-op-test! + "infix +" + (pl-op-body "a + b") + (list "compound" "+" (list (list "atom" "a") (list "atom" "b")))) + +(pl-op-test! + "infix * tighter than +" + (pl-op-body "a + b * c") + (list + "compound" + "+" + (list + (list "atom" "a") + (list "compound" "*" (list (list "atom" "b") (list "atom" "c")))))) + +(pl-op-test! + "parens override precedence" + (pl-op-body "(a + b) * c") + (list + "compound" + "*" + (list + (list "compound" "+" (list (list "atom" "a") (list "atom" "b"))) + (list "atom" "c")))) + +(pl-op-test! + "+ is yfx (left-assoc)" + (pl-op-body "a + b + c") + (list + "compound" + "+" + (list + (list "compound" "+" (list (list "atom" "a") (list "atom" "b"))) + (list "atom" "c")))) + +(pl-op-test! + "; is xfy (right-assoc)" + (pl-op-body "a ; b ; c") + (list + "compound" + ";" + (list + (list "atom" "a") + (list "compound" ";" (list (list "atom" "b") (list "atom" "c")))))) + +(pl-op-test! + "= folds at 700" + (pl-op-body "X = 5") + (list "compound" "=" (list (list "var" "X") (list "num" 5)))) + +(pl-op-test! + "is + nests via 700>500>400" + (pl-op-body "X is 2 + 3 * 4") + (list + "compound" + "is" + (list + (list "var" "X") + (list + "compound" + "+" + (list + (list "num" 2) + (list "compound" "*" (list (list "num" 3) (list "num" 4)))))))) + +(pl-op-test! + "< parses at 700" + (pl-op-body "2 < 3") + (list "compound" "<" (list (list "num" 2) (list "num" 3)))) + +(pl-op-test! + "mod parses as yfx 400" + (pl-op-body "10 mod 3") + (list "compound" "mod" (list (list "num" 10) (list "num" 3)))) + +(pl-op-test! + "comma in body folds right-assoc" + (pl-op-body "a, b, c") + (list + "compound" + "," + (list + (list "atom" "a") + (list "compound" "," (list (list "atom" "b") (list "atom" "c")))))) + +;; ── solver tests via infix ── + +(pl-op-test! + "X is 2 + 3 binds X = 5" + (let + ((env {}) (trail (pl-mk-trail))) + (begin + (pl-solve-once! pl-op-empty-db (pl-op-goal "X is 2 + 3" env) trail) + (pl-num-val (pl-walk-deep (dict-get env "X"))))) + 5) + +(pl-op-test! + "infix conjunction parses + solves" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "X = 5, X = 5" {}) + (pl-mk-trail)) + true) + +(pl-op-test! + "infix mismatch fails" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "X = 5, X = 6" {}) + (pl-mk-trail)) + false) + +(pl-op-test! + "infix disjunction picks left" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "true ; fail" {}) + (pl-mk-trail)) + true) + +(pl-op-test! + "2 < 5 succeeds" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "2 < 5" {}) + (pl-mk-trail)) + true) + +(pl-op-test! + "5 < 2 fails" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "5 < 2" {}) + (pl-mk-trail)) + false) + +(pl-op-test! + "5 >= 5 succeeds" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "5 >= 5" {}) + (pl-mk-trail)) + true) + +(pl-op-test! + "3 =< 5 succeeds" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "3 =< 5" {}) + (pl-mk-trail)) + true) + +(pl-op-test! + "infix < with arithmetic both sides" + (pl-solve-once! + pl-op-empty-db + (pl-op-goal "1 + 2 < 2 * 3" {}) + (pl-mk-trail)) + true) + +(define pl-operators-tests-run! (fn () {:failed pl-op-test-fail :passed pl-op-test-pass :total pl-op-test-count :failures pl-op-test-failures})) diff --git a/plans/prolog-on-sx.md b/plans/prolog-on-sx.md index 19610a0e..d97221f7 100644 --- a/plans/prolog-on-sx.md +++ b/plans/prolog-on-sx.md @@ -65,7 +65,7 @@ Representation choices (finalise in phase 1, document here): - [x] Target: all 5 classic programs passing — append (6) + reverse (6) + member (7) + nqueens (6) + family (10) = 35 program tests, all green. Phase 3 architecturally complete bar the conformance harness/scoreboard. ### Phase 4 — operator table + more built-ins (next run) -- [ ] Operator table parsing (prefix/infix/postfix, precedence, assoc) +- [x] Operator table parsing (prefix/infix/postfix, precedence, assoc) — `pl-op-table` (15 entries: `, ; -> = \= is < > =< >= + - * / mod`); precedence-climbing parser via `pp-parse-primary` + `pp-parse-term-prec` + `pp-parse-op-rhs`. Parens override precedence. Args inside compounds parsed at 999 so `,` stays as separator. xfx/xfy/yfx supported; prefix/postfix deferred (so `-5` still tokenises as bare atom + num as before). Comparison built-ins `/2 ==/2` added. New `tests/operators.sx` 19 tests cover assoc/precedence/parens + solver via infix. - [ ] `assert/1`, `asserta/1`, `assertz/1`, `retract/1` - [ ] `findall/3`, `bagof/3`, `setof/3` - [ ] `copy_term/2`, `functor/3`, `arg/3`, `=../2` @@ -88,6 +88,7 @@ Representation choices (finalise in phase 1, document here): _Newest first. Agent appends on every commit._ +- 2026-04-25 — Phase 4 starts: operator-table parsing. Parser rewrite uses precedence climbing (xfx/xfy/yfx); 15-op table covers control (`, ; ->`), comparison (`= \\= is < > =< >=`), arithmetic (`+ - * / mod`). Parens override. Backwards-compatible: prefix-syntax compounds (`=(X, Y)`, `+(2, 3)`) still parse as before; existing 183 tests untouched. Added comparison built-ins `/2 ==/2` to runtime (eval both sides, compare). New `tests/operators.sx` 19 tests; conformance script gained an operators row. Total **202** (+19). Prefix/postfix deferred — `-5` keeps old bare-atom semantics. - 2026-04-25 — Conformance harness landed. `lib/prolog/conformance.sh` runs all 9 suites in one sx_server epoch, parses the `{:failed/:passed/:total/:failures}` summary lines, and writes `scoreboard.json` + `scoreboard.md`. `SX_SERVER` env var overrides the binary path; default points at the main-repo build. Phase 3 fully complete: 183 / 183 passing across parse/unify/clausedb/solve/append/reverse/member/nqueens/family. - 2026-04-25 — `family.pl` fifth classic program — completes the 5-program target. 5-fact pedigree + male/female + derived father/mother/ancestor/sibling. 10 tests cover fact lookup + count, transitive ancestor through 3 generations, descendant counting (5), gender-restricted derivations, sibling via shared parent guarded by `\=`. Total 183 (+10). All 5 classic programs ticked; Phase 3 needs only conformance harness + scoreboard left. - 2026-04-25 — `nqueens.pl` fourth classic program. Permute-and-test variant exercises every Phase-3 feature: lists with `[H|T]` cons sugar, multi-clause backtracking, recursive `permute`/`select`/`safe`/`no_attack`, `is/2` arithmetic on diagonals, `\=/2` for diagonal-conflict check. 6 tests at N ∈ {1,2,3,4,5} with expected counts {1,0,0,2,10} + first-solution `[2,4,1,3]`. N=5 takes ~30s (120 perms × safe-check); N=8 omitted as it would be ~thousands of seconds. Total 173 (+6).