diff --git a/lib/datalog/db.sx b/lib/datalog/db.sx index d8e3be56..230d1c1c 100644 --- a/lib/datalog/db.sx +++ b/lib/datalog/db.sx @@ -251,6 +251,24 @@ (dl-index-add! db rel-key lit) true))))))) +;; A simple term — number, string, or symbol — i.e. anything legal +;; as an EDB fact arg. Compound (list) args belong only in body +;; literals where they encode arithmetic / aggregate sub-goals. +(define + dl-simple-term? + (fn + (term) + (or (number? term) (string? term) (symbol? term)))) + +(define + dl-args-simple? + (fn + (lit i n) + (cond + ((>= i n) true) + ((not (dl-simple-term? (nth lit i))) false) + (else (dl-args-simple? lit (+ i 1) n))))) + (define dl-add-fact! (fn @@ -261,6 +279,11 @@ ((dl-reserved-rel? (dl-rel-name lit)) (error (str "dl-add-fact!: '" (dl-rel-name lit) "' is a reserved name (built-in / aggregate / negation)"))) + ((not (dl-args-simple? lit 1 (len lit))) + (error (str "dl-add-fact!: fact args must be numbers, strings, " + "or symbols — compound args (e.g. arithmetic " + "expressions) are body-only and aren't evaluated " + "in fact position. got " lit))) ((not (dl-ground? lit (dl-empty-subst))) (error (str "dl-add-fact!: expected ground literal, got " lit))) (else @@ -373,6 +396,11 @@ (error (str "dl-add-rule!: head must be a non-empty list " "starting with a relation-name symbol, got " (get rule :head)))) + ((not (dl-args-simple? (get rule :head) 1 (len (get rule :head)))) + (error (str "dl-add-rule!: rule head args must be variables or " + "constants — compound terms (e.g. `(*(X, 2))`) are " + "not legal in head position; introduce an `is`-bound " + "intermediate in the body. got " (get rule :head)))) ((not (list? (if (has-key? rule :body) (get rule :body) (list)))) (error (str "dl-add-rule!: body must be a list of literals, got " (get rule :body)))) diff --git a/lib/datalog/scoreboard.json b/lib/datalog/scoreboard.json index 40f37987..b30f55a4 100644 --- a/lib/datalog/scoreboard.json +++ b/lib/datalog/scoreboard.json @@ -1,13 +1,13 @@ { "lang": "datalog", - "total_passed": 269, + "total_passed": 271, "total_failed": 0, - "total": 269, + "total": 271, "suites": [ {"name":"tokenize","passed":31,"failed":0,"total":31}, {"name":"parse","passed":23,"failed":0,"total":23}, {"name":"unify","passed":29,"failed":0,"total":29}, - {"name":"eval","passed":40,"failed":0,"total":40}, + {"name":"eval","passed":42,"failed":0,"total":42}, {"name":"builtins","passed":26,"failed":0,"total":26}, {"name":"semi_naive","passed":8,"failed":0,"total":8}, {"name":"negation","passed":10,"failed":0,"total":10}, @@ -16,5 +16,5 @@ {"name":"magic","passed":36,"failed":0,"total":36}, {"name":"demo","passed":21,"failed":0,"total":21} ], - "generated": "2026-05-11T08:39:03+00:00" + "generated": "2026-05-11T08:44:12+00:00" } diff --git a/lib/datalog/scoreboard.md b/lib/datalog/scoreboard.md index 174fc06d..3ea409b0 100644 --- a/lib/datalog/scoreboard.md +++ b/lib/datalog/scoreboard.md @@ -1,13 +1,13 @@ # datalog scoreboard -**269 / 269 passing** (0 failure(s)). +**271 / 271 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | tokenize | 31 | 31 | ok | | parse | 23 | 23 | ok | | unify | 29 | 29 | ok | -| eval | 40 | 40 | ok | +| eval | 42 | 42 | ok | | builtins | 26 | 26 | ok | | semi_naive | 8 | 8 | ok | | negation | 10 | 10 | ok | diff --git a/lib/datalog/tests/eval.sx b/lib/datalog/tests/eval.sx index f859c2b2..1dd432de 100644 --- a/lib/datalog/tests/eval.sx +++ b/lib/datalog/tests/eval.sx @@ -234,6 +234,24 @@ :body (list {:weird "stuff"})})))) true) + ;; Facts may only have simple-term args (number / string / symbol). + ;; A compound arg like `+(1, 2)` would otherwise be silently + ;; stored as the unreduced expression `(+ 1 2)` because dl-ground? + ;; sees no free variables. + (dl-et-test! + "compound arg in fact rejected" + (dl-et-throws? (fn () (dl-program "p(+(1, 2))."))) + true) + + ;; Rule heads may only have variable or constant args — no + ;; compounds. Compound heads would be saturated as unreduced + ;; tuples rather than the arithmetic result the user expected. + (dl-et-test! + "compound arg in rule head rejected" + (dl-et-throws? + (fn () (dl-program "n(3). double(*(X, 2)) :- n(X)."))) + true) + (dl-et-test! "unsafe head var" (dl-et-throws? (fn () (dl-program "p(X, Y) :- q(X)."))) diff --git a/plans/datalog-on-sx.md b/plans/datalog-on-sx.md index 29572e5c..2c65125c 100644 --- a/plans/datalog-on-sx.md +++ b/plans/datalog-on-sx.md @@ -15,7 +15,7 @@ for rose-ash data (e.g. federation graph, content relationships). ## Status (rolling) -`bash lib/datalog/conformance.sh` → **269/269 across 11 suites** +`bash lib/datalog/conformance.sh` → **271/271 across 11 suites** (tokenize, parse, unify, eval, builtins, semi_naive, negation, aggregates, api, magic, demo). Source is ~3100 LOC, tests ~2900 LOC, public API documented in `lib/datalog/datalog.sx`. @@ -320,6 +320,16 @@ large graphs. _Newest first._ +- 2026-05-11 — Compound terms in fact-arg / rule-head positions were + silently stored as unreduced expressions. `p(+(1, 2)).` resulted + in a tuple `(p (+ 1 2))` (dl-ground? sees no free variables, so it + passed). `double(*(X, 2)) :- n(X).` saturated to `double((* 3 2))` + rather than `double(6)`. Datalog has no function symbols in arg + positions — `dl-add-fact!` and `dl-add-rule!` now reject compound + args via a new `dl-simple-term?` (number / string / symbol). + Compounds remain legal in body literals where they encode `is` / + arithmetic / aggregate sub-goals. 2 new regression tests; 271/271. + - 2026-05-11 — Quoted atoms with uppercase-or-underscore-leading names were misclassified as variables. `p('Hello World').` ran through the tokenizer's `"atom"` branch and through the parser's