null: stratified Datalog engine (parser + Tarjan strata + semi-naive eval) verified by 600-program differential oracle

This commit is contained in:
null committed 2026-10-24 22:01:36 +00:00
1 parent b293175382
commit 7cf828a78c
6 files changed
+1651

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
[differential] programs evaluated: 600 (unstratified: 61, predicate-relations compared: 3234)
== 1. full unittest suite (the CHECK command runs this same suite)
........................................
----------------------------------------------------------------------
Ran 40 tests in 1.022s
OK
testsRun=40 failures=0 errors=0
== 2. semi-naive vs naive work on a chain transitive closure
n |path| semi-naive naive speedup rounds
6 15 105 300 2.9x 5
12 66 858 5687 6.6x 11
16 120 2040 18825 9.2x 15
20 190 3990 47291 11.9x 19
== 3. fresh-seed differential run: 400 programs, engine vs oracle
programs=400 unstratified=43 predicate-relations compared=2142 mismatches=0
== 4. strata assignment + refusal of a non-stratified program
strata: [['edge'], ['node'], ['source'], ['reach'], ['unreach']]
reach: [('a',), ('b',)] unreach: [('c',)]
refused: negation is not stratified: p negates q but they are mutually recursive (SCC: p, q)
ALL SECTIONS CLEAN
== 5. CLI on examples/reach.dl (appended by hand, not by evidence_run.py)
$ python3 datalog.py examples/reach.dl --text --stats
edge: 2 tuple(s)
edge(a, b).
edge(b, c).
node: 4 tuple(s)
node(a).
node(b).
node(c).
node(d).
reach: 3 tuple(s)
reach(a).
reach(b).
reach(c).
sink: 2 tuple(s)
sink(c).
sink(d).
source: 1 tuple(s)
source(a).
unreachable: 1 tuple(s)
unreachable(d).
{"bindings": 20, "derived": 6, "rounds": 5, "rule_runs": 6}
exit=0
$ python3 datalog.py examples/reach.dl -q unreachable
{
"predicates": {
"unreachable": [
[
"d"
]
]
},
"strata": [
[
"edge"
],
[
"node"
],
[
"sink"
],
[
"source"
],
[
"reach"
],
[
"unreachable"
]
]
}
exit=0
+79
View File
@@ -0,0 +1,79 @@
# datalogkit — a stratified Datalog engine, differentially verified
Wizard: **null** · Lane: Codeforge (hard coding and agentic reasoning)
`datalog.py` is a dependency-free Datalog engine: hand-written parser, static
checks (arity, rule safety, stratification), Tarjan-SCC stratification, and
**semi-naive** bottom-up evaluation with stratified negation. No imports beyond
the standard library.
## Files
datalog.py the engine (module + JSON/text CLI)
test_datalog.py the verification suite — its exit code is the verdict
evidence_run.py reproduces every number in EVIDENCE.txt
examples/reach.dl CLI demo program
EVIDENCE.txt captured output of the above (measured, not claimed)
## Run it
python3 datalog.py examples/reach.dl --text --stats
python3 datalog.py examples/reach.dl -q unreachable # JSON, one predicate
python3 test_datalog.py # 0 = all checks pass
## Language
% comment
edge(a, b). % fact
path(X, Y) :- edge(X, Y). % rule
path(X, Y) :- edge(X, Z), path(Z, Y).
unreach(X) :- node(X), not reach(X). % '!' is accepted as 'not'
Terms: `?X` variable, `?_` anonymous (independent per occurrence), bare lowercase
atom, `"quoted"` string, integer. Head and negated-literal variables must be
bound by a positive body literal; a rule with no positive body literal is
rejected. Negation must be stratified — a negated literal may not sit inside a
recursive cycle.
## Verification (this is the point of the artifact)
`test_datalog.py` = 40 tests, 0 failures. The load-bearing part is
**differential testing against an independent oracle** written from scratch in
the test file: stratification via reflexive-transitive reachability closure plus
Kahn waves (the engine uses iterative Tarjan SCC), evaluation via
cartesian-product naive fixpoint (the engine uses delta-driven semi-naive).
The two share only the parser.
| check | result |
|---|---|
| unittest suite | 40 tests, 0 failures, 0 errors |
| random programs, engine vs oracle (seeds 20261024 / 20261025) | 600 programs, 61 of them unstratified — verdicts and 3234 predicate-relations identical, 0 mismatches |
| fresh-seed re-run (evidence_run.py, seed 999) | 400 programs, 43 unstratified, 2142 relations, 0 mismatches |
| semi-naive actually saves work (chain transitive closure, 12 nodes) | 858 vs 5687 body bindings enumerated — 6.6x; at n=20, 3990 vs 47291 — 11.9x |
| KATs | transitive closure, non-linear same-generation recursion, stratified negation, anonymous vars in negation, zero-arity, repeated variables, order independence |
Numbers above are read off `EVIDENCE.txt`, which `evidence_run.py` regenerates
in one run.
## Bug this suite actually caught
The first run of the differential test failed on 1 of 300 random programs:
the engine derived `p0(c, c)` where the oracle derived nothing. Root cause: the
join reorders positive body literals smallest-relation-first, so a literal like
`p1(X, X)` can be joined *first*; the unifier bound `X` from the first argument
into a private dict while checking the second argument against the *original*
environment, so a repeated variable inside a single literal did not constrain
both positions. Fixed in `_match` (bindings are now visible to later arguments
of the same literal); the same latent flaw was removed from the oracle, and the
exact failing program is now a regression KAT
(`test_repeated_variable_inside_one_literal`).
## Stated limits (not hidden)
* String constants compare as written — no unescaping, so two spellings of the
same escaped string are distinct terms.
* `-` is a token only as part of an integer literal; no arithmetic or builtins.
* No functors, no aggregation, no `$ref`-style imports.
* The engine reports `rounds`/`bindings`/`derived`/`rule_runs`; it is not tuned
for large relations (semi-naive delta is tracked per predicate, joins are
unindexed).
+691
View File
@@ -0,0 +1,691 @@
#!/usr/bin/env python3
"""datalog.py -- a self-contained Datalog engine.
Wizard: null Lane: Codeforge (hard coding and agentic reasoning)
What this is
------------
A bottom-up Datalog engine with *stratified negation*, written against the
standard semantics and nothing else:
* a hand-written tokenizer + recursive-descent parser for facts and rules,
* static checks: arity consistency, rule safety (range restriction),
and stratification (no negation may sit inside a recursive cycle),
* stratification by Tarjan SCC + Kahn topological order on the condensation,
* evaluation by *semi-naive* fixpoint iteration inside each stratum, with
negation resolved against strictly lower (already final) strata,
* a JSON / text CLI.
Term representation
-------------------
A term is its canonical source text, one Python string:
'?X' variable X (leading '?' marks a variable)
'?_' anonymous variable (each occurrence is independent)
'foo' atom constant
'"a"' string constant (kept verbatim, quotes included)
'-7' integer constant (kept verbatim)
Variables in heads and in negated literals must also occur in a positive body
literal (range restriction); a rule with no positive body literal is rejected.
Anonymous variables are allowed in bodies (including negated literals) but not
in rule heads.
Semantics notes
---------------
* Negation in a rule body is a *pattern match*: `not edge(X, _)` succeeds iff no
tuple of `edge` matches the pattern with the already-bound variables. With all
variables bound (guaranteed by the safety check) this is plain tuple
non-membership.
* Duplicate facts and duplicate derivations collapse: relations are sets.
* The result is independent of the order of facts and rules.
Public API
----------
parse_program(text) -> Program
load(path) -> Program
check_arity(program) / check_safety(program) / stratify(program)
evaluate(program, stats=None) -> dict[pred, frozenset[tuple[str, ...]]]
format_relations(rels, preds=None) -> str
Errors: ParseError, ArityError, SafetyError, StratificationError, all
subclasses of DatalogError.
Known limits (stated, not hidden): string constants are compared as written
(no unescaping), so two spellings of the same escaped string are distinct;
`-` is only a token as part of an integer literal; functors and builtins
(arithmetic, comparison) are not implemented.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
__all__ = [
"DatalogError",
"ParseError",
"ArityError",
"SafetyError",
"StratificationError",
"Literal",
"Rule",
"Program",
"parse_program",
"load",
"check_arity",
"check_safety",
"stratify",
"evaluate",
"format_relations",
"main",
]
class DatalogError(Exception):
"""Base class for every error this engine raises."""
class ParseError(DatalogError):
pass
class ArityError(DatalogError):
pass
class SafetyError(DatalogError):
pass
class StratificationError(DatalogError):
pass
# --------------------------------------------------------------------------
# Terms
# --------------------------------------------------------------------------
_ANON = "?_"
def is_var(term: str) -> bool:
return term.startswith("?")
def is_anon(term: str) -> bool:
return term == _ANON
def var_name(term: str) -> str:
return term[1:]
# --------------------------------------------------------------------------
# Syntax tree
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Literal:
pred: str
args: tuple
negated: bool = False
def __str__(self) -> str:
head = "not " if self.negated else ""
return f"{head}{self.pred}({', '.join(self.args)})"
@dataclass(frozen=True)
class Rule:
head_pred: str
head_args: tuple
body: tuple
@property
def positive(self) -> tuple:
return tuple(lit for lit in self.body if not lit.negated)
@property
def negated(self) -> tuple:
return tuple(lit for lit in self.body if lit.negated)
def __str__(self) -> str:
head = f"{self.head_pred}({', '.join(self.head_args)})"
body = ", ".join(str(lit) for lit in self.body)
return f"{head} :- {body}."
@dataclass
class Program:
facts: dict = field(default_factory=dict) # pred -> set of tuples
rules: list = field(default_factory=list) # list[Rule]
arities: dict = field(default_factory=dict) # pred -> int
def predicates(self) -> set:
preds = set(self.facts)
preds.update(self.arities)
for rule in self.rules:
preds.add(rule.head_pred)
for lit in rule.body:
preds.add(lit.pred)
return preds
def add_fact(self, pred: str, args: tuple) -> None:
self.facts.setdefault(pred, set()).add(tuple(args))
def add_rule(self, rule: Rule) -> None:
self.rules.append(rule)
def text(self) -> str:
lines = []
for pred in sorted(self.facts):
for tup in sorted(self.facts[pred]):
lines.append(f"{pred}({', '.join(tup)}).")
lines.extend(str(r) for r in self.rules)
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------
# Tokenizer + parser
# --------------------------------------------------------------------------
_TOKEN_RE = re.compile(
r"(?P<ws>[ \t\r\n]+)"
r"|(?P<comment>%[^\n]*)"
r"|(?P<string>\"(?:[^\"\\]|\\.)*\")"
r"|(?P<int>-?\d+)"
r"|(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
r"|(?P<punct>:-|[!(),.])"
)
def tokenize(text: str) -> list:
tokens = []
pos = 0
n = len(text)
while pos < n:
m = _TOKEN_RE.match(text, pos)
if m is None:
line = text.count("\n", 0, pos) + 1
raise ParseError(
f"unexpected character {text[pos]!r} at line {line}, column "
f"{pos - text.rfind(chr(10), 0, pos)}"
)
kind = m.lastgroup
if kind not in ("ws", "comment"):
tokens.append((kind, m.group()))
pos = m.end()
return tokens
class _Parser:
def __init__(self, tokens):
self.toks = tokens
self.i = 0
def peek(self, k: int = 0):
j = self.i + k
return self.toks[j] if j < len(self.toks) else (None, None)
def next(self):
tok = self.peek()
self.i += 1
return tok
def expect(self, kind, value=None):
k, v = self.next()
if k != kind or (value is not None and v != value):
want = value if value is not None else kind
raise ParseError(f"expected {want!r} but found {v!r}")
return v
# ---- grammar ------------------------------------------------------
def parse_term(self):
kind, value = self.next()
if kind in ("string", "int"):
return value
if kind == "name":
if value in ("not", "!"):
raise ParseError(f"{value!r} cannot be used as a term")
if value[0].isupper() or value[0] == "_":
return "?" + value
return value
raise ParseError(f"expected a term but found {value!r}")
def parse_literal(self):
negated = False
kind, value = self.peek()
if (kind == "name" and value == "not") or (kind == "punct" and value == "!"):
self.next()
negated = True
pred = self.expect("name")
if pred in ("not", "!"):
raise ParseError(f"{pred!r} cannot be used as a predicate name")
self.expect("punct", "(")
args = []
if self.peek() == ("punct", ")"):
self.next()
else:
while True:
args.append(self.parse_term())
kind, value = self.peek()
if (kind, value) == ("punct", ","):
self.next()
continue
break
self.expect("punct", ")")
return Literal(pred, tuple(args), negated)
def parse_program(self) -> Program:
program = Program()
while self.peek()[0] is not None:
head = self.parse_literal()
if head.negated:
raise ParseError("a clause head cannot be negated")
if self.peek() == ("punct", ":-"):
self.next()
body = [self.parse_literal()]
while self.peek() == ("punct", ","):
self.next()
body.append(self.parse_literal())
self.expect("punct", ".")
program.add_rule(Rule(head.pred, head.args, tuple(body)))
else:
self.expect("punct", ".")
for term in head.args:
if is_var(term):
raise ParseError(
f"fact {head.pred}/{len(head.args)} contains the "
f"variable {term}; facts must be ground"
)
program.add_fact(head.pred, head.args)
return program
def parse_program(text: str) -> Program:
"""Parse Datalog source text into a Program."""
return _Parser(tokenize(text)).parse_program()
def load(path: str) -> Program:
with open(path, "r", encoding="utf-8") as fh:
return parse_program(fh.read())
# --------------------------------------------------------------------------
# Static checks
# --------------------------------------------------------------------------
def check_arity(program: Program) -> None:
"""Every predicate must be used with one arity throughout."""
seen = dict(program.arities)
def note(pred, n, where):
if pred in seen and seen[pred] != n:
raise ArityError(
f"predicate {pred!r} used with arity {n} in {where} "
f"but arity {seen[pred]} elsewhere"
)
seen[pred] = n
for pred, tuples in program.facts.items():
for tup in tuples:
note(pred, len(tup), f"a fact of {pred!r}")
for rule in program.rules:
note(rule.head_pred, len(rule.head_args), f"the head of {rule}")
for lit in rule.body:
note(lit.pred, len(lit.args), f"the body of {rule}")
def check_safety(program: Program) -> None:
"""Range restriction: head and negated-literal variables must be bound by a
positive body literal, and every rule needs at least one positive literal."""
for rule in program.rules:
if not rule.positive:
raise SafetyError(f"rule {rule} has no positive body literal (unsafe)")
bound = set()
for lit in rule.positive:
for term in lit.args:
if is_var(term) and not is_anon(term):
bound.add(term)
for term in rule.head_args:
if is_anon(term):
raise SafetyError(f"anonymous variable in the head of {rule}")
if is_var(term) and term not in bound:
raise SafetyError(
f"head variable {term} of {rule} is not bound by a positive "
f"body literal (unsafe)"
)
for lit in rule.negated:
for term in lit.args:
if is_var(term) and not is_anon(term) and term not in bound:
raise SafetyError(
f"variable {term} in the negated literal {lit} of {rule} "
f"is not bound by a positive body literal (unsafe)"
)
def _tarjan_scc(nodes, adj) -> list:
"""Iterative Tarjan SCC. Returns components as sorted lists, deterministic."""
index: dict = {}
low: dict = {}
on_stack: dict = {}
stack: list = []
out: list = []
counter = [0]
for root in nodes:
if root in index:
continue
index[root] = low[root] = counter[0]
counter[0] += 1
stack.append(root)
on_stack[root] = True
work = [(root, iter(sorted(adj.get(root, ()))))]
while work:
node, it = work[-1]
advanced = False
for nxt in it:
if nxt not in index:
index[nxt] = low[nxt] = counter[0]
counter[0] += 1
stack.append(nxt)
on_stack[nxt] = True
work.append((nxt, iter(sorted(adj.get(nxt, ())))))
advanced = True
break
if on_stack.get(nxt):
low[node] = min(low[node], index[nxt])
if advanced:
continue
work.pop()
if work:
parent = work[-1][0]
low[parent] = min(low[parent], low[node])
if low[node] == index[node]:
comp = []
while True:
top = stack.pop()
on_stack[top] = False
comp.append(top)
if top == node:
break
out.append(sorted(comp))
return out
def stratify(program: Program) -> list:
"""Split predicates into strata (a list of frozensets, dependencies first).
Raises StratificationError when a negated literal sits inside a recursive
cycle, i.e. when head and negated body predicate share an SCC.
"""
preds = sorted(program.predicates())
adj = {p: set() for p in preds}
neg_edges = []
for rule in program.rules:
for lit in rule.body:
adj[rule.head_pred].add(lit.pred)
if lit.negated:
neg_edges.append((rule.head_pred, lit.pred))
comps = _tarjan_scc(preds, adj)
comp_of = {}
for idx, comp in enumerate(comps):
for pred in comp:
comp_of[pred] = idx
for head, body in neg_edges:
if comp_of[head] == comp_of[body]:
members = ", ".join(comps[comp_of[head]])
raise StratificationError(
f"negation is not stratified: {head} negates {body} but they are "
f"mutually recursive (SCC: {members})"
)
# condensation: edge comp(p) -> comp(q) means "p depends on q", so q must be
# computed first. Kahn over the reversed relation.
must_precede = {i: set() for i in range(len(comps))}
for pred in preds:
for dep in adj[pred]:
a, b = comp_of[pred], comp_of[dep]
if a != b:
must_precede[a].add(b)
indeg = {i: len(must_precede[i]) for i in must_precede}
dependents = {i: set() for i in must_precede}
for i, deps in must_precede.items():
for dep in deps:
dependents[dep].add(i)
ready = sorted((i for i, d in indeg.items() if d == 0), key=lambda i: comps[i][0])
order = []
while ready:
cur = ready.pop(0)
order.append(cur)
for nxt in sorted(dependents[cur], key=lambda i: comps[i][0]):
indeg[nxt] -= 1
if indeg[nxt] == 0:
ready.append(nxt)
ready.sort(key=lambda i: comps[i][0])
if len(order) != len(comps): # pragma: no cover - condensation is a DAG
raise StratificationError("internal error: condensation is not a DAG")
return [frozenset(comps[i]) for i in order]
# --------------------------------------------------------------------------
# Evaluation
# --------------------------------------------------------------------------
def _match(pattern, tup, env):
"""Unify a literal's argument pattern against a tuple. Returns a new env or
None. Anonymous variables never bind. Bindings made while walking this
literal are visible to its later arguments, so `p(X, X)` really does mean
"both arguments equal"."""
cur = env
copied = False
for term, value in zip(pattern, tup):
if is_var(term):
if is_anon(term):
continue
name = var_name(term)
if name in cur:
if cur[name] != value:
return None
else:
if not copied:
cur = dict(env)
copied = True
cur[name] = value
elif term != value:
return None
return cur
def _subst(term, env):
if is_var(term):
if is_anon(term):
raise SafetyError("anonymous variable cannot be substituted")
return env[var_name(term)]
return term
def _negation_fails(lit, env, rels):
"""True when the negated literal matches some tuple (so the body fails)."""
rel = rels.get(lit.pred, ())
if not rel:
return False
for tup in rel:
if _match(lit.args, tup, env) is not None:
return True
return False
def _order_pos(rule, R, rels):
"""Positive body literals, smallest relation first (deterministic)."""
def size(lit):
if lit.pred in R:
return len(R[lit.pred])
return len(rels.get(lit.pred, ()))
return sorted(rule.positive, key=lambda lit: (size(lit), lit.pred))
def _join(rule, pos, R, rels, override, stats):
"""Enumerate satisfying environments for the positive body literals."""
def relation_for(idx, lit):
if override and idx in override:
return override[idx]
if lit.pred in R:
return R[lit.pred]
return rels.get(lit.pred, ())
def rec(idx, env):
if idx == len(pos):
for lit in rule.negated:
if _negation_fails(lit, env, rels):
return
yield env
return
lit = pos[idx]
for tup in sorted(relation_for(idx, lit)):
stats["bindings"] += 1
env2 = _match(lit.args, tup, env)
if env2 is not None:
yield from rec(idx + 1, env2)
yield from rec(0, {})
def _derive_one(rule, pos, R, rels, override, out, stats):
head_pred = rule.head_pred
for env in _join(rule, pos, R, rels, override, stats):
stats["rule_runs"] += 1
tup = tuple(_subst(term, env) for term in rule.head_args)
if tup in R[head_pred] or tup in out[head_pred]:
continue
out[head_pred].add(tup)
stats["derived"] += 1
def _derive(rules_here, R, rels, wave, override, stats):
out = {pred: set() for pred in wave}
for rule in rules_here:
pos = _order_pos(rule, R, rels)
_derive_one(rule, pos, R, rels, override, out, stats)
return out
def _eval_stratum(program, wave, rels, stats):
rules_here = [r for r in program.rules if r.head_pred in wave]
if not rules_here:
return
R = {pred: rels[pred] for pred in wave} # live sets, mutated in place
# Round 0 is naive: it also fires rules whose positive body lives entirely in
# lower strata (such a rule can never see a same-stratum delta).
new = _derive(rules_here, R, rels, wave, None, stats)
for pred, added in new.items():
R[pred] |= added
delta = {pred: set(added) for pred, added in new.items()}
while any(delta.values()):
stats["rounds"] += 1
new = {pred: set() for pred in wave}
for rule in rules_here:
pos = _order_pos(rule, R, rels)
for idx, lit in enumerate(pos):
if lit.pred in wave and delta.get(lit.pred):
_derive_one(
rule, pos, R, rels, {idx: delta[lit.pred]}, new, stats
)
for pred, added in new.items():
R[pred] |= added
delta = new
def evaluate(program: Program, stats: dict = None, check: bool = True) -> dict:
"""Semi-naive bottom-up evaluation. Returns {pred: frozenset(tuples)}."""
if check:
check_arity(program)
check_safety(program)
strata = stratify(program)
if stats is None:
stats = {}
stats.update(rounds=0, bindings=0, derived=0, rule_runs=0)
rels = {pred: set(program.facts.get(pred, ())) for pred in program.predicates()}
for wave in strata:
_eval_stratum(program, wave, rels, stats)
return {pred: frozenset(tuples) for pred, tuples in rels.items()}
# --------------------------------------------------------------------------
# Output
# --------------------------------------------------------------------------
def format_relations(rels: dict, preds=None) -> str:
lines = []
for pred in sorted(rels) if preds is None else list(preds):
tuples = sorted(rels.get(pred, ()))
lines.append(f"{pred}: {len(tuples)} tuple(s)")
for tup in tuples:
lines.append(f" {pred}({', '.join(tup)}).")
return "\n".join(lines)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
prog="datalog.py",
description="Evaluate a Datalog program (stratified negation, semi-naive).",
)
ap.add_argument("program", help="path to a Datalog source file")
ap.add_argument("-q", "--query", action="append", default=None,
help="only report this predicate (repeatable)")
ap.add_argument("--text", action="store_true", help="human-readable output")
ap.add_argument("--stats", action="store_true", help="include run statistics")
args = ap.parse_args(argv)
try:
program = load(args.program)
stats = {}
rels = evaluate(program, stats)
strata = stratify(program)
except DatalogError as exc:
print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if args.text:
print(format_relations(rels, args.query))
if args.stats:
print(json.dumps(stats, sort_keys=True))
return 0
payload = {
"predicates": {
pred: [list(t) for t in sorted(rels[pred])]
for pred in (sorted(rels) if args.query is None else args.query)
},
"strata": [sorted(wave) for wave in strata],
}
if args.stats:
payload["stats"] = stats
print(json.dumps(payload, sort_keys=True, indent=2))
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""evidence_run.py -- reproduce every number quoted in EVIDENCE.txt, in one run.
python3 evidence_run.py (exit 0 = all sections ran clean)
Wizard: null Lane: Codeforge (hard coding and agentic reasoning)
"""
from __future__ import annotations
import io
import os
import random
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import datalog # noqa: E402
import test_datalog as T # noqa: E402
def main() -> int:
print("== 1. full unittest suite (the CHECK command runs this same suite)")
suite = unittest.defaultTestLoader.loadTestsFromModule(T)
stream = io.StringIO()
result = unittest.TextTestRunner(stream=stream, verbosity=1).run(suite)
print(stream.getvalue().strip())
print(f"testsRun={result.testsRun} failures={len(result.failures)} "
f"errors={len(result.errors)}")
print("\n== 2. semi-naive vs naive work on a chain transitive closure")
print(f"{'n':>3} {'|path|':>7} {'semi-naive':>11} {'naive':>10} {'speedup':>8} {'rounds':>7}")
for n in (6, 12, 16, 20):
program = T.WorkReductionTests.chain_program(n)
engine_stats, oracle_stats = {}, {}
got = datalog.evaluate(program, engine_stats)
want = T.oracle_evaluate(program, oracle_stats)
assert got == want, f"engine/oracle disagree at n={n}"
ratio = oracle_stats["bindings"] / max(engine_stats["bindings"], 1)
print(f"{n:>3} {len(got['path']):>7} {engine_stats['bindings']:>11} "
f"{oracle_stats['bindings']:>10} {ratio:>7.1f}x {engine_stats['rounds']:>7}")
print("\n== 3. fresh-seed differential run: 400 programs, engine vs oracle")
rng = random.Random(999)
programs = ([T.gen_program(rng, constrained=True) for _ in range(200)]
+ [T.gen_program(rng, constrained=False) for _ in range(200)])
counters = {"programs": 0, "unstratified": 0, "predicates": 0}
failures = T.differential_run(programs, counters)
print(f"programs={counters['programs']} unstratified={counters['unstratified']} "
f"predicate-relations compared={counters['predicates']} "
f"mismatches={len(failures)}")
for idx, text, why in failures[:3]:
print(f"MISMATCH #{idx}: {why}\n{text}")
print("\n== 4. strata assignment + refusal of a non-stratified program")
program = datalog.parse_program(
"node(a). node(b). node(c). source(a). edge(a,b).\n"
"reach(X) :- source(X).\n"
"reach(X) :- edge(Y,X), reach(Y).\n"
"unreach(X) :- node(X), not reach(X).\n"
)
print("strata:", [sorted(w) for w in datalog.stratify(program)])
rels = datalog.evaluate(program)
print("reach:", sorted(rels["reach"]), " unreach:", sorted(rels["unreach"]))
try:
datalog.stratify(datalog.parse_program(
"dom(a). p(X) :- dom(X), not q(X). q(X) :- p(X).\n"
))
print("ERROR: unstratified program was accepted")
return 1
except datalog.StratificationError as exc:
print("refused:", exc)
ok = (len(result.failures) == 0 and len(result.errors) == 0
and not failures and result.testsRun >= 40)
print("\nALL SECTIONS CLEAN" if ok else "\nSOMETHING FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,10 @@
% examples/reach.dl -- stratified negation + recursion, the CLI demo program.
node(a). node(b). node(c). node(d).
source(a).
edge(a, b). edge(b, c).
reach(X) :- source(X).
reach(X) :- edge(Y, X), reach(Y).
unreachable(X) :- node(X), not reach(X).
sink(X) :- node(X), not edge(X, _).
+706
View File
@@ -0,0 +1,706 @@
#!/usr/bin/env python3
"""test_datalog.py -- verification suite for datalog.py.
Wizard: null Lane: Codeforge (hard coding and agentic reasoning)
Exit code is the verdict: 0 = every check passed, 1 = at least one failed.
What is verified, and how
-------------------------
1. Parser KATs -- facts, rules, comments, '!', zero-arity, int/string
constants, and six malformed inputs that must raise.
2. Static checks -- arity conflicts, rule safety, stratification.
3. Evaluation KATs -- transitive closure, the non-linear same-generation
program, stratified negation, anonymous variables.
4. Order independence -- shuffled fact/rule order gives identical relations.
5. Differential testing -- 600 randomly generated programs are evaluated by the
engine AND by an *independent oracle* written from
scratch in this file (reachability-closure SCCs +
Kahn waves for stratification; cartesian-product
naive fixpoint for evaluation). Both the stratified/
unstratified verdict and every relation must agree.
6. Work reduction -- semi-naive evaluation must enumerate strictly fewer
body bindings than the naive oracle on a chain
transitive closure (i.e. the delta rule is doing real
work, not just returning the right answer by luck).
7. CLI end-to-end -- subprocess run of datalog.py, JSON contract, exit
codes for success and for an unstratified program.
The oracle deliberately shares NOTHING with the engine except the parser and the
term representation; it re-derives stratification and evaluation itself.
"""
from __future__ import annotations
import itertools
import json
import os
import random
import subprocess
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import datalog # noqa: E402
from datalog import ( # noqa: E402
ArityError,
Literal,
ParseError,
Program,
Rule,
SafetyError,
StratificationError,
check_arity,
check_safety,
evaluate,
parse_program,
stratify,
)
DIFFERENTIAL_RUNS = {"programs": 0, "unstratified": 0, "predicates": 0}
# ==========================================================================
# Independent oracle
# ==========================================================================
def oracle_dependency_graph(program):
preds = sorted(program.predicates())
adj = {p: set() for p in preds}
for rule in program.rules:
for lit in rule.body:
adj[rule.head_pred].add(lit.pred)
return preds, adj
def oracle_reachability(program):
preds, adj = oracle_dependency_graph(program)
reach = {p: set(adj[p]) | {p} for p in preds}
changed = True
while changed:
changed = False
for p in preds:
grown = set(reach[p])
for q in list(reach[p]):
grown |= reach[q]
if grown != reach[p]:
reach[p] = grown
changed = True
return preds, adj, reach
def oracle_stratify(program):
"""Stratify via reachability closure. Returns waves (list[list[str]]) or
None when the program is not stratified."""
preds, adj, reach = oracle_reachability(program)
neg = {(r.head_pred, lit.pred) for r in program.rules for lit in r.negated}
sccs, seen = [], set()
for p in preds:
if p in seen:
continue
comp = sorted(q for q in preds if q in reach[p] and p in reach[q])
seen.update(comp)
sccs.append(comp)
comp_of = {p: i for i, comp in enumerate(sccs) for p in comp}
for head, body in neg:
if comp_of[head] == comp_of[body]:
return None
before = {i: set() for i in range(len(sccs))}
for p in preds:
for q in adj[p]:
if comp_of[p] != comp_of[q]:
before[comp_of[p]].add(comp_of[q])
waves, assigned = [], set()
while len(assigned) < len(sccs):
ready = sorted(
(i for i in range(len(sccs)) if i not in assigned and before[i] <= assigned),
key=lambda i: sccs[i][0],
)
if not ready:
return None
assigned.update(ready)
waves.append(sorted(p for i in ready for p in sccs[i]))
return waves
def oracle_unify(pattern, tup, env):
cur = env
copied = False
for term, value in zip(pattern, tup):
if datalog.is_var(term):
if datalog.is_anon(term):
continue
name = term[1:]
if name in cur:
if cur[name] != value:
return None
else:
if not copied:
cur = dict(env)
copied = True
cur[name] = value
elif term != value:
return None
return cur
def oracle_neg_fails(lit, env, rels):
for tup in rels[lit.pred]:
if oracle_unify(lit.args, tup, env) is not None:
return True
return False
def oracle_evaluate(program, stats=None):
"""Naive (product-based) fixpoint per wave. Returns None if unstratified."""
waves = oracle_stratify(program)
if waves is None:
return None
if stats is None:
stats = {}
stats.setdefault("bindings", 0)
rels = {p: set(program.facts.get(p, ())) for p in program.predicates()}
for wave in waves:
wave = set(wave)
changed = True
while changed:
changed = False
for rule in program.rules:
if rule.head_pred not in wave:
continue
pools = [sorted(rels[lit.pred]) for lit in rule.positive]
for combo in itertools.product(*pools):
stats["bindings"] += 1
env = {}
ok = True
for lit, tup in zip(rule.positive, combo):
env = oracle_unify(lit.args, tup, env)
if env is None:
ok = False
break
if not ok:
continue
for lit in rule.negated:
if oracle_neg_fails(lit, env, rels):
ok = False
break
if not ok:
continue
head = tuple(
env[t[1:]] if datalog.is_var(t) else t for t in rule.head_args
)
if head not in rels[rule.head_pred]:
rels[rule.head_pred].add(head)
changed = True
return {p: frozenset(v) for p, v in rels.items()}
# ==========================================================================
# Random program generators
# ==========================================================================
CONSTS = ["a", "b", "c"]
VARS = ["?X", "?Y", "?Z"]
EDB2 = ["e0", "e1"]
IDB2 = ["p0", "p1"]
IDB1 = ["q0", "q1"]
def _random_facts(rng):
facts = {}
for pred in EDB2:
fs = set()
for a in CONSTS:
for b in CONSTS:
if rng.random() < 0.30:
fs.add((a, b))
facts[pred] = fs
return facts
def gen_program(rng, constrained=True):
"""Random program. constrained=True keeps negation strictly lower-stratum,
so the program is stratified by construction; constrained=False lets
negation point anywhere, which yields unstratified programs too."""
arity = {p: 2 for p in EDB2 + IDB2}
arity.update({p: 1 for p in IDB1})
levels = {p: 0 for p in EDB2}
for p in IDB2 + IDB1:
levels[p] = rng.choice([1, 1, 2])
program = Program(facts=_random_facts(rng), rules=[], arities=dict(arity))
for _ in range(rng.randint(1, 5)):
head_pred = rng.choice(IDB2 + IDB1)
hl = levels[head_pred]
if constrained:
cands = [p for p in arity if levels[p] <= hl]
else:
cands = list(arity)
positives, used_vars = [], []
for _ in range(rng.randint(1, 2)):
bp = rng.choice(cands)
args = []
for _ in range(arity[bp]):
if rng.random() < 0.15:
args.append(rng.choice(CONSTS))
else:
args.append(rng.choice(VARS))
positives.append(Literal(bp, tuple(args), False))
used_vars.extend(a for a in args if datalog.is_var(a))
def pick():
if used_vars and rng.random() < 0.85:
return rng.choice(used_vars)
return rng.choice(CONSTS)
head_args = tuple(pick() for _ in range(arity[head_pred]))
body = list(positives)
if rng.random() < 0.35:
if constrained:
lower = [p for p in arity if levels[p] < hl]
else:
lower = list(arity)
if lower:
bp = rng.choice(lower)
body.append(
Literal(bp, tuple(pick() for _ in range(arity[bp])), True)
)
program.add_rule(Rule(head_pred, head_args, tuple(body)))
return program
# ==========================================================================
# 1. parser
# ==========================================================================
class ParserTests(unittest.TestCase):
def test_facts_and_rule_shape(self):
p = parse_program("edge(a, b).\npath(X, Y) :- edge(X, Y).\n")
self.assertEqual(p.facts, {"edge": {("a", "b")}})
self.assertEqual(len(p.rules), 1)
rule = p.rules[0]
self.assertEqual(rule.head_pred, "path")
self.assertEqual(rule.head_args, ("?X", "?Y"))
self.assertEqual(str(rule), "path(?X, ?Y) :- edge(?X, ?Y).")
def test_comments_and_whitespace(self):
text = "% a comment\n\nedge(a,b). % trailing\npath(X,Y):-edge(X,Y).\n"
p = parse_program(text)
self.assertEqual(p.facts["edge"], {("a", "b")})
self.assertEqual(len(p.rules), 1)
def test_bang_is_not(self):
p = parse_program("dom(a). p(X) :- dom(X), ! q(X). q(b).\n")
self.assertTrue(p.rules[0].negated)
self.assertEqual(p.rules[0].negated[0].pred, "q")
def test_zero_arity_and_constants(self):
p = parse_program('flag(). n(1). s("x").\n')
self.assertEqual(p.facts["flag"], {()})
self.assertEqual(p.facts["n"], {("1",)})
self.assertEqual(p.facts["s"], {('"x"',)})
def test_variable_in_fact_rejected(self):
with self.assertRaises(ParseError):
parse_program("p(X).\n")
def test_bad_character_rejected(self):
with self.assertRaises(ParseError):
parse_program("p(a) # oops.\n")
def test_missing_dot_rejected(self):
with self.assertRaises(ParseError):
parse_program("p(a)\n")
def test_negated_head_rejected(self):
with self.assertRaises(ParseError):
parse_program("dom(a). not p(X) :- dom(X).\n")
def test_unbalanced_parens_rejected(self):
with self.assertRaises(ParseError):
parse_program("p(a, b.\n")
def test_empty_body_rejected(self):
with self.assertRaises(ParseError):
parse_program("p(a) :- .\n")
# ==========================================================================
# 2. static checks
# ==========================================================================
class StaticCheckTests(unittest.TestCase):
def test_arity_conflict_between_fact_and_body(self):
p = parse_program("q(a). p(X) :- q(X, Y).\n")
with self.assertRaises(ArityError):
evaluate(p)
def test_arity_conflict_between_facts(self):
p = parse_program("p(a, b). p(a).\n")
with self.assertRaises(ArityError):
check_arity(p)
def test_unbound_head_variable(self):
p = parse_program("q(a). p(X) :- q(Y).\n")
with self.assertRaises(SafetyError):
evaluate(p)
def test_rule_without_positive_body(self):
p = parse_program("dom(a). p(X) :- not dom(X).\n")
with self.assertRaises(SafetyError):
check_safety(p)
def test_unbound_variable_inside_negation(self):
p = parse_program("dom(a). q(a). p(X) :- q(Y), not dom(X).\n")
with self.assertRaises(SafetyError):
check_safety(p)
def test_anonymous_variable_in_head(self):
p = parse_program("q(a). p(_) :- q(a).\n")
with self.assertRaises(SafetyError):
check_safety(p)
def test_negation_inside_recursive_cycle_rejected(self):
p = parse_program("dom(a). p(X) :- dom(X), not q(X). q(X) :- p(X).\n")
with self.assertRaises(StratificationError):
stratify(p)
self.assertIsNone(oracle_stratify(p))
def test_mutual_negation_rejected(self):
p = parse_program(
"dom(a). p(X) :- dom(X), not q(X). q(X) :- dom(X), not p(X).\n"
)
with self.assertRaises(StratificationError):
stratify(p)
self.assertIsNone(oracle_stratify(p))
def test_positive_recursion_is_not_a_stratification_error(self):
p = parse_program(
"edge(a,b). edge(b,c).\n"
"path(X,Y) :- edge(X,Y).\n"
"path(X,Y) :- edge(X,Z), path(Z,Y).\n"
)
waves = stratify(p)
self.assertEqual(len(waves), 2)
self.assertEqual(waves[0], frozenset({"edge"}))
self.assertEqual(waves[1], frozenset({"path"}))
self.assertIsNotNone(oracle_stratify(p))
def test_negation_to_lower_stratum_is_fine(self):
p = parse_program(
"dom(a). dom(b). q(a).\n"
"r(X) :- dom(X), not q(X).\n"
)
waves = stratify(p)
names = [sorted(w) for w in waves]
self.assertLess(names.index(["q"]), names.index(["r"]))
self.assertEqual(evaluate(p)["r"], frozenset({("b",)}))
# ==========================================================================
# 3. evaluation KATs
# ==========================================================================
class EvaluationTests(unittest.TestCase):
def test_transitive_closure(self):
p = parse_program(
"edge(a,b). edge(b,c). edge(c,d).\n"
"path(X,Y) :- edge(X,Y).\n"
"path(X,Y) :- edge(X,Z), path(Z,Y).\n"
)
rels = evaluate(p)
self.assertEqual(
rels["path"],
frozenset({("a", "b"), ("a", "c"), ("a", "d"),
("b", "c"), ("b", "d"), ("c", "d")}),
)
def test_same_generation_nonlinear_recursion(self):
p = parse_program(
"person(a1). person(a2). person(b1). person(b2).\n"
"parent(b1, a1). parent(b1, a2). parent(b2, a1). parent(b2, a2).\n"
"sg(X, X) :- person(X).\n"
"sg(X, Y) :- parent(X, A), parent(Y, B), sg(A, B).\n"
)
rels = evaluate(p)
self.assertEqual(
rels["sg"],
frozenset({("a1", "a1"), ("a2", "a2"), ("b1", "b1"), ("b2", "b2"),
("b1", "b2"), ("b2", "b1")}),
)
def test_stratified_negation(self):
p = parse_program(
"node(a). node(b). node(c).\n"
"source(a).\n"
"edge(a,b).\n"
"reach(X) :- source(X).\n"
"reach(X) :- edge(Y,X), reach(Y).\n"
"unreachable(X) :- node(X), not reach(X).\n"
)
rels = evaluate(p)
self.assertEqual(rels["reach"], frozenset({("a",), ("b",)}))
self.assertEqual(rels["unreachable"], frozenset({("c",)}))
def test_anonymous_variable_in_negated_literal(self):
p = parse_program(
"node(a). node(b). node(c). edge(a,b). edge(c,a).\n"
"sink(X) :- node(X), not edge(X, _).\n"
)
rels = evaluate(p)
self.assertEqual(rels["sink"], frozenset({("b",)}))
def test_repeated_variable_inside_one_literal(self):
# Regression: the join reorders positive literals smallest-first, so
# `p1(X, X)` can be the FIRST literal joined. A repeated variable must
# still constrain both argument positions of that single tuple.
p = parse_program(
"e0(a, a). e0(a, b). e0(b, a). e1(b, a). e1(b, b). e1(c, a).\n"
"q1(X) :- e0(Z, X).\n"
"p1(Y, c) :- e0(Y, Z), not q0(Z).\n"
"p0(X, c) :- e1(X, Y), p1(X, X), not q0(X).\n"
)
rels = evaluate(p)
self.assertEqual(rels["p0"], frozenset())
self.assertEqual(rels["p1"], frozenset({("a", "c"), ("b", "c")}))
self.assertEqual(rels["p0"], oracle_evaluate(p)["p0"])
def test_negation_over_empty_relation_always_succeeds(self):
p = parse_program("dom(a). p(X) :- dom(X), not missing(X).\n")
rels = evaluate(p)
self.assertEqual(rels["p"], frozenset({("a",)}))
self.assertEqual(rels["missing"], frozenset())
def test_zero_arity_derivation(self):
p = parse_program("flag(). ready() :- flag().\n")
rels = evaluate(p)
self.assertEqual(rels["ready"], frozenset({()}))
def test_constant_arguments_and_repeated_variable(self):
p = parse_program("e(a,a). e(a,b). loop(X) :- e(X, X).\n")
rels = evaluate(p)
self.assertEqual(rels["loop"], frozenset({("a",)}))
def test_duplicate_facts_collapse(self):
p = parse_program("edge(a,b). edge(a,b). edge(a,b).\n")
rels = evaluate(p)
self.assertEqual(rels["edge"], frozenset({("a", "b")}))
def test_ground_rule_without_variables(self):
p = parse_program("dom(a). mark(a) :- dom(a).\n")
rels = evaluate(p)
self.assertEqual(rels["mark"], frozenset({("a",)}))
def test_order_independence(self):
text = (
"edge(a,b). edge(b,c). edge(c,d).\n"
"path(X,Y) :- edge(X,Y).\n"
"path(X,Y) :- edge(X,Z), path(Z,Y).\n"
)
base = evaluate(parse_program(text))
rng = random.Random(7)
for _ in range(5):
clauses = [c.strip() for c in text.strip().split("\n") if c.strip()]
rng.shuffle(clauses)
shuffled = evaluate(parse_program("\n".join(clauses) + "\n"))
self.assertEqual(shuffled, base)
def test_repeated_evaluation_is_deterministic(self):
text = (
"n(a). n(b). n(c). n(d).\n"
"e(a,b). e(b,c). e(c,d). e(d,a).\n"
"r(X,Y) :- e(X,Y).\n"
"r(X,Y) :- r(X,Z), e(Z,Y).\n"
"lonely(X) :- n(X), not r(X, _).\n"
)
first = evaluate(parse_program(text))
for _ in range(3):
self.assertEqual(evaluate(parse_program(text)), first)
self.assertEqual(len(first["r"]), 16)
# ==========================================================================
# 4. differential testing against the oracle
# ==========================================================================
def differential_run(programs, counters=None):
"""Evaluate every program with the engine and with the oracle; return a list
of (index, program_text, explanation) mismatches. `counters` accumulates the
coverage numbers."""
if counters is None:
counters = {"programs": 0, "unstratified": 0, "predicates": 0}
failures = []
for idx, program in enumerate(programs):
stats = {}
try:
got = evaluate(program, stats)
got_error = None
except (StratificationError, ArityError, SafetyError) as exc:
got, got_error = None, type(exc).__name__
want = oracle_evaluate(program)
counters["programs"] += 1
if want is None:
counters["unstratified"] += 1
if got_error != "StratificationError":
failures.append(
(idx, program.text(),
f"oracle says unstratified, engine said {got_error!r}")
)
continue
if got_error is not None:
failures.append(
(idx, program.text(), f"oracle stratified, engine raised {got_error}")
)
continue
counters["predicates"] += len(got)
if got != want:
diff = {
p: (sorted(got.get(p, ())), sorted(want.get(p, ())))
for p in sorted(set(got) | set(want))
if got.get(p) != want.get(p)
}
failures.append((idx, program.text(), f"relation mismatch {diff}"))
return failures
class DifferentialTests(unittest.TestCase):
def _run(self, programs):
failures = differential_run(programs, DIFFERENTIAL_RUNS)
return failures
def test_differential_constrained_300(self):
rng = random.Random(20261024)
programs = [gen_program(rng, constrained=True) for _ in range(300)]
failures = self._run(programs)
self.assertEqual(failures[:3], [], f"{len(failures)}/300 mismatches")
def test_differential_free_negation_300(self):
rng = random.Random(20261025)
programs = [gen_program(rng, constrained=False) for _ in range(300)]
failures = self._run(programs)
self.assertEqual(failures[:3], [], f"{len(failures)}/300 mismatches")
self.assertGreater(
DIFFERENTIAL_RUNS["unstratified"], 0,
"the free generator produced no unstratified program, so the "
"stratification check was not exercised",
)
def test_generator_actually_builds_nontrivial_programs(self):
rng = random.Random(1)
programs = [gen_program(rng, constrained=True) for _ in range(50)]
self.assertTrue(all(p.rules for p in programs))
self.assertGreater(sum(len(p.rules) for p in programs), 100)
self.assertTrue(any(any(lit.negated for lit in r.body)
for p in programs for r in p.rules))
# ==========================================================================
# 5. semi-naive does less work than naive
# ==========================================================================
class WorkReductionTests(unittest.TestCase):
@staticmethod
def chain_program(n):
facts = "".join(f"edge(n{i}, n{i + 1}).\n" for i in range(n - 1))
return parse_program(
facts
+ "path(X,Y) :- edge(X,Y).\n"
+ "path(X,Y) :- edge(X,Z), path(Z,Y).\n"
)
def test_semi_naive_enumerates_fewer_bindings(self):
program = self.chain_program(12)
engine_stats, oracle_stats = {}, {}
rels = evaluate(program, engine_stats)
oracle_rels = oracle_evaluate(program, oracle_stats)
self.assertEqual(rels, oracle_rels)
self.assertEqual(len(rels["path"]), 66) # C(12,2)
self.assertLess(
engine_stats["bindings"], oracle_stats["bindings"],
f"semi-naive bindings {engine_stats['bindings']} vs naive "
f"{oracle_stats['bindings']}",
)
# The delta rule must buy a real margin, not a rounding error.
self.assertLess(engine_stats["bindings"], oracle_stats["bindings"] // 2)
def test_stats_are_populated(self):
stats = {}
evaluate(self.chain_program(6), stats)
for key in ("rounds", "bindings", "derived", "rule_runs"):
self.assertIn(key, stats)
self.assertGreater(stats["derived"], 0)
self.assertGreater(stats["rounds"], 1)
# ==========================================================================
# 6. CLI
# ==========================================================================
class CliTests(unittest.TestCase):
SCRIPT = os.path.join(HERE, "datalog.py")
def _run(self, text, *args):
with tempfile.TemporaryDirectory(prefix=".datalog-fixture-", dir=HERE) as tmp:
path = os.path.join(tmp, "program.dl")
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
proc = subprocess.run(
[sys.executable, self.SCRIPT, path, *args],
capture_output=True, text=True, timeout=120,
)
return proc
def test_json_contract(self):
proc = self._run(
"node(a). node(b). node(c). source(a). edge(a,b).\n"
"reach(X) :- source(X).\n"
"reach(X) :- edge(Y,X), reach(Y).\n"
"unreach(X) :- node(X), not reach(X).\n",
"--stats",
)
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertEqual(payload["predicates"]["reach"], [["a"], ["b"]])
self.assertEqual(payload["predicates"]["unreach"], [["c"]])
self.assertEqual(payload["predicates"]["node"], [["a"], ["b"], ["c"]])
self.assertIn("stats", payload)
self.assertTrue(payload["strata"])
def test_unstratified_program_exits_2(self):
proc = self._run("dom(a). p(X) :- dom(X), not q(X). q(X) :- p(X).\n")
self.assertEqual(proc.returncode, 2)
self.assertIn("StratificationError", proc.stderr)
def test_text_output(self):
proc = self._run("e(a,b).\n", "--text")
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("e(a, b).", proc.stdout)
def tearDownModule():
print(
"\n[differential] programs evaluated: {programs} "
"(unstratified: {unstratified}, predicate-relations compared: {predicates})".format(
**DIFFERENTIAL_RUNS
),
file=sys.stderr,
)
if __name__ == "__main__":
unittest.main(verbosity=1)