tiny-datalog 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tiny_datalog-0.2.0/tiny_datalog.egg-info → tiny_datalog-0.3.0}/PKG-INFO +28 -7
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/README.md +27 -6
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/pyproject.toml +1 -1
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/__init__.py +1 -1
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/datalog.py +61 -3
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/tabling.py +75 -31
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0/tiny_datalog.egg-info}/PKG-INFO +28 -7
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/LICENSE +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/setup.cfg +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/containment.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/defeasible.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/incremental.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/magic.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/prolog.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/semantics.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/semiring.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/subsumption.py +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/SOURCES.txt +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/dependency_links.txt +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/entry_points.txt +0 -0
- {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tiny-datalog
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Datalog engine small enough to read in an afternoon
|
|
5
5
|
Author: Andrew Goodchild
|
|
6
6
|
License-Expression: MIT
|
|
@@ -126,7 +126,7 @@ Nothing to install:
|
|
|
126
126
|
|
|
127
127
|
```sh
|
|
128
128
|
git clone https://github.com/andrewgoodchild/tiny-datalog && cd tiny-datalog
|
|
129
|
-
python3 tests.py #
|
|
129
|
+
python3 tests.py # 254 tests, ~12s
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
## Why the language choice decides what you can ask later
|
|
@@ -289,7 +289,9 @@ regression test without writing Python.
|
|
|
289
289
|
|
|
290
290
|
Not an engine to build a product on. Joins are nested-loop,
|
|
291
291
|
stable-model search is exhaustive, evaluation is batch. For real
|
|
292
|
-
workloads see Soufflé, clingo, RDFox or Feldera
|
|
292
|
+
workloads see Soufflé, clingo, RDFox or Feldera; for Datalog inside a
|
|
293
|
+
Python application, with arithmetic and queries over SQL databases,
|
|
294
|
+
see [pyDatalog](https://pypi.org/project/pyDatalog/).
|
|
293
295
|
|
|
294
296
|
Deliberate omissions, because saying why teaches more than lacking them
|
|
295
297
|
quietly:
|
|
@@ -306,7 +308,7 @@ quietly:
|
|
|
306
308
|
(disjointness and unsatisfiability detection included). SNOMED needs
|
|
307
309
|
ELH (EL plus role hierarchies) with right identities, which is what
|
|
308
310
|
the ELK and Snorocket reasoners implement and this does not.
|
|
309
|
-
- **A REPL (interactive prompt)
|
|
311
|
+
- **A REPL (interactive prompt).** Run a file, or call it from Python.
|
|
310
312
|
|
|
311
313
|
Aggregation used to be on this list;
|
|
312
314
|
[lesson 13](https://github.com/andrewgoodchild/tiny-datalog/blob/main/lessons/13-aggregation.md) is what promoting an omission
|
|
@@ -324,8 +326,27 @@ pip install tiny-datalog
|
|
|
324
326
|
from tiny_datalog import run_program, explain
|
|
325
327
|
|
|
326
328
|
engine = run_program(open("supply-chain.dl").read())
|
|
327
|
-
for
|
|
328
|
-
print(
|
|
329
|
+
for answer in engine.query("exposed(S, C)"):
|
|
330
|
+
print(answer) # {'S': 'pkg0', 'C': 'cve_2026_0001'}
|
|
331
|
+
print("\n".join(explain(engine, "exposed", ("pkg0", "cve_2026_0001"))))
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Facts can come straight from Python data (a CSV file, a database
|
|
335
|
+
query) instead of program text. Each row is checked as if it had been
|
|
336
|
+
parsed:
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
rules = """
|
|
340
|
+
uses(X, Y) :- depends(X, Y).
|
|
341
|
+
uses(X, Z) :- depends(X, Y), uses(Y, Z).
|
|
342
|
+
exposed(S, C) :- service(S), uses(S, L), vulnerable(L, C).
|
|
343
|
+
"""
|
|
344
|
+
engine = run_program(rules, facts={
|
|
345
|
+
"depends": [("app", "lib"), ("lib", "core")],
|
|
346
|
+
"service": ["app"], # one-column rows may be bare
|
|
347
|
+
"vulnerable": [("core", "cve_2026_0001")],
|
|
348
|
+
})
|
|
349
|
+
engine.query("exposed(S, C)") # [{'S': 'app', 'C': 'cve_2026_0001'}]
|
|
329
350
|
```
|
|
330
351
|
|
|
331
352
|
The command-line interface installs too, as `tiny-datalog` (and
|
|
@@ -365,7 +386,7 @@ cases/ golden test cases — add one without writing Python
|
|
|
365
386
|
conformance/ the external datalog-conformance corpus (Soufflé, Nemo,
|
|
366
387
|
Crepe, SPINdle), run against every evaluation strategy
|
|
367
388
|
benchmarks/ scaled input generators (chain/tree/clique/grid)
|
|
368
|
-
tests.py
|
|
389
|
+
tests.py 254 tests: every shipped program and exercise answer is
|
|
369
390
|
executed, a conformance suite runs every query through
|
|
370
391
|
every applicable strategy, and a seeded fuzzer checks
|
|
371
392
|
the same property on random programs
|
|
@@ -93,7 +93,7 @@ Nothing to install:
|
|
|
93
93
|
|
|
94
94
|
```sh
|
|
95
95
|
git clone https://github.com/andrewgoodchild/tiny-datalog && cd tiny-datalog
|
|
96
|
-
python3 tests.py #
|
|
96
|
+
python3 tests.py # 254 tests, ~12s
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
## Why the language choice decides what you can ask later
|
|
@@ -256,7 +256,9 @@ regression test without writing Python.
|
|
|
256
256
|
|
|
257
257
|
Not an engine to build a product on. Joins are nested-loop,
|
|
258
258
|
stable-model search is exhaustive, evaluation is batch. For real
|
|
259
|
-
workloads see Soufflé, clingo, RDFox or Feldera
|
|
259
|
+
workloads see Soufflé, clingo, RDFox or Feldera; for Datalog inside a
|
|
260
|
+
Python application, with arithmetic and queries over SQL databases,
|
|
261
|
+
see [pyDatalog](https://pypi.org/project/pyDatalog/).
|
|
260
262
|
|
|
261
263
|
Deliberate omissions, because saying why teaches more than lacking them
|
|
262
264
|
quietly:
|
|
@@ -273,7 +275,7 @@ quietly:
|
|
|
273
275
|
(disjointness and unsatisfiability detection included). SNOMED needs
|
|
274
276
|
ELH (EL plus role hierarchies) with right identities, which is what
|
|
275
277
|
the ELK and Snorocket reasoners implement and this does not.
|
|
276
|
-
- **A REPL (interactive prompt)
|
|
278
|
+
- **A REPL (interactive prompt).** Run a file, or call it from Python.
|
|
277
279
|
|
|
278
280
|
Aggregation used to be on this list;
|
|
279
281
|
[lesson 13](https://github.com/andrewgoodchild/tiny-datalog/blob/main/lessons/13-aggregation.md) is what promoting an omission
|
|
@@ -291,8 +293,27 @@ pip install tiny-datalog
|
|
|
291
293
|
from tiny_datalog import run_program, explain
|
|
292
294
|
|
|
293
295
|
engine = run_program(open("supply-chain.dl").read())
|
|
294
|
-
for
|
|
295
|
-
print(
|
|
296
|
+
for answer in engine.query("exposed(S, C)"):
|
|
297
|
+
print(answer) # {'S': 'pkg0', 'C': 'cve_2026_0001'}
|
|
298
|
+
print("\n".join(explain(engine, "exposed", ("pkg0", "cve_2026_0001"))))
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Facts can come straight from Python data (a CSV file, a database
|
|
302
|
+
query) instead of program text. Each row is checked as if it had been
|
|
303
|
+
parsed:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
rules = """
|
|
307
|
+
uses(X, Y) :- depends(X, Y).
|
|
308
|
+
uses(X, Z) :- depends(X, Y), uses(Y, Z).
|
|
309
|
+
exposed(S, C) :- service(S), uses(S, L), vulnerable(L, C).
|
|
310
|
+
"""
|
|
311
|
+
engine = run_program(rules, facts={
|
|
312
|
+
"depends": [("app", "lib"), ("lib", "core")],
|
|
313
|
+
"service": ["app"], # one-column rows may be bare
|
|
314
|
+
"vulnerable": [("core", "cve_2026_0001")],
|
|
315
|
+
})
|
|
316
|
+
engine.query("exposed(S, C)") # [{'S': 'app', 'C': 'cve_2026_0001'}]
|
|
296
317
|
```
|
|
297
318
|
|
|
298
319
|
The command-line interface installs too, as `tiny-datalog` (and
|
|
@@ -332,7 +353,7 @@ cases/ golden test cases — add one without writing Python
|
|
|
332
353
|
conformance/ the external datalog-conformance corpus (Soufflé, Nemo,
|
|
333
354
|
Crepe, SPINdle), run against every evaluation strategy
|
|
334
355
|
benchmarks/ scaled input generators (chain/tree/clique/grid)
|
|
335
|
-
tests.py
|
|
356
|
+
tests.py 254 tests: every shipped program and exercise answer is
|
|
336
357
|
executed, a conformance suite runs every query through
|
|
337
358
|
every applicable strategy, and a seeded fuzzer checks
|
|
338
359
|
the same property on random programs
|
|
@@ -821,6 +821,27 @@ class Engine:
|
|
|
821
821
|
groups[key].append(s[var.name])
|
|
822
822
|
return groups
|
|
823
823
|
|
|
824
|
+
def query(self, goal):
|
|
825
|
+
"""The answers to a query, one dict per answer, in sorted order:
|
|
826
|
+
|
|
827
|
+
engine.query("exposed(S, C)")
|
|
828
|
+
-> [{"S": "pkg0", "C": "cve_2026_0001"}, ...]
|
|
829
|
+
|
|
830
|
+
`goal` is query text or an Atom. Anonymous variables are left
|
|
831
|
+
out of the answers; a query with no variables answers [{}] if it
|
|
832
|
+
holds and [] if it does not."""
|
|
833
|
+
atom = parse_goal(goal) if isinstance(goal, str) else goal
|
|
834
|
+
check_query_atom(atom, self.program.arity)
|
|
835
|
+
names = []
|
|
836
|
+
for a in atom.args:
|
|
837
|
+
if isinstance(a, Var) and not a.anonymous and a.name not in names:
|
|
838
|
+
names.append(a.name)
|
|
839
|
+
rows = {tuple(s[n] for n in names)
|
|
840
|
+
for s in (_match(atom.args, tup, {})
|
|
841
|
+
for tup in self.rels.get(atom.pred, ()))
|
|
842
|
+
if s is not None}
|
|
843
|
+
return [dict(zip(names, row)) for row in sorted(rows, key=_sort_key)]
|
|
844
|
+
|
|
824
845
|
@staticmethod
|
|
825
846
|
def _instantiate(atom, subst):
|
|
826
847
|
return tuple(a.value if isinstance(a, Const) else subst[a.name]
|
|
@@ -851,13 +872,50 @@ def _fold(func, values, rule):
|
|
|
851
872
|
return agg
|
|
852
873
|
|
|
853
874
|
|
|
854
|
-
def run_program(text):
|
|
855
|
-
"""Parse, stratify, and evaluate a program; return the Engine.
|
|
856
|
-
|
|
875
|
+
def run_program(text, facts=None):
|
|
876
|
+
"""Parse, stratify, and evaluate a program; return the Engine.
|
|
877
|
+
|
|
878
|
+
`facts` adds base facts from Python data, so rows from a CSV file or
|
|
879
|
+
a database query can feed the rules without first being written out
|
|
880
|
+
as Datalog text:
|
|
881
|
+
|
|
882
|
+
run_program(rules, facts={"depends": [("pkg4", "pkg13")],
|
|
883
|
+
"service": ["pkg4"]})
|
|
884
|
+
|
|
885
|
+
Each row is a tuple of str, int or float (a bare value is a one-
|
|
886
|
+
column row), and every fact is checked exactly as if it had been
|
|
887
|
+
parsed: arity, groundness, and agreement with the rules."""
|
|
888
|
+
engine = Engine(Program(parse(text) + python_facts(facts or {})))
|
|
857
889
|
engine.run()
|
|
858
890
|
return engine
|
|
859
891
|
|
|
860
892
|
|
|
893
|
+
_PREDICATE = re.compile(r"[a-z][A-Za-z0-9_]*\Z")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def python_facts(facts):
|
|
897
|
+
"""{predicate: rows} as fact clauses (see run_program)."""
|
|
898
|
+
clauses = []
|
|
899
|
+
for pred, rows in facts.items():
|
|
900
|
+
if not isinstance(pred, str) or not _PREDICATE.match(pred) \
|
|
901
|
+
or pred == "not":
|
|
902
|
+
raise DatalogError("%r cannot name a predicate: a predicate is "
|
|
903
|
+
"a lowercase identifier" % (pred,))
|
|
904
|
+
for row in rows:
|
|
905
|
+
if not isinstance(row, (tuple, list)):
|
|
906
|
+
row = (row,)
|
|
907
|
+
for v in row:
|
|
908
|
+
# bool is an int in Python, and would print as a constant
|
|
909
|
+
# named True; nan and inf print as constants too
|
|
910
|
+
if (isinstance(v, bool) or not isinstance(v, (str, int, float))
|
|
911
|
+
or (isinstance(v, float) and not math.isfinite(v))):
|
|
912
|
+
raise DatalogError(
|
|
913
|
+
"fact %s%r: values must be str, int or finite float, "
|
|
914
|
+
"not %r" % (pred, tuple(row), v))
|
|
915
|
+
clauses.append(Rule(Atom(pred, tuple(Const(v) for v in row)), ()))
|
|
916
|
+
return clauses
|
|
917
|
+
|
|
918
|
+
|
|
861
919
|
# ---------------------------------------------------------------------------
|
|
862
920
|
# CLI
|
|
863
921
|
# ---------------------------------------------------------------------------
|
|
@@ -30,9 +30,15 @@ the magic predicates' contents. They are the same sets — magic sets is
|
|
|
30
30
|
tabling performed at compile time, tabling is magic sets performed at
|
|
31
31
|
run time.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
Negation is supported when the program is stratified, which is the
|
|
34
|
+
simplification pyDatalog also makes. A negated subgoal `not q(a)`
|
|
35
|
+
belongs to a lower stratum, so q's tables can be *completed* first —
|
|
36
|
+
grown to fixpoint on their own, since nothing in them depends on the
|
|
37
|
+
rule asking — and then looked up. Negation through recursion needs
|
|
38
|
+
full SLG resolution (Chen & Warren 1996), which suspends calls instead
|
|
39
|
+
of re-running them, detects when a group of tables is complete, and
|
|
40
|
+
*delays* negative literals it cannot yet decide; it computes the
|
|
41
|
+
well-founded semantics, and Lesson 15 says why it is not here.
|
|
36
42
|
|
|
37
43
|
CLI
|
|
38
44
|
---
|
|
@@ -47,12 +53,14 @@ import sys
|
|
|
47
53
|
from collections import defaultdict
|
|
48
54
|
|
|
49
55
|
from tiny_datalog.datalog import (
|
|
50
|
-
check_query_atom, Const, DatalogError,
|
|
51
|
-
read_program, validate, _aggregate_of,
|
|
56
|
+
check_query_atom, Const, DatalogError, StratificationError, format_fact,
|
|
57
|
+
parse, parse_goal, read_program, stratify, validate, _aggregate_of,
|
|
58
|
+
_match, _sort_key)
|
|
52
59
|
|
|
53
60
|
|
|
54
61
|
class TabledEngine:
|
|
55
|
-
"""Iterative QSQR: tables keyed by call pattern, filled to fixpoint
|
|
62
|
+
"""Iterative QSQR: tables keyed by call pattern, filled to fixpoint;
|
|
63
|
+
a negated subgoal's tables are completed first, one stratum down.
|
|
56
64
|
|
|
57
65
|
After query(), `tables` maps (pred, pattern) — pattern has a constant
|
|
58
66
|
per bound argument and None per free one — to the set of full answer
|
|
@@ -65,18 +73,27 @@ class TabledEngine:
|
|
|
65
73
|
raise DatalogError("retraction is incremental.py's job: %s" % c)
|
|
66
74
|
if c.body and _aggregate_of(c.head):
|
|
67
75
|
raise DatalogError(
|
|
68
|
-
"tabled aggregation
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
"tabled aggregation is not implemented here — use "
|
|
77
|
+
"datalog.py: %s" % c)
|
|
78
|
+
try:
|
|
79
|
+
self.strata = stratify(clauses)
|
|
80
|
+
except StratificationError as exc:
|
|
81
|
+
raise StratificationError(
|
|
82
|
+
"%s. Tabling under unstratified negation is full SLG "
|
|
83
|
+
"resolution, which computes the well-founded semantics "
|
|
84
|
+
"(Lesson 15); datalog.py --models computes it bottom-up."
|
|
85
|
+
% str(exc).rstrip("."), exc.cycle) from exc
|
|
76
86
|
self.by_pred = defaultdict(list) # (pred, arity) -> clauses
|
|
77
87
|
for c in clauses:
|
|
78
|
-
|
|
88
|
+
# positive literals first: they bind; negations then test
|
|
89
|
+
# ground atoms (safety guarantees they are ground by then)
|
|
90
|
+
body = tuple(sorted(c.body, key=lambda lit: lit.negated))
|
|
91
|
+
self.by_pred[(c.head.pred, len(c.head.args))].append(
|
|
92
|
+
(c.head, body))
|
|
79
93
|
self.tables = {}
|
|
94
|
+
self.complete = set() # tables known to hold every answer
|
|
95
|
+
self.open = {} # ...and the rest, in creation order (a
|
|
96
|
+
# dict: a set's order would vary per run)
|
|
80
97
|
self.rounds = 0
|
|
81
98
|
|
|
82
99
|
# -- call patterns ------------------------------------------------------
|
|
@@ -98,8 +115,8 @@ class TabledEngine:
|
|
|
98
115
|
def _table(self, pred, pattern):
|
|
99
116
|
key = (pred, pattern)
|
|
100
117
|
if key not in self.tables:
|
|
101
|
-
self.tables[key] = set() # discovered a new subgoal
|
|
102
|
-
self.
|
|
118
|
+
self.tables[key] = set() # discovered a new subgoal, which
|
|
119
|
+
self.open[key] = None # the fixpoint will now revisit
|
|
103
120
|
return self.tables[key]
|
|
104
121
|
|
|
105
122
|
# -- one round of top-down solving --------------------------------------
|
|
@@ -112,8 +129,17 @@ class TabledEngine:
|
|
|
112
129
|
yield subst
|
|
113
130
|
return
|
|
114
131
|
lit, rest = body[0], body[1:]
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
key = (lit.atom.pred, self._pattern(lit.atom, subst))
|
|
133
|
+
table = self._table(*key)
|
|
134
|
+
if lit.negated:
|
|
135
|
+
# `not q(a)` may only read a finished table. q sits in a
|
|
136
|
+
# lower stratum, so finishing it cannot need this rule.
|
|
137
|
+
if key not in self.complete:
|
|
138
|
+
self._fixpoint(below=self.strata.get(lit.atom.pred, 0))
|
|
139
|
+
if key[1] not in table: # the pattern is the ground atom
|
|
140
|
+
yield from self._prove(rest, subst)
|
|
141
|
+
return
|
|
142
|
+
for ans in list(table): # a negation below may add to it
|
|
117
143
|
s = _match(lit.atom.args, ans, subst)
|
|
118
144
|
if s is not None:
|
|
119
145
|
yield from self._prove(rest, s)
|
|
@@ -122,11 +148,11 @@ class TabledEngine:
|
|
|
122
148
|
"""Re-derive a subgoal's answers from its clauses, one step of
|
|
123
149
|
head unification plus a tabled body proof."""
|
|
124
150
|
pred, pattern = key
|
|
125
|
-
for
|
|
151
|
+
for head, body in self.by_pred.get((pred, len(pattern)), ()):
|
|
126
152
|
# unify the head with the call pattern (bound args only)
|
|
127
153
|
seed = {}
|
|
128
154
|
ok = True
|
|
129
|
-
for a, v in zip(
|
|
155
|
+
for a, v in zip(head.args, pattern):
|
|
130
156
|
if v is None:
|
|
131
157
|
continue
|
|
132
158
|
if isinstance(a, Const):
|
|
@@ -140,9 +166,9 @@ class TabledEngine:
|
|
|
140
166
|
seed[a.name] = v
|
|
141
167
|
if not ok:
|
|
142
168
|
continue
|
|
143
|
-
for s in self._prove(
|
|
169
|
+
for s in self._prove(body, seed):
|
|
144
170
|
yield tuple(a.value if isinstance(a, Const) else s[a.name]
|
|
145
|
-
for a in
|
|
171
|
+
for a in head.args)
|
|
146
172
|
|
|
147
173
|
# -- the fixpoint --------------------------------------------------------
|
|
148
174
|
|
|
@@ -152,21 +178,39 @@ class TabledEngine:
|
|
|
152
178
|
check_query_atom(atom, self.arity)
|
|
153
179
|
root = (atom.pred, self._pattern(atom, {}))
|
|
154
180
|
self.tables = {root: set()}
|
|
181
|
+
self.complete = set()
|
|
182
|
+
self.open = {root: None}
|
|
155
183
|
self.rounds = 0
|
|
184
|
+
self._fixpoint()
|
|
185
|
+
return {t for t in self.tables[root]
|
|
186
|
+
if _match(atom.args, t, {}) is not None}
|
|
187
|
+
|
|
188
|
+
def _fixpoint(self, below=None):
|
|
189
|
+
"""Re-solve tables until none grows and no new subgoal appears.
|
|
190
|
+
With `below`, only the tables of strata up to that one — the
|
|
191
|
+
nested fixpoint a negation runs to complete what it reads; they
|
|
192
|
+
are then marked complete. Without, every table: the query's
|
|
193
|
+
own fixpoint, whose rounds are the ones counted."""
|
|
156
194
|
changed = True
|
|
157
195
|
while changed:
|
|
196
|
+
known = len(self.tables)
|
|
158
197
|
changed = False
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
for key in list(self.
|
|
198
|
+
if below is None:
|
|
199
|
+
self.rounds += 1
|
|
200
|
+
for key in list(self.open): # complete tables cannot grow
|
|
201
|
+
if below is not None and self.strata.get(key[0], 0) > below:
|
|
202
|
+
continue
|
|
162
203
|
table = self.tables[key]
|
|
163
204
|
for ans in list(self._answers_for(key)):
|
|
164
205
|
if ans not in table:
|
|
165
206
|
table.add(ans)
|
|
166
207
|
changed = True
|
|
167
|
-
changed = changed or self.
|
|
168
|
-
|
|
169
|
-
if
|
|
208
|
+
changed = changed or len(self.tables) != known
|
|
209
|
+
done = {k for k in self.open
|
|
210
|
+
if below is None or self.strata.get(k[0], 0) <= below}
|
|
211
|
+
self.complete |= done
|
|
212
|
+
for k in done:
|
|
213
|
+
del self.open[k]
|
|
170
214
|
|
|
171
215
|
|
|
172
216
|
# ---------------------------------------------------------------------------
|
|
@@ -175,9 +219,9 @@ class TabledEngine:
|
|
|
175
219
|
|
|
176
220
|
def main(argv=None):
|
|
177
221
|
ap = argparse.ArgumentParser(
|
|
178
|
-
description="Tabled top-down (QSQR) evaluation of
|
|
222
|
+
description="Tabled top-down (QSQR) evaluation of stratified "
|
|
179
223
|
"Datalog — handles left recursion SLD cannot.")
|
|
180
|
-
ap.add_argument("file", help="Datalog program (.dl),
|
|
224
|
+
ap.add_argument("file", help="Datalog program (.dl), stratified")
|
|
181
225
|
ap.add_argument("-q", "--query", action="append", default=[],
|
|
182
226
|
metavar="ATOM", help="goal to solve (repeatable)")
|
|
183
227
|
ap.add_argument("-t", "--tables", action="store_true",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tiny-datalog
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Datalog engine small enough to read in an afternoon
|
|
5
5
|
Author: Andrew Goodchild
|
|
6
6
|
License-Expression: MIT
|
|
@@ -126,7 +126,7 @@ Nothing to install:
|
|
|
126
126
|
|
|
127
127
|
```sh
|
|
128
128
|
git clone https://github.com/andrewgoodchild/tiny-datalog && cd tiny-datalog
|
|
129
|
-
python3 tests.py #
|
|
129
|
+
python3 tests.py # 254 tests, ~12s
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
## Why the language choice decides what you can ask later
|
|
@@ -289,7 +289,9 @@ regression test without writing Python.
|
|
|
289
289
|
|
|
290
290
|
Not an engine to build a product on. Joins are nested-loop,
|
|
291
291
|
stable-model search is exhaustive, evaluation is batch. For real
|
|
292
|
-
workloads see Soufflé, clingo, RDFox or Feldera
|
|
292
|
+
workloads see Soufflé, clingo, RDFox or Feldera; for Datalog inside a
|
|
293
|
+
Python application, with arithmetic and queries over SQL databases,
|
|
294
|
+
see [pyDatalog](https://pypi.org/project/pyDatalog/).
|
|
293
295
|
|
|
294
296
|
Deliberate omissions, because saying why teaches more than lacking them
|
|
295
297
|
quietly:
|
|
@@ -306,7 +308,7 @@ quietly:
|
|
|
306
308
|
(disjointness and unsatisfiability detection included). SNOMED needs
|
|
307
309
|
ELH (EL plus role hierarchies) with right identities, which is what
|
|
308
310
|
the ELK and Snorocket reasoners implement and this does not.
|
|
309
|
-
- **A REPL (interactive prompt)
|
|
311
|
+
- **A REPL (interactive prompt).** Run a file, or call it from Python.
|
|
310
312
|
|
|
311
313
|
Aggregation used to be on this list;
|
|
312
314
|
[lesson 13](https://github.com/andrewgoodchild/tiny-datalog/blob/main/lessons/13-aggregation.md) is what promoting an omission
|
|
@@ -324,8 +326,27 @@ pip install tiny-datalog
|
|
|
324
326
|
from tiny_datalog import run_program, explain
|
|
325
327
|
|
|
326
328
|
engine = run_program(open("supply-chain.dl").read())
|
|
327
|
-
for
|
|
328
|
-
print(
|
|
329
|
+
for answer in engine.query("exposed(S, C)"):
|
|
330
|
+
print(answer) # {'S': 'pkg0', 'C': 'cve_2026_0001'}
|
|
331
|
+
print("\n".join(explain(engine, "exposed", ("pkg0", "cve_2026_0001"))))
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Facts can come straight from Python data (a CSV file, a database
|
|
335
|
+
query) instead of program text. Each row is checked as if it had been
|
|
336
|
+
parsed:
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
rules = """
|
|
340
|
+
uses(X, Y) :- depends(X, Y).
|
|
341
|
+
uses(X, Z) :- depends(X, Y), uses(Y, Z).
|
|
342
|
+
exposed(S, C) :- service(S), uses(S, L), vulnerable(L, C).
|
|
343
|
+
"""
|
|
344
|
+
engine = run_program(rules, facts={
|
|
345
|
+
"depends": [("app", "lib"), ("lib", "core")],
|
|
346
|
+
"service": ["app"], # one-column rows may be bare
|
|
347
|
+
"vulnerable": [("core", "cve_2026_0001")],
|
|
348
|
+
})
|
|
349
|
+
engine.query("exposed(S, C)") # [{'S': 'app', 'C': 'cve_2026_0001'}]
|
|
329
350
|
```
|
|
330
351
|
|
|
331
352
|
The command-line interface installs too, as `tiny-datalog` (and
|
|
@@ -365,7 +386,7 @@ cases/ golden test cases — add one without writing Python
|
|
|
365
386
|
conformance/ the external datalog-conformance corpus (Soufflé, Nemo,
|
|
366
387
|
Crepe, SPINdle), run against every evaluation strategy
|
|
367
388
|
benchmarks/ scaled input generators (chain/tree/clique/grid)
|
|
368
|
-
tests.py
|
|
389
|
+
tests.py 254 tests: every shipped program and exercise answer is
|
|
369
390
|
executed, a conformance suite runs every query through
|
|
370
391
|
every applicable strategy, and a seeded fuzzer checks
|
|
371
392
|
the same property on random programs
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|