Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s
A "mixed" relation has both user-asserted facts AND rules with the
same head. Previously dl-retract! wiped every rule-head relation
wholesale before re-saturating — the saturator only re-derives the
IDB portion, so explicit EDB facts vanished even for a no-op retract
of a non-existent tuple. Repro:
(let ((db (dl-program "p(a). p(b). p(X) :- q(X). q(c).")))
(dl-retract! db (quote (p z)))
(dl-query db (quote (p X))))
went from {a, b, c} to just {c}.
Fix: track :edb-keys provenance in the db.
- dl-make-db now allocates an :edb-keys dict.
- dl-add-fact! (public) marks (rel-key, tuple-key) in :edb-keys.
- New internal dl-add-derived! does the append without marking.
- Saturator (semi-naive + naive driver) now calls dl-add-derived!.
- dl-retract! strips only the IDB-derived portion of rule-head
relations (anything not in :edb-keys) and preserves the EDB
portion through the re-saturate pass.
2 new regression tests; conformance 262/262.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
10 KiB
Plaintext
304 lines
10 KiB
Plaintext
;; lib/datalog/api.sx — SX-data embedding API.
|
|
;;
|
|
;; Where Phase 1's `dl-program` takes a Datalog source string,
|
|
;; this module exposes a parser-free API that consumes SX data
|
|
;; directly. Two rule shapes are accepted:
|
|
;;
|
|
;; - dict: {:head <literal> :body (<literal> ...)}
|
|
;; - list: (<head-elements...> <- <body-literal> ...)
|
|
;; — `<-` is an SX symbol used as the rule arrow.
|
|
;;
|
|
;; Examples:
|
|
;;
|
|
;; (dl-program-data
|
|
;; '((parent tom bob) (parent tom liz) (parent bob ann))
|
|
;; '((ancestor X Y <- (parent X Y))
|
|
;; (ancestor X Z <- (parent X Y) (ancestor Y Z))))
|
|
;;
|
|
;; (dl-query db '(ancestor tom X)) ; same query API as before
|
|
;;
|
|
;; Variables follow the parser convention: SX symbols whose first
|
|
;; character is uppercase or `_` are variables.
|
|
|
|
(define
|
|
dl-rule
|
|
(fn (head body) {:head head :body body}))
|
|
|
|
(define
|
|
dl-rule-arrow?
|
|
(fn
|
|
(x)
|
|
(and (symbol? x) (= (symbol->string x) "<-"))))
|
|
|
|
(define
|
|
dl-find-arrow
|
|
(fn
|
|
(rl i n)
|
|
(cond
|
|
((>= i n) nil)
|
|
((dl-rule-arrow? (nth rl i)) i)
|
|
(else (dl-find-arrow rl (+ i 1) n)))))
|
|
|
|
;; Given a list of the form (head-elt ... <- body-lit ...) returns
|
|
;; {:head (head-elt ...) :body (body-lit ...)}. If no arrow is
|
|
;; present, the whole list is treated as the head and the body is
|
|
;; empty (i.e. a fact written rule-style).
|
|
(define
|
|
dl-rule-from-list
|
|
(fn
|
|
(rl)
|
|
(let ((n (len rl)))
|
|
(let ((idx (dl-find-arrow rl 0 n)))
|
|
(cond
|
|
((nil? idx) {:head rl :body (list)})
|
|
(else
|
|
(let
|
|
((head (slice rl 0 idx))
|
|
(body (slice rl (+ idx 1) n)))
|
|
{:head head :body body})))))))
|
|
|
|
;; Coerce a rule given as either a dict or a list-with-arrow to a dict.
|
|
(define
|
|
dl-coerce-rule
|
|
(fn
|
|
(r)
|
|
(cond
|
|
((dict? r) r)
|
|
((list? r) (dl-rule-from-list r))
|
|
(else (error (str "dl-coerce-rule: expected dict or list, got " r))))))
|
|
|
|
;; Build a db from SX data lists.
|
|
(define
|
|
dl-program-data
|
|
(fn
|
|
(facts rules)
|
|
(let ((db (dl-make-db)))
|
|
(do
|
|
(for-each (fn (lit) (dl-add-fact! db lit)) facts)
|
|
(for-each
|
|
(fn (r) (dl-add-rule! db (dl-coerce-rule r)))
|
|
rules)
|
|
db))))
|
|
|
|
;; Add a single fact at runtime, then re-saturate the db so derived
|
|
;; tuples reflect the change. Returns the db.
|
|
(define
|
|
dl-assert!
|
|
(fn
|
|
(db lit)
|
|
(do
|
|
(dl-add-fact! db lit)
|
|
(dl-saturate! db)
|
|
db)))
|
|
|
|
;; Remove a fact and re-saturate. Mixed relations (which have BOTH
|
|
;; user-asserted facts AND rules) are supported via :edb-keys provenance
|
|
;; — explicit facts are marked at dl-add-fact! time, the saturator uses
|
|
;; dl-add-derived! which doesn't mark them, so the retract pass can
|
|
;; safely wipe IDB-derived tuples while preserving the user's EDB.
|
|
;;
|
|
;; Effect:
|
|
;; - remove tuples matching `lit` from :facts and :edb-keys
|
|
;; - for every relation that has a rule (i.e. potentially IDB or
|
|
;; mixed), drop the IDB-derived portion (anything not in :edb-keys)
|
|
;; so the saturator can re-derive cleanly
|
|
;; - re-saturate
|
|
(define
|
|
dl-retract!
|
|
(fn
|
|
(db lit)
|
|
(let
|
|
((rel-key (dl-rel-name lit)))
|
|
(do
|
|
;; Drop the matching tuple from its relation list, its facts-keys,
|
|
;; its first-arg index, AND from :edb-keys (if present).
|
|
(when
|
|
(has-key? (get db :facts) rel-key)
|
|
(let
|
|
((existing (get (get db :facts) rel-key))
|
|
(kept (list))
|
|
(kept-keys {})
|
|
(kept-index {})
|
|
(edb-rel (cond
|
|
((has-key? (get db :edb-keys) rel-key)
|
|
(get (get db :edb-keys) rel-key))
|
|
(else nil)))
|
|
(kept-edb {}))
|
|
(do
|
|
(for-each
|
|
(fn
|
|
(t)
|
|
(when
|
|
(not (dl-tuple-equal? t lit))
|
|
(do
|
|
(append! kept t)
|
|
(let ((tk (dl-tuple-key t)))
|
|
(do
|
|
(dict-set! kept-keys tk true)
|
|
(when
|
|
(and (not (nil? edb-rel))
|
|
(has-key? edb-rel tk))
|
|
(dict-set! kept-edb tk true))))
|
|
(when
|
|
(>= (len t) 2)
|
|
(let ((k (dl-arg-key (nth t 1))))
|
|
(do
|
|
(when
|
|
(not (has-key? kept-index k))
|
|
(dict-set! kept-index k (list)))
|
|
(append! (get kept-index k) t)))))))
|
|
existing)
|
|
(dict-set! (get db :facts) rel-key kept)
|
|
(dict-set! (get db :facts-keys) rel-key kept-keys)
|
|
(dict-set! (get db :facts-index) rel-key kept-index)
|
|
(when
|
|
(not (nil? edb-rel))
|
|
(dict-set! (get db :edb-keys) rel-key kept-edb)))))
|
|
;; For each rule-head relation, strip the IDB-derived tuples
|
|
;; (anything not marked in :edb-keys) so the saturator can
|
|
;; cleanly re-derive without leaving stale tuples that depended
|
|
;; on the now-removed fact.
|
|
(let ((rule-heads (dl-rule-head-rels db)))
|
|
(for-each
|
|
(fn
|
|
(k)
|
|
(when
|
|
(has-key? (get db :facts) k)
|
|
(let
|
|
((existing (get (get db :facts) k))
|
|
(kept (list))
|
|
(kept-keys {})
|
|
(kept-index {})
|
|
(edb-rel (cond
|
|
((has-key? (get db :edb-keys) k)
|
|
(get (get db :edb-keys) k))
|
|
(else {}))))
|
|
(do
|
|
(for-each
|
|
(fn
|
|
(t)
|
|
(let ((tk (dl-tuple-key t)))
|
|
(when
|
|
(has-key? edb-rel tk)
|
|
(do
|
|
(append! kept t)
|
|
(dict-set! kept-keys tk true)
|
|
(when
|
|
(>= (len t) 2)
|
|
(let ((kk (dl-arg-key (nth t 1))))
|
|
(do
|
|
(when
|
|
(not (has-key? kept-index kk))
|
|
(dict-set! kept-index kk (list)))
|
|
(append! (get kept-index kk) t))))))))
|
|
existing)
|
|
(dict-set! (get db :facts) k kept)
|
|
(dict-set! (get db :facts-keys) k kept-keys)
|
|
(dict-set! (get db :facts-index) k kept-index)))))
|
|
rule-heads))
|
|
(dl-saturate! db)
|
|
db))))
|
|
|
|
;; ── Convenience: single-call source + query ───────────────────
|
|
;; (dl-eval source query-source) parses both, builds a db, saturates,
|
|
;; runs the query, returns the substitution list. The query source
|
|
;; should be `?- goal[, goal ...].` — the parser produces a clause
|
|
;; with :query containing a list of literals which is fed straight
|
|
;; to dl-query.
|
|
(define
|
|
dl-eval
|
|
(fn
|
|
(source query-source)
|
|
(let
|
|
((db (dl-program source))
|
|
(queries (dl-parse query-source)))
|
|
(cond
|
|
((= (len queries) 0) (error "dl-eval: query string is empty"))
|
|
((not (has-key? (first queries) :query))
|
|
(error "dl-eval: second arg must be a `?- ...` query clause"))
|
|
(else
|
|
(dl-query db (get (first queries) :query)))))))
|
|
|
|
;; (dl-eval-magic source query-source) — like dl-eval but routes a
|
|
;; single-positive-literal query through `dl-magic-query` for goal-
|
|
;; directed evaluation. Multi-literal query bodies fall back to the
|
|
;; standard dl-query path (magic-sets is currently only wired for
|
|
;; single-positive goals). The caller's source is parsed afresh
|
|
;; each call so successive invocations are independent.
|
|
(define
|
|
dl-eval-magic
|
|
(fn
|
|
(source query-source)
|
|
(let
|
|
((db (dl-program source))
|
|
(queries (dl-parse query-source)))
|
|
(cond
|
|
((= (len queries) 0) (error "dl-eval-magic: query string is empty"))
|
|
((not (has-key? (first queries) :query))
|
|
(error
|
|
"dl-eval-magic: second arg must be a `?- ...` query clause"))
|
|
(else
|
|
(let
|
|
((qbody (get (first queries) :query)))
|
|
(cond
|
|
((and (= (len qbody) 1)
|
|
(list? (first qbody))
|
|
(> (len (first qbody)) 0)
|
|
(symbol? (first (first qbody))))
|
|
(dl-magic-query db (first qbody)))
|
|
(else (dl-query db qbody)))))))))
|
|
|
|
;; List rules whose head's relation matches `rel-name`. Useful for
|
|
;; inspection ("show me how this relation is derived") without
|
|
;; exposing the internal `:rules` list.
|
|
(define
|
|
dl-rules-of
|
|
(fn
|
|
(db rel-name)
|
|
(let ((out (list)))
|
|
(do
|
|
(for-each
|
|
(fn
|
|
(rule)
|
|
(when
|
|
(= (dl-rel-name (get rule :head)) rel-name)
|
|
(append! out rule)))
|
|
(dl-rules db))
|
|
out))))
|
|
|
|
(define
|
|
dl-rule-head-rels
|
|
(fn
|
|
(db)
|
|
(let ((seen (list)))
|
|
(do
|
|
(for-each
|
|
(fn
|
|
(rule)
|
|
(let ((h (dl-rel-name (get rule :head))))
|
|
(when
|
|
(and (not (nil? h)) (not (dl-member-string? h seen)))
|
|
(append! seen h))))
|
|
(dl-rules db))
|
|
seen))))
|
|
|
|
;; Wipe every relation that has at least one rule (i.e. every IDB
|
|
;; relation) — leaves EDB facts and rule definitions intact. Useful
|
|
;; before a follow-up `dl-saturate!` if you want a clean restart, or
|
|
;; for inspection of the EDB-only baseline.
|
|
(define
|
|
dl-clear-idb!
|
|
(fn
|
|
(db)
|
|
(let ((rule-heads (dl-rule-head-rels db)))
|
|
(do
|
|
(for-each
|
|
(fn
|
|
(k)
|
|
(do
|
|
(dict-set! (get db :facts) k (list))
|
|
(dict-set! (get db :facts-keys) k {})
|
|
(dict-set! (get db :facts-index) k {})))
|
|
rule-heads)
|
|
db))))
|