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.
Files changed (21) hide show
  1. {tiny_datalog-0.2.0/tiny_datalog.egg-info → tiny_datalog-0.3.0}/PKG-INFO +28 -7
  2. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/README.md +27 -6
  3. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/pyproject.toml +1 -1
  4. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/__init__.py +1 -1
  5. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/datalog.py +61 -3
  6. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/tabling.py +75 -31
  7. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0/tiny_datalog.egg-info}/PKG-INFO +28 -7
  8. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/LICENSE +0 -0
  9. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/setup.cfg +0 -0
  10. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/containment.py +0 -0
  11. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/defeasible.py +0 -0
  12. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/incremental.py +0 -0
  13. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/magic.py +0 -0
  14. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/prolog.py +0 -0
  15. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/semantics.py +0 -0
  16. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/semiring.py +0 -0
  17. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog/subsumption.py +0 -0
  18. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/SOURCES.txt +0 -0
  19. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/dependency_links.txt +0 -0
  20. {tiny_datalog-0.2.0 → tiny_datalog-0.3.0}/tiny_datalog.egg-info/entry_points.txt +0 -0
  21. {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.2.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 # 246 tests, ~12s
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) and packaging.** `git clone` and run.
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 service, cve in sorted(engine.rels["exposed"]):
328
- print("\n".join(explain(engine, "exposed", (service, cve))))
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 246 tests: every shipped program and exercise answer is
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 # 246 tests, ~12s
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) and packaging.** `git clone` and run.
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 service, cve in sorted(engine.rels["exposed"]):
295
- print("\n".join(explain(engine, "exposed", (service, cve))))
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 246 tests: every shipped program and exercise answer is
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tiny-datalog"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A Datalog engine small enough to read in an afternoon"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -30,7 +30,7 @@ from tiny_datalog.datalog import (
30
30
  format_atom, format_fact,
31
31
  )
32
32
 
33
- __version__ = "0.2.0"
33
+ __version__ = "0.3.0"
34
34
 
35
35
  __all__ = [
36
36
  "Var", "Const", "Struct", "Atom", "Literal", "Rule",
@@ -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
- engine = Engine(Program(parse(text)))
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
- Positive programs only (tabling under negation is SLG resolution, which
34
- computes the well-founded semantics — XSB's whole claim to fame — and
35
- is beyond this teaching module).
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, format_fact, parse, parse_goal,
51
- read_program, validate, _aggregate_of, _match, _sort_key)
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 needs completion detection (real "
69
- "SLG); this module is positive-rules-only: %s" % c)
70
- for lit in c.body:
71
- if lit.negated:
72
- raise DatalogError(
73
- "tabling under negation is SLG resolution (the "
74
- "well-founded semantics, XSB) — beyond this "
75
- "module: %s" % c)
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
- self.by_pred[(c.head.pred, len(c.head.args))].append(c)
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._grew = True # ...which the fixpoint must revisit
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
- table = self._table(lit.atom.pred, self._pattern(lit.atom, subst))
116
- for ans in table:
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 clause in self.by_pred.get((pred, len(pattern)), ()):
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(clause.head.args, pattern):
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(list(clause.body), seed):
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 clause.head.args)
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
- self._grew = False
160
- self.rounds += 1
161
- for key in list(self.tables):
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._grew
168
- return {t for t in self.tables[root]
169
- if _match(atom.args, t, {}) is not None}
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 positive "
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), positive rules only")
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.2.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 # 246 tests, ~12s
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) and packaging.** `git clone` and run.
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 service, cve in sorted(engine.rels["exposed"]):
328
- print("\n".join(explain(engine, "exposed", (service, cve))))
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 246 tests: every shipped program and exercise answer is
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