datalog: built-ins + body arithmetic + order-aware safety (Phase 4, 106/106)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s

New lib/datalog/builtins.sx: (< <= > >= = !=) and (is X expr) with
+ - * /. dl-eval-arith recursively evaluates nested compounds.
Safety analysis now walks body left-to-right tracking the bound
set: comparisons require all args bound, is RHS vars must be bound
(LHS becomes bound), = special-cases the var/non-var combos.
db.sx keeps the simple safety check as a forward-reference
fallback; builtins.sx redefines dl-rule-check-safety to the
comprehensive version. eval.sx dispatches built-ins through
dl-eval-builtin instead of erroring. 19 new tests.
This commit is contained in:
2026-05-07 23:51:21 +00:00
parent 6457eb668c
commit 7ce723f732
8 changed files with 617 additions and 110 deletions

View File

@@ -1,4 +1,4 @@
;; lib/datalog/db.sx — Datalog database (EDB + IDB + rules) + safety.
;; lib/datalog/db.sx — Datalog database (EDB + IDB + rules) + safety hook.
;;
;; A db is a mutable dict:
;; {:facts {<rel-name-string> -> (literal ...)}
@@ -8,13 +8,12 @@
;; directly against rule body literals. Each relation's tuple list is
;; deduplicated on insert.
;;
;; Phase 3 makes no distinction between EDB (asserted) and IDB (derived);
;; a future phase may track provenance for retraction.
;; Phase 3 introduced safety analysis for head variables; Phase 4 (in
;; lib/datalog/builtins.sx) swaps in the real `dl-rule-check-safety`,
;; which is order-aware and understands built-in predicates.
(define dl-make-db (fn () {:facts {} :rules (list)}))
;; The relation name (as string) of a positive literal, or of the
;; underlying literal of a negative one. nil for malformed input.
(define
dl-rel-name
(fn
@@ -59,8 +58,6 @@
((and (list? lit) (> (len lit) 0)) true)
(else false))))
;; Membership using dl-deep tuple equality (handles var/constant symbols
;; and numbers consistently).
(define
dl-tuple-equal?
(fn
@@ -89,7 +86,6 @@
((dl-tuple-equal? lit (first lits)) true)
(else (dl-tuple-member? lit (rest lits))))))
;; Ensure :facts has a list for the given relation key, then return it.
(define
dl-ensure-rel!
(fn
@@ -102,8 +98,6 @@
(dict-set! facts rel-key (list)))
(get facts rel-key)))))
;; All tuples currently known for a relation (EDB IDB). Returns empty
;; list when relation hasn't been seen.
(define
dl-rel-tuples
(fn
@@ -112,7 +106,6 @@
((facts (get db :facts)))
(if (has-key? facts rel-key) (get facts rel-key) (list)))))
;; Add a ground literal. Returns true iff the literal was new.
(define
dl-add-fact!
(fn
@@ -131,72 +124,53 @@
((dl-tuple-member? lit tuples) false)
(else (do (append! tuples lit) true)))))))))
;; Collect variables appearing in the positive body literals of `body`.
;; The full safety check lives in builtins.sx (it has to know which
;; predicates are built-ins). dl-add-rule! calls it via forward
;; reference; load builtins.sx alongside db.sx in any setup that
;; adds rules. The fallback below is used if builtins.sx isn't loaded.
(define
dl-positive-body-vars
(fn
(body)
(let
((vars (list)))
(do
(for-each
(fn
(lit)
(when
(dl-positive-lit? lit)
(for-each
(fn
(v)
(when (not (dl-member-string? v vars)) (append! vars v)))
(dl-vars-of lit))))
body)
vars))))
;; Collect variables in any literal (positive, negated, or built-in).
(define
dl-all-body-vars
(fn
(body)
(let
((vars (list)))
(do
(for-each
(fn
(lit)
(let
((target (if (and (dict? lit) (has-key? lit :neg)) (get lit :neg) lit)))
(for-each
(fn
(v)
(when (not (dl-member-string? v vars)) (append! vars v)))
(dl-vars-of target))))
body)
vars))))
;; Return the list of head variables NOT covered by some positive body
;; literal. A safe rule has an empty list. The check ignores '_' since
;; that is treated as a fresh anonymous variable per occurrence.
(define
dl-rule-unsafe-head-vars
dl-rule-check-safety
(fn
(rule)
(let
((head (get rule :head))
(body (get rule :body))
(head-vars (dl-vars-of head))
(body-vars (dl-positive-body-vars body))
(out (list)))
((head-vars (dl-vars-of (get rule :head))) (body-vars (list)))
(do
(for-each
(fn
(v)
(lit)
(when
(and (not (dl-member-string? v body-vars)) (not (= v "_")))
(append! out v)))
head-vars)
out))))
(and
(list? lit)
(> (len lit) 0)
(not (and (dict? lit) (has-key? lit :neg))))
(for-each
(fn
(v)
(when
(not (dl-member-string? v body-vars))
(append! body-vars v)))
(dl-vars-of lit))))
(get rule :body))
(let
((missing (list)))
(do
(for-each
(fn
(v)
(when
(and
(not (dl-member-string? v body-vars))
(not (= v "_")))
(append! missing v)))
head-vars)
(cond
((> (len missing) 0)
(str
"head variable(s) "
missing
" do not appear in any body literal"))
(else nil))))))))
;; Add a rule. Rejects unsafe rules with a pointer at the offending var.
(define
dl-add-rule!
(fn
@@ -208,21 +182,14 @@
(error (str "dl-add-rule!: rule missing :head, got " rule)))
(else
(let
((unsafe (dl-rule-unsafe-head-vars rule)))
((err (dl-rule-check-safety rule)))
(cond
((> (len unsafe) 0)
(error
(str
"dl-add-rule!: unsafe rule — head variable(s) "
unsafe
" do not appear in any positive body literal of "
rule)))
((not (nil? err)) (error (str "dl-add-rule!: " err)))
(else
(let
((rules (get db :rules)))
(do (append! rules rule) true)))))))))
;; Load a parsed clause (fact or rule) into the db. Queries are ignored.
(define
dl-add-clause!
(fn
@@ -233,7 +200,6 @@
(dl-add-fact! db (get clause :head)))
(else (dl-add-rule! db clause)))))
;; Parse source text and load every clause into the db. Returns the db.
(define
dl-load-program!
(fn
@@ -242,14 +208,12 @@
((clauses (dl-parse source)))
(do (for-each (fn (c) (dl-add-clause! db c)) clauses) db))))
;; Convenience: build a db from source in one step.
(define
dl-program
(fn (source) (let ((db (dl-make-db))) (dl-load-program! db source))))
(define dl-rules (fn (db) (get db :rules)))
;; Total number of stored ground tuples across all relations.
(define
dl-fact-count
(fn