From e5c8851e4761d31ed5e77494f9f68f395fa0f52c Mon Sep 17 00:00:00 2001
From: Hassan Abedi
Date: Tue, 30 Jun 2026 11:31:15 +0200
Subject: [PATCH 1/5] Update Ordered library to version `0.2.1`
---
build.zig.zon | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/build.zig.zon b/build.zig.zon
index 06f7b35..c25b20a 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -9,8 +9,8 @@
.hash = "minish-0.3.0-SQtSTYI3AgCxWdWbKxS_lvmfbp0wJk29ZW5C9CozJaxm",
},
.ordered = .{
- .url = "https://github.com/CogitatorTech/ordered/archive/refs/tags/v0.2.0.tar.gz",
- .hash = "ordered-0.2.0-Gy41sAAkAgBO3TZAn9nYuspdeDcD_sHLoIGEZY4pCDDM",
+ .url = "https://github.com/CogitatorTech/ordered/archive/refs/tags/v0.2.1.tar.gz",
+ .hash = "ordered-0.2.1-Gy41sDCgAgA_QUoMf8Upew_Yx8sAPe7Fw_A2v5qP8GMK",
},
},
.paths = .{ "build.zig", "build.zig.zon", "src", "LICENSE", "README.md" },
From 644613708d8d736478a5ff11c564a313c25742b2 Mon Sep 17 00:00:00 2001
From: Hassan Abedi
Date: Sat, 1 Aug 2026 11:30:27 +0200
Subject: [PATCH 2/5] Fix a buch of bugs
---
AGENTS.md | 2 +-
ROADMAP.md | 6 +-
examples/README.md | 2 +
examples/e3_data_lineage.zig | 4 +-
examples/e4_rbac_authorization.zig | 4 +-
examples/e5_taint_analysis.zig | 4 +-
examples/e6_dependency_resolution.zig | 4 +-
examples/e9_arithmetic_hops.zig | 60 +++
src/zodd/aggregate.zig | 43 ++-
src/zodd/extend.zig | 46 ++-
src/zodd/frontend/analyze.zig | 221 +++++++++--
src/zodd/frontend/ast.zig | 64 ++-
src/zodd/frontend/builder.zig | 82 +++-
src/zodd/frontend/dyntuple.zig | 2 +-
src/zodd/frontend/evaluator.zig | 35 +-
src/zodd/frontend/explain.zig | 166 +++++++-
src/zodd/frontend/magic.zig | 364 ++++++++++++++++++
src/zodd/frontend/parser.zig | 114 +++++-
src/zodd/frontend/plan.zig | 89 ++++-
src/zodd/frontend/program.zig | 170 +++++++-
src/zodd/frontend/token.zig | 16 +-
src/zodd/index.zig | 98 ++++-
src/zodd/iteration.zig | 12 +
src/zodd/join.zig | 6 +-
src/zodd/relation.zig | 69 +++-
src/zodd/variable.zig | 3 +-
tests/frontend_tests.zig | 535 ++++++++++++++++++++++++++
web/index.html | 17 +-
web/zodd_wasm.zig | 2 +-
29 files changed, 2119 insertions(+), 121 deletions(-)
create mode 100644 examples/e9_arithmetic_hops.zig
create mode 100644 src/zodd/frontend/magic.zig
diff --git a/AGENTS.md b/AGENTS.md
index 1bde233..a824494 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@ Priorities, in order:
- `src/zodd/frontend/`: Datalog frontend. `program.zig` is the public `Database` API; `token.zig` and `parser.zig` parse textual Datalog;
`ast.zig` and `builder.zig` hold the shared IR and the programmatic builder; `analyze.zig` checks safety and stratification;
`dyntuple.zig`, `plan.zig`, `join_runtime.zig`, and `evaluator.zig` compile and run rules on the engine core; `explain.zig` renders rule
- plans and provenance proof trees.
+ plans and provenance proof trees; `magic.zig` builds the demand-transformed (magic sets) program behind `Database.queryDemand`.
- `tests/`: Non-unit tests (`integration_tests.zig`, `regression_tests.zig`, `property_tests.zig`, `incremental_tests.zig`, `frontend_tests.zig`).
- `web/`: Web frontend. `zodd_wasm.zig` is the Wasm wrapper built by `zig build wasm`; `index.html`, `main.js`, and `style.css` are the UI;
`smoke_test.mjs` is the Node.js smoke test run by `make web-test`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 6957530..648d8f1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -31,9 +31,13 @@ This document lists the completed and planned features for Zodd.
- [ ] Streaming input
- [x] Rule DSL (textual Datalog frontend with a parser, a builder API, stratified negation, and aggregates)
- [x] Comparison operators (`<`, `<=`, `>`, `>=`, `=`, and `!=` as body filters)
+- [x] Arithmetic in comparison filters (`+`, `-`, `*`, `/`, and parentheses on either side of a comparison)
+- [x] Arithmetic assignments (`D2 is D + 1` binds a fresh variable per tuple; recursive use requires an iteration limit)
- [x] Query planner
- [x] Explain (rule plan rendering and tuple provenance proof trees)
-- [ ] Magic sets
+- [x] Magic sets (demand-driven queries via `Database.queryDemand`, with a full-evaluation fallback for negation and aggregates)
+- [x] Fact retraction (`Database.retract` removes a base fact and recomputes on the next solve or query)
+- [x] Predicate arity up to 16 columns
### Development and Testing
diff --git a/examples/README.md b/examples/README.md
index 6268eb0..a78a270 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -12,6 +12,7 @@
| 6 | [e6_dependency_resolution.zig](e6_dependency_resolution.zig) | Package dependency resolution with size aggregation and indexes. |
| 7 | [e7_datalog_frontend.zig](e7_datalog_frontend.zig) | Datalog parser, evaluator, and query frontend. |
| 8 | [e8_comparison_filters.zig](e8_comparison_filters.zig) | Comparison filters to monitor latencies against SLA limits. |
+| 9 | [e9_arithmetic_hops.zig](e9_arithmetic_hops.zig) | Arithmetic assignments counting network hops under an iteration limit. |
#### Running Examples
@@ -32,4 +33,5 @@ zig build run-e5_taint_analysis
zig build run-e6_dependency_resolution
zig build run-e7_datalog_frontend
zig build run-e8_comparison_filters
+zig build run-e9_arithmetic_hops
```
diff --git a/examples/e3_data_lineage.zig b/examples/e3_data_lineage.zig
index 75be55c..77fce79 100644
--- a/examples/e3_data_lineage.zig
+++ b/examples/e3_data_lineage.zig
@@ -12,12 +12,12 @@ const zodd = @import("zodd");
// contains_pii(D2) :- contains_pii(D1), transform(D1, D2),
// NOT anonymizes(D1, D2).
// violation(D) :- contains_pii(D), public_dataset(D).
-
+
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
-
+
std.debug.print("Zodd Datalog Engine - Data Lineage Tracking\n", .{});
std.debug.print("=================================================\n\n", .{});
diff --git a/examples/e4_rbac_authorization.zig b/examples/e4_rbac_authorization.zig
index 762386d..f728ea6 100644
--- a/examples/e4_rbac_authorization.zig
+++ b/examples/e4_rbac_authorization.zig
@@ -10,12 +10,12 @@ const zodd = @import("zodd");
// has_role(U, R2) :- has_role(U, R1), role_hier(R1, R2).
// can_access(U, P) :- has_role(U, R), role_perm(R, P).
// effective(U, P) :- can_access(U, P), NOT denied(U, P).
-
+
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
-
+
std.debug.print("Zodd Datalog Engine - RBAC Authorization\n", .{});
std.debug.print("=================================================\n\n", .{});
diff --git a/examples/e5_taint_analysis.zig b/examples/e5_taint_analysis.zig
index 756f064..ba95e94 100644
--- a/examples/e5_taint_analysis.zig
+++ b/examples/e5_taint_analysis.zig
@@ -13,12 +13,12 @@ const zodd = @import("zodd");
//
// Uses ExtendWith (leapfrog trie join) for taint propagation and
// FilterAnti for sanitizer filtering.
-
+
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
-
+
std.debug.print("Zodd Datalog Engine - Taint Analysis\n", .{});
std.debug.print("=============================================\n\n", .{});
diff --git a/examples/e6_dependency_resolution.zig b/examples/e6_dependency_resolution.zig
index e908eed..8f931da 100644
--- a/examples/e6_dependency_resolution.zig
+++ b/examples/e6_dependency_resolution.zig
@@ -14,12 +14,12 @@ const zodd = @import("zodd");
// - Variable + Relation for transitive closure
// - aggregate for computing total install size per package
// - SecondaryIndex for efficient reverse-dependency lookups
-
+
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
-
+
std.debug.print("Zodd Datalog Engine - Package Dependency Resolution\n", .{});
std.debug.print("===================================================\n\n", .{});
diff --git a/examples/e9_arithmetic_hops.zig b/examples/e9_arithmetic_hops.zig
new file mode 100644
index 0000000..c8a94aa
--- /dev/null
+++ b/examples/e9_arithmetic_hops.zig
@@ -0,0 +1,60 @@
+const std = @import("std");
+const zodd = @import("zodd");
+
+// Arithmetic Assignments
+//
+// Counts network hops from a gateway with the `is` operator, which binds a
+// fresh variable to an arithmetic expression per tuple. Recursive rules
+// that use an assignment can derive new values forever (here the topology
+// has a cycle), so the database requires `max_iterations` to be set, and a
+// comparison filter bounds the hop count itself.
+//
+// Datalog rules:
+// reach("gw", 0).
+// reach(Y, H2) :- reach(X, H), link(X, Y), H2 is H + 1, H2 < 5.
+// best(N, min(H)) :- reach(N, H).
+
+pub fn main() !void {
+ var gpa = std.heap.DebugAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ std.debug.print("Zodd Datalog Engine - Arithmetic Assignments\n", .{});
+ std.debug.print("============================================\n\n", .{});
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\% Directed links; d -> a closes a cycle.
+ \\link("gw", "a"). link("a", "b"). link("b", "c").
+ \\link("a", "c"). link("c", "d"). link("d", "a").
+ \\
+ \\% Hop counts from the gateway: H2 is H + 1 computes the next hop
+ \\% count, and H2 < 5 keeps the search within a hop budget.
+ \\reach("gw", 0).
+ \\reach(Y, H2) :- reach(X, H), link(X, Y), H2 is H + 1, H2 < 5.
+ \\
+ \\% Shortest observed hop count per node.
+ \\best(N, min(H)) :- reach(N, H).
+ );
+
+ // A recursive rule with an assignment must run under an iteration
+ // limit; without one, `solve` returns error.IterationLimitRequired.
+ db.max_iterations = 32;
+ try db.solve();
+
+ std.debug.print("Reachable within the hop budget:\n", .{});
+ var best = try db.query("best", &.{ null, null });
+ defer best.deinit();
+ while (best.next()) |row| {
+ std.debug.print(" {s}: {d} hop(s)\n", .{ row.get(0).str, row.get(1).int });
+ }
+
+ std.debug.print("\nAll hop counts observed for node c:\n", .{});
+ var c_hops = try db.query("reach", &.{ zodd.Value{ .str = "c" }, null });
+ defer c_hops.deinit();
+ while (c_hops.next()) |row| {
+ std.debug.print(" {d} hop(s)\n", .{row.get(1).int});
+ }
+}
diff --git a/src/zodd/aggregate.zig b/src/zodd/aggregate.zig
index adee657..4f41ca1 100644
--- a/src/zodd/aggregate.zig
+++ b/src/zodd/aggregate.zig
@@ -37,7 +37,14 @@ pub fn aggregate(
const sortContext = struct {
pub fn lessThan(_: void, a: Intermediate, b: Intermediate) bool {
- return std.math.order(a[0], b[0]) == .lt;
+ return switch (std.math.order(a[0], b[0])) {
+ .lt => true,
+ .gt => false,
+ // Tuples within a group keep their input order (the pointers
+ // index the sorted input), so order-sensitive folders are
+ // deterministic despite the unstable sort.
+ .eq => @intFromPtr(a[1]) < @intFromPtr(b[1]),
+ };
}
};
std.sort.pdq(Intermediate, intermediates, {}, sortContext.lessThan);
@@ -185,6 +192,40 @@ test "aggregate: max per key" {
try std.testing.expectEqual(@as(u32, 500), result.elements[1].@"1");
}
+test "aggregate: fold order within a group follows the input order" {
+ const allocator = std.testing.allocator;
+ const Tuple = struct { u32, u32 };
+
+ // Group on the second column so groups interleave in the sorted input
+ // and the group sort has real work to do; an order-sensitive folder
+ // then exposes any within-group reordering. For group key j, the first
+ // tuple in input order is (j, j).
+ var tuples: [1000]Tuple = undefined;
+ for (&tuples, 0..) |*t, i| {
+ t.* = .{ @intCast(i), @intCast(i % 10) };
+ }
+ var data = try Relation(Tuple).fromSlice(allocator, &tuples);
+ defer data.deinit();
+
+ const sentinel = std.math.maxInt(u32);
+ var result = try aggregate(Tuple, u32, u32, allocator, &data, struct {
+ fn key(t: *const Tuple) u32 {
+ return t[1];
+ }
+ }.key, sentinel, struct {
+ fn fold(acc: u32, t: *const Tuple) u32 {
+ // First value per group: order-sensitive on purpose.
+ return if (acc == sentinel) t[0] else acc;
+ }
+ }.fold);
+ defer result.deinit();
+
+ try std.testing.expectEqual(@as(usize, 10), result.len());
+ for (result.elements, 0..) |row, j| {
+ try std.testing.expectEqual(@as(u32, @intCast(j)), row.@"1");
+ }
+}
+
test "aggregate: empty input produces empty relation" {
const allocator = std.testing.allocator;
const Tuple = struct { u32, u32 };
diff --git a/src/zodd/extend.zig b/src/zodd/extend.zig
index e50f4a4..5a59a63 100644
--- a/src/zodd/extend.zig
+++ b/src/zodd/extend.zig
@@ -411,7 +411,8 @@ pub fn extendInto(
}
if (results.items.len > 0) {
- const rel = try Relation(Result).fromSlice(output.allocator, results.items);
+ var rel = try Relation(Result).fromSlice(output.allocator, results.items);
+ errdefer rel.deinit();
try output.insert(rel);
}
}
@@ -485,7 +486,11 @@ fn gallopValHelper(comptime Key: type, comptime Val: type, slice: []const struct
step = new_step;
}
- const end = @min(pos + step + 1, slice.len);
+ // Saturating arithmetic: `step` may be maxInt(usize) after the doubling
+ // loop saturated, in which case `pos + step + 1` would overflow.
+ const end_of_step = std.math.add(usize, pos, step) catch std.math.maxInt(usize);
+ const upper = std.math.add(usize, end_of_step, 1) catch std.math.maxInt(usize);
+ const end = @min(upper, slice.len);
var lo = pos + 1;
var hi = end;
@@ -648,6 +653,43 @@ test "extendInto: leapfrog join" {
try std.testing.expectEqual(output.recent.elements[1][1], 30);
}
+test "extendInto: allocation failure does not leak the result relation" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
+ fn run(allocator: Allocator) !void {
+ const Tuple = struct { u32 };
+ const Val = u32;
+
+ var source = Variable(Tuple).init(allocator);
+ defer source.deinit();
+ try source.insertSlice(&[_]Tuple{.{1}});
+ _ = try source.changed();
+
+ var base = try Relation(struct { u32, u32 }).fromSlice(allocator, &[_]struct { u32, u32 }{
+ .{ 1, 10 },
+ .{ 1, 20 },
+ });
+ defer base.deinit();
+
+ var output = Variable(struct { u32, u32 }).init(allocator);
+ defer output.deinit();
+
+ var ext = ExtendWith(Tuple, u32, Val).init(allocator, &base, struct {
+ fn f(t: *const Tuple) u32 {
+ return t[0];
+ }
+ }.f);
+ var leapers = [_]Leaper(Tuple, Val){ext.leaper()};
+
+ try extendInto(Tuple, Val, struct { u32, u32 }, &source, &leapers, &output, struct {
+ fn logic(t: *const Tuple, v: *const Val) struct { u32, u32 } {
+ return .{ t[0], v.* };
+ }
+ }.logic);
+ _ = try output.changed();
+ }
+ }.run, .{});
+}
+
test "extendInto: only anti leapers is harmless" {
const allocator = std.testing.allocator;
const Tuple = struct { u32 };
diff --git a/src/zodd/frontend/analyze.zig b/src/zodd/frontend/analyze.zig
index 8a7166e..531b1df 100644
--- a/src/zodd/frontend/analyze.zig
+++ b/src/zodd/frontend/analyze.zig
@@ -25,8 +25,13 @@ pub const AnalyzeError = error{
UnsafeHeadVariable,
UnsafeNegatedVariable,
UnsafeComparisonVariable,
+ UnsafeAssignmentVariable,
UnsafeAggregate,
+ InvalidAssignment,
NegationCycle,
+ TooManyVariables,
+ TooManyStrata,
+ IterationLimitRequired,
} || Allocator.Error;
/// Where an analysis error occurred. The message is owned by the program's
@@ -41,6 +46,10 @@ pub const Diagnostic = struct {
pub const Analysis = struct {
/// Number of strata; predicate strata are stored in `PredInfo.stratum`.
stratum_count: u16,
+ /// True when a recursive rule carries an assignment. Such programs can
+ /// derive unboundedly many values, so evaluating them requires an
+ /// explicit iteration limit.
+ recursive_assignment: bool = false,
};
/// A dependency edge from a rule head to a body predicate.
@@ -56,7 +65,7 @@ pub fn analyze(
program: *ast.Program,
diagnostic: ?*Diagnostic,
) AnalyzeError!Analysis {
- lowerWildcards(program);
+ try lowerWildcards(program);
for (program.preds.items) |*info| {
info.derived = false;
@@ -74,21 +83,22 @@ pub fn analyze(
}
/// Replaces every wildcard term with a fresh rule-scoped variable.
-fn lowerWildcards(program: *ast.Program) void {
+fn lowerWildcards(program: *ast.Program) AnalyzeError!void {
for (program.rules.items) |*rule| {
switch (rule.head) {
- .plain => |atom| lowerTerms(atom.terms, &rule.var_count),
- .aggregate => |agg| lowerTerms(agg.group_terms, &rule.var_count),
+ .plain => |atom| try lowerTerms(atom.terms, &rule.var_count),
+ .aggregate => |agg| try lowerTerms(agg.group_terms, &rule.var_count),
}
for (rule.body) |literal| {
- lowerTerms(literal.atom.terms, &rule.var_count);
+ try lowerTerms(literal.atom.terms, &rule.var_count);
}
}
}
-fn lowerTerms(terms: []ast.Term, var_count: *u16) void {
+fn lowerTerms(terms: []ast.Term, var_count: *u16) AnalyzeError!void {
for (terms) |*term| {
if (term.* == .wildcard) {
+ if (var_count.* == std.math.maxInt(u16)) return error.TooManyVariables;
term.* = ast.Term{ .variable = var_count.* };
var_count.* += 1;
}
@@ -113,6 +123,16 @@ fn checkSafety(
}
}
+ // Assignments extend the bound set in order, so later assignments,
+ // comparisons, negated literals, and the head may use their targets.
+ for (rule.assigns) |assign| {
+ try checkBoundExpr(program, rule, diagnostic, assign.expr, &bound, error.UnsafeAssignmentVariable, "assignment variable not bound by a positive body literal or an earlier assignment");
+ if (bound.isSet(assign.target)) {
+ return fail(program, diagnostic, rule, error.InvalidAssignment, "assignment target is already bound");
+ }
+ bound.set(assign.target);
+ }
+
switch (rule.head) {
.plain => |atom| {
for (atom.terms) |term| {
@@ -143,11 +163,34 @@ fn checkSafety(
}
for (rule.compares) |compare| {
- for ([_]ast.Term{ compare.lhs, compare.rhs }) |term| {
+ for ([_]ast.Expr{ compare.lhs, compare.rhs }) |expr| {
+ try checkBoundExpr(program, rule, diagnostic, expr, &bound, error.UnsafeComparisonVariable, "comparison variable not bound by a positive body literal");
+ }
+ }
+}
+
+/// Checks every variable of an expression against the bound set, failing
+/// with the given error. Recursion depth is bounded by the builder's
+/// `ast.MAX_EXPR_NODES` cap.
+fn checkBoundExpr(
+ program: *ast.Program,
+ rule: *const ast.Rule,
+ diagnostic: ?*Diagnostic,
+ expr: ast.Expr,
+ bound: *const std.DynamicBitSetUnmanaged,
+ err: AnalyzeError,
+ message: []const u8,
+) AnalyzeError!void {
+ switch (expr) {
+ .term => |term| {
if (term == .variable and !bound.isSet(term.variable)) {
- return fail(program, diagnostic, rule, error.UnsafeComparisonVariable, "comparison variable not bound by a positive body literal");
+ return fail(program, diagnostic, rule, err, message);
}
- }
+ },
+ .binop => |binop| {
+ try checkBoundExpr(program, rule, diagnostic, binop.lhs, bound, err, message);
+ try checkBoundExpr(program, rule, diagnostic, binop.rhs, bound, err, message);
+ },
}
}
@@ -241,7 +284,11 @@ fn stratify(program: *ast.Program, diagnostic: ?*Diagnostic) AnalyzeError!Analys
}
continue;
}
- const required: u16 = scc_stratum[target_scc] + @intFromBool(edge.negative);
+ // Checked in u32: a chain of negations one stratum deep per
+ // predicate can push past the u16 stratum range.
+ const required_wide = @as(u32, scc_stratum[target_scc]) + @intFromBool(edge.negative);
+ if (required_wide >= std.math.maxInt(u16)) return error.TooManyStrata;
+ const required: u16 = @intCast(required_wide);
stratum = @max(stratum, required);
}
}
@@ -253,7 +300,26 @@ fn stratify(program: *ast.Program, diagnostic: ?*Diagnostic) AnalyzeError!Analys
var max_stratum: u16 = 0;
for (scc_stratum) |s| max_stratum = @max(max_stratum, s);
- return Analysis{ .stratum_count = max_stratum + 1 };
+
+ // A recursive rule with an assignment can derive fresh values forever
+ // (its value universe is no longer bounded by the input); flag it so the
+ // caller can insist on an iteration limit.
+ var recursive_assignment = false;
+ for (program.rules.items) |rule| {
+ if (rule.assigns.len == 0) continue;
+ const head_scc = tarjan.scc_id[rule.head.pred()];
+ for (rule.body) |literal| {
+ if (tarjan.scc_id[literal.atom.pred] == head_scc) {
+ recursive_assignment = true;
+ break;
+ }
+ }
+ }
+
+ return Analysis{
+ .stratum_count = max_stratum + 1,
+ .recursive_assignment = recursive_assignment,
+ };
}
const undefined_index = std.math.maxInt(u32);
@@ -281,35 +347,60 @@ const Tarjan = struct {
return self.scc_members.items[start..end];
}
- /// Recursive step; depth is bounded by the number of predicates.
- fn strongConnect(self: *Tarjan, node: u32) Allocator.Error!void {
- self.index[node] = self.counter;
- self.lowlink[node] = self.counter;
- self.counter += 1;
- try self.stack.append(self.arena, node);
- self.on_stack.set(node);
+ /// Iterative with an explicit frame stack: recursion depth would be
+ /// bounded only by the number of predicates, which untrusted programs
+ /// control, so deep dependency chains must not consume native stack.
+ fn strongConnect(self: *Tarjan, root: u32) Allocator.Error!void {
+ const Frame = struct { node: u32, edge_index: usize };
+ var frames: std.ArrayListUnmanaged(Frame) = .empty;
+
+ try self.visit(root);
+ try frames.append(self.arena, .{ .node = root, .edge_index = 0 });
+
+ while (frames.items.len > 0) {
+ const frame = &frames.items[frames.items.len - 1];
+ const node = frame.node;
+ const edges = self.adjacency[node].items;
+
+ if (frame.edge_index < edges.len) {
+ const edge = edges[frame.edge_index];
+ frame.edge_index += 1;
+ if (self.index[edge.to] == undefined_index) {
+ try self.visit(edge.to);
+ try frames.append(self.arena, .{ .node = edge.to, .edge_index = 0 });
+ } else if (self.on_stack.isSet(edge.to)) {
+ self.lowlink[node] = @min(self.lowlink[node], self.index[edge.to]);
+ }
+ continue;
+ }
- for (self.adjacency[node].items) |edge| {
- if (self.index[edge.to] == undefined_index) {
- try self.strongConnect(edge.to);
- self.lowlink[node] = @min(self.lowlink[node], self.lowlink[edge.to]);
- } else if (self.on_stack.isSet(edge.to)) {
- self.lowlink[node] = @min(self.lowlink[node], self.index[edge.to]);
+ if (self.lowlink[node] == self.index[node]) {
+ const scc: u32 = @intCast(self.scc_offsets.items.len);
+ try self.scc_offsets.append(self.arena, @intCast(self.scc_members.items.len));
+ while (true) {
+ const member = self.stack.pop().?;
+ self.on_stack.unset(member);
+ self.scc_id[member] = scc;
+ try self.scc_members.append(self.arena, member);
+ if (member == node) break;
+ }
}
- }
- if (self.lowlink[node] == self.index[node]) {
- const scc: u32 = @intCast(self.scc_offsets.items.len);
- try self.scc_offsets.append(self.arena, @intCast(self.scc_members.items.len));
- while (true) {
- const member = self.stack.pop().?;
- self.on_stack.unset(member);
- self.scc_id[member] = scc;
- try self.scc_members.append(self.arena, member);
- if (member == node) break;
+ _ = frames.pop();
+ if (frames.items.len > 0) {
+ const parent = frames.items[frames.items.len - 1].node;
+ self.lowlink[parent] = @min(self.lowlink[parent], self.lowlink[node]);
}
}
}
+
+ fn visit(self: *Tarjan, node: u32) Allocator.Error!void {
+ self.index[node] = self.counter;
+ self.lowlink[node] = self.counter;
+ self.counter += 1;
+ try self.stack.append(self.arena, node);
+ self.on_stack.set(node);
+ }
};
test "analyze: strata for negation" {
@@ -544,3 +635,65 @@ test "analyze: wildcard lowering is idempotent" {
_ = try analyze(&program, null);
try std.testing.expectEqual(var_count_after_first, program.rules.items[0].var_count);
}
+
+test "analyze: deep positive dependency chains do not overflow the stack" {
+ const allocator = std.testing.allocator;
+ const Builder = @import("builder.zig").Builder;
+ const Interner = @import("interner.zig").Interner;
+
+ var program = ast.Program.init(allocator);
+ defer program.deinit();
+ var interner = Interner.init(allocator);
+ defer interner.deinit();
+ var builder = Builder{ .program = &program, .interner = &interner };
+
+ // p_i(X) :- p_{i-1}(X), deep enough that a recursive SCC walk would
+ // exhaust the native stack.
+ const depth = 100_000;
+ var name_buf: [32]u8 = undefined;
+ var prev = try builder.predicate("p0", 1);
+ for (1..depth) |i| {
+ const name = try std.fmt.bufPrint(&name_buf, "p{d}", .{i});
+ const pred = try builder.predicate(name, 1);
+ var r = builder.rule(pred);
+ const x = try r.v("X");
+ try r.head(&.{x});
+ try r.pos(prev, &.{x});
+ try r.finish();
+ prev = pred;
+ }
+
+ const analysis = try analyze(&program, null);
+ try std.testing.expectEqual(@as(u16, 1), analysis.stratum_count);
+}
+
+test "analyze: too many negation strata is an error, not a trap" {
+ const allocator = std.testing.allocator;
+ const Builder = @import("builder.zig").Builder;
+ const Interner = @import("interner.zig").Interner;
+
+ var program = ast.Program.init(allocator);
+ defer program.deinit();
+ var interner = Interner.init(allocator);
+ defer interner.deinit();
+ var builder = Builder{ .program = &program, .interner = &interner };
+
+ // p_i(X) :- e(X), not p_{i-1}(X): every step adds a stratum, past u16.
+ const depth = 65_600;
+ var name_buf: [32]u8 = undefined;
+ const e = try builder.predicate("e", 1);
+ var prev = try builder.predicate("p0", 1);
+ for (1..depth) |i| {
+ const name = try std.fmt.bufPrint(&name_buf, "p{d}", .{i});
+ const pred = try builder.predicate(name, 1);
+ var r = builder.rule(pred);
+ const x = try r.v("X");
+ try r.head(&.{x});
+ try r.pos(e, &.{x});
+ try r.neg(prev, &.{x});
+ try r.finish();
+ prev = pred;
+ }
+
+ try std.testing.expectError(error.TooManyStrata, analyze(&program, null));
+}
diff --git a/src/zodd/frontend/ast.zig b/src/zodd/frontend/ast.zig
index 1e44b88..e0f65ab 100644
--- a/src/zodd/frontend/ast.zig
+++ b/src/zodd/frontend/ast.zig
@@ -73,14 +73,63 @@ pub const CmpOp = enum {
}
};
-/// A body comparison, like `X < Y`. Comparisons are filters: they bind no
-/// variables, so both sides must be bound by positive body literals.
-/// Equality and inequality compare any values; ordered operators compare
-/// integers, and a string operand fails the comparison.
+/// A binary arithmetic operator inside a comparison expression.
+pub const ArithOp = enum {
+ add,
+ sub,
+ mul,
+ div,
+
+ /// Returns the source spelling of the operator.
+ pub fn symbol(self: ArithOp) []const u8 {
+ return switch (self) {
+ .add => "+",
+ .sub => "-",
+ .mul => "*",
+ .div => "/",
+ };
+ }
+};
+
+/// Upper bound on the nodes (terms and operators) of one comparison side.
+/// Keeps recursive expression walks stack-safe on untrusted input.
+pub const MAX_EXPR_NODES = 64;
+
+/// One side of a comparison: a term, or unsigned integer arithmetic over
+/// terms. Arithmetic requires integer operands; a string operand, overflow,
+/// underflow, division by zero, or a result past the 63-bit atom range
+/// fails the enclosing comparison for that tuple.
+pub const Expr = union(enum) {
+ term: Term,
+ binop: *const BinExpr,
+};
+
+pub const BinExpr = struct {
+ op: ArithOp,
+ lhs: Expr,
+ rhs: Expr,
+};
+
+/// A body assignment, like `D2 is D + 1`. Assignments bind their target:
+/// every right-hand-side variable must be bound by positive body literals
+/// (or an earlier assignment), and the target must not be bound elsewhere.
+/// An assignment whose expression produces no value derives nothing for
+/// that tuple.
+pub const Assign = struct {
+ target: VarId,
+ expr: Expr,
+ span: Span = .{},
+};
+
+/// A body comparison, like `X < Y` or `W * 2 < 100`. Comparisons are
+/// filters: they bind no variables, so every variable on either side must
+/// be bound by positive body literals. Equality and inequality compare any
+/// values; ordered operators compare integers, and a string operand fails
+/// the comparison.
pub const Compare = struct {
op: CmpOp,
- lhs: Term,
- rhs: Term,
+ lhs: Expr,
+ rhs: Expr,
span: Span = .{},
};
@@ -117,6 +166,9 @@ pub const Rule = struct {
body: []Literal,
/// Body comparisons, applied as filters after the positive literals.
compares: []Compare = &.{},
+ /// Body assignments, applied in order after the positive literals and
+ /// before the comparisons.
+ assigns: []Assign = &.{},
/// Number of distinct rule-scoped variables, including lowered wildcards.
var_count: u16,
/// Display names indexed by `VarId`. Variables lowered from wildcards
diff --git a/src/zodd/frontend/builder.zig b/src/zodd/frontend/builder.zig
index 04bf370..ca434d1 100644
--- a/src/zodd/frontend/builder.zig
+++ b/src/zodd/frontend/builder.zig
@@ -36,6 +36,9 @@ pub const BuildError = ast.ConstructError || interner_mod.EncodeError || error{
InvalidComparison,
MissingHead,
EmptyBody,
+ TooManyVariables,
+ ExpressionTooLarge,
+ InvalidAssignment,
} || Allocator.Error;
pub const Builder = struct {
@@ -105,6 +108,7 @@ pub const RuleBuilder = struct {
head_spec: ?ast.Head = null,
body: std.ArrayListUnmanaged(ast.Literal) = .empty,
compares: std.ArrayListUnmanaged(ast.Compare) = .empty,
+ assigns: std.ArrayListUnmanaged(ast.Assign) = .empty,
var_names: std.ArrayListUnmanaged([]const u8) = .empty,
span: ast.Span = .{},
@@ -120,6 +124,9 @@ pub const RuleBuilder = struct {
return ast.Term{ .variable = @intCast(id) };
}
}
+ if (self.var_names.items.len >= std.math.maxInt(ast.VarId)) {
+ return error.TooManyVariables;
+ }
const id: ast.VarId = @intCast(self.var_names.items.len);
const copy = try self.arena().dupe(u8, name);
try self.var_names.append(self.arena(), copy);
@@ -173,10 +180,58 @@ pub const RuleBuilder = struct {
/// variables or constants; comparisons bind no variables, so every
/// variable must also occur in a positive body literal.
pub fn cmp(self: *RuleBuilder, lhs: ast.Term, op: ast.CmpOp, rhs: ast.Term) BuildError!void {
- if (lhs == .wildcard or rhs == .wildcard) return error.InvalidComparison;
+ try self.cmpExpr(.{ .term = lhs }, op, .{ .term = rhs });
+ }
+
+ /// Appends a body comparison over arithmetic expressions, like
+ /// `W * 2 < 100`. The same variable-binding rules as `cmp` apply to
+ /// every term of both expressions.
+ pub fn cmpExpr(self: *RuleBuilder, lhs: ast.Expr, op: ast.CmpOp, rhs: ast.Expr) BuildError!void {
+ try self.checkExpr(lhs);
+ try self.checkExpr(rhs);
try self.compares.append(self.arena(), .{ .op = op, .lhs = lhs, .rhs = rhs });
}
+ /// Appends a body assignment, like `assign(d2, expr)`: the target
+ /// variable takes the expression's value per tuple. The target must be
+ /// a variable that is not bound anywhere else in the rule; safety
+ /// analysis enforces that and the binding of the expression's
+ /// variables.
+ pub fn assign(self: *RuleBuilder, target: ast.Term, expr: ast.Expr) BuildError!void {
+ if (target != .variable) return error.InvalidAssignment;
+ try self.checkExpr(expr);
+ try self.assigns.append(self.arena(), .{ .target = target.variable, .expr = expr });
+ }
+
+ /// Builds an arithmetic expression node in the program arena.
+ pub fn binExpr(self: *RuleBuilder, op: ast.ArithOp, lhs: ast.Expr, rhs: ast.Expr) BuildError!ast.Expr {
+ const node = try self.arena().create(ast.BinExpr);
+ node.* = .{ .op = op, .lhs = lhs, .rhs = rhs };
+ return ast.Expr{ .binop = node };
+ }
+
+ /// Rejects wildcards anywhere in the expression and expressions past
+ /// `ast.MAX_EXPR_NODES` nodes. The explicit work stack keeps the walk
+ /// safe for arbitrarily deep caller-built expressions.
+ fn checkExpr(self: *RuleBuilder, expr: ast.Expr) BuildError!void {
+ var stack: std.ArrayListUnmanaged(ast.Expr) = .empty;
+ defer stack.deinit(self.arena());
+ try stack.append(self.arena(), expr);
+
+ var nodes: usize = 0;
+ while (stack.pop()) |e| {
+ nodes += 1;
+ if (nodes > ast.MAX_EXPR_NODES) return error.ExpressionTooLarge;
+ switch (e) {
+ .term => |term| if (term == .wildcard) return error.InvalidComparison,
+ .binop => |binop| {
+ try stack.append(self.arena(), binop.lhs);
+ try stack.append(self.arena(), binop.rhs);
+ },
+ }
+ }
+ }
+
fn literal(self: *RuleBuilder, pred: ast.PredId, terms: []const ast.Term, negated: bool) BuildError!void {
const info = self.builder.program.preds.items[pred];
if (terms.len != info.arity) return error.ArityMismatch;
@@ -199,6 +254,7 @@ pub const RuleBuilder = struct {
.head = head_spec,
.body = self.body.items,
.compares = self.compares.items,
+ .assigns = self.assigns.items,
.var_count = @intCast(self.var_names.items.len),
.var_names = self.var_names.items,
.index = @intCast(program.rules.items.len),
@@ -315,8 +371,8 @@ test "Builder: comparisons" {
const compares = program.rules.items[0].compares;
try std.testing.expectEqual(@as(usize, 1), compares.len);
try std.testing.expectEqual(ast.CmpOp.ge, compares[0].op);
- try std.testing.expectEqual(age.variable, compares[0].lhs.variable);
- try std.testing.expectEqual(@as(u64, 18), compares[0].rhs.constant);
+ try std.testing.expectEqual(age.variable, compares[0].lhs.term.variable);
+ try std.testing.expectEqual(@as(u64, 18), compares[0].rhs.term.constant);
}
test "Builder: aggregate head validation" {
@@ -368,3 +424,23 @@ test "Builder: string facts go through the interner" {
const row1 = program.facts.items[1].row;
try std.testing.expectEqual(interner_mod.Value{ .int = 7 }, interner.resolve(row1[1]));
}
+
+test "RuleBuilder: too many variables is an error, not a trap" {
+ const allocator = std.testing.allocator;
+
+ var program = ast.Program.init(allocator);
+ defer program.deinit();
+ var interner = Interner.init(allocator);
+ defer interner.deinit();
+ var builder = Builder{ .program = &program, .interner = &interner };
+
+ const p = try builder.predicate("p", 1);
+ var r = builder.rule(p);
+
+ // Pre-fill to the VarId limit; the next fresh name must be refused
+ // instead of trapping on the id cast.
+ for (0..std.math.maxInt(ast.VarId) + 1) |_| {
+ try r.var_names.append(r.arena(), "x");
+ }
+ try std.testing.expectError(error.TooManyVariables, r.v("fresh"));
+}
diff --git a/src/zodd/frontend/dyntuple.zig b/src/zodd/frontend/dyntuple.zig
index 84c664d..33b4daa 100644
--- a/src/zodd/frontend/dyntuple.zig
+++ b/src/zodd/frontend/dyntuple.zig
@@ -20,7 +20,7 @@ pub const Atom = u64;
/// Maximum predicate arity supported by the frontend. Each stored tuple
/// occupies `MAX_ARITY * 8` bytes regardless of the predicate's actual arity.
-pub const MAX_ARITY = 8;
+pub const MAX_ARITY = 16;
/// The single tuple type used by all frontend relations: a tuple struct of
/// `MAX_ARITY` `u64` fields. Columns at or past a predicate's arity are
diff --git a/src/zodd/frontend/evaluator.zig b/src/zodd/frontend/evaluator.zig
index 1b3ef27..8f1d6ad 100644
--- a/src/zodd/frontend/evaluator.zig
+++ b/src/zodd/frontend/evaluator.zig
@@ -357,6 +357,16 @@ pub const Evaluator = struct {
}
current = out.items;
},
+ .assign => |assign| {
+ var out: std.ArrayListUnmanaged(DynTuple) = .empty;
+ for (current) |*tuple| {
+ const value = cmpValue(assign.expr, tuple) orelse continue;
+ var extended = tuple.*;
+ dyntuple.set(&extended, assign.dest, value);
+ try out.append(arena, extended);
+ }
+ current = out.items;
+ },
}
if (current.len == 0) return;
}
@@ -388,7 +398,7 @@ pub const Evaluator = struct {
arena,
grouped.elements,
agg.group_len,
- agg.group_len,
+ agg.val_col,
agg.func,
&folded,
);
@@ -413,8 +423,8 @@ pub const Evaluator = struct {
/// ordered operators compare integers, and a string operand fails the
/// comparison.
fn satisfies(cmp: *const plan_mod.CmpStep, tuple: *const DynTuple) bool {
- const lhs = cmpValue(cmp.lhs, tuple);
- const rhs = cmpValue(cmp.rhs, tuple);
+ const lhs = cmpValue(cmp.lhs, tuple) orelse return false;
+ const rhs = cmpValue(cmp.rhs, tuple) orelse return false;
switch (cmp.op) {
.eq => return lhs == rhs,
.ne => return lhs != rhs,
@@ -430,10 +440,27 @@ pub const Evaluator = struct {
};
}
- fn cmpValue(arg: plan_mod.CmpArg, tuple: *const DynTuple) dyntuple.Atom {
+ /// Evaluates one comparison side. Null means the value does not exist:
+ /// arithmetic on a string operand, overflow, underflow, division by
+ /// zero, or a result past the 63-bit atom range. A null side fails the
+ /// comparison for that tuple.
+ fn cmpValue(arg: plan_mod.CmpArg, tuple: *const DynTuple) ?dyntuple.Atom {
return switch (arg) {
.col => |col| dyntuple.get(tuple, col),
.constant => |constant| constant,
+ .binop => |binop| blk: {
+ const lhs = cmpValue(binop.lhs, tuple) orelse break :blk null;
+ const rhs = cmpValue(binop.rhs, tuple) orelse break :blk null;
+ if (interner_mod.isStr(lhs) or interner_mod.isStr(rhs)) break :blk null;
+ const result = switch (binop.op) {
+ .add => std.math.add(u64, lhs, rhs) catch break :blk null,
+ .sub => std.math.sub(u64, lhs, rhs) catch break :blk null,
+ .mul => std.math.mul(u64, lhs, rhs) catch break :blk null,
+ .div => if (rhs == 0) break :blk null else lhs / rhs,
+ };
+ if (result > interner_mod.PAYLOAD_MASK) break :blk null;
+ break :blk result;
+ },
};
}
diff --git a/src/zodd/frontend/explain.zig b/src/zodd/frontend/explain.zig
index 0795df3..71320e5 100644
--- a/src/zodd/frontend/explain.zig
+++ b/src/zodd/frontend/explain.zig
@@ -111,11 +111,17 @@ pub fn writeRule(
if (literal.negated) try writer.writeAll("not ");
try writeAtomTemplate(writer, program, interner, rule, literal.atom);
}
+ for (rule.assigns) |assign| {
+ try writer.writeAll(", ");
+ try writeVar(writer, rule, assign.target);
+ try writer.writeAll(" is ");
+ try writeExpr(writer, interner, rule, assign.expr);
+ }
for (rule.compares) |compare| {
try writer.writeAll(", ");
- try writeTerm(writer, interner, rule, compare.lhs);
+ try writeExpr(writer, interner, rule, compare.lhs);
try writer.print(" {s} ", .{compare.op.symbol()});
- try writeTerm(writer, interner, rule, compare.rhs);
+ try writeExpr(writer, interner, rule, compare.rhs);
}
try writer.writeAll(".");
}
@@ -188,6 +194,13 @@ pub fn writePlan(
try writeCmpArg(writer, interner, rule, plan.layout, cmp.rhs);
try writer.writeAll("\n");
},
+ .assign => |assign| {
+ try writer.writeAll(" assign ");
+ try writeVar(writer, rule, plan.layout[assign.dest]);
+ try writer.writeAll(" = ");
+ try writeCmpArg(writer, interner, rule, plan.layout, assign.expr);
+ try writer.writeAll("\n");
+ },
}
}
@@ -206,6 +219,33 @@ fn writeCmpArg(
switch (arg) {
.col => |col| try writeVar(writer, rule, layout[col]),
.constant => |value| try writeValue(writer, interner, value),
+ .binop => |binop| {
+ try writer.writeAll("(");
+ try writeCmpArg(writer, interner, rule, layout, binop.lhs);
+ try writer.print(" {s} ", .{binop.op.symbol()});
+ try writeCmpArg(writer, interner, rule, layout, binop.rhs);
+ try writer.writeAll(")");
+ },
+ }
+}
+
+/// Writes one comparison side as source-like text, parenthesizing nested
+/// arithmetic.
+fn writeExpr(
+ writer: *std.Io.Writer,
+ interner: *const Interner,
+ rule: *const ast.Rule,
+ expr: ast.Expr,
+) WriteError!void {
+ switch (expr) {
+ .term => |term| try writeTerm(writer, interner, rule, term),
+ .binop => |binop| {
+ try writer.writeAll("(");
+ try writeExpr(writer, interner, rule, binop.lhs);
+ try writer.print(" {s} ", .{binop.op.symbol()});
+ try writeExpr(writer, interner, rule, binop.rhs);
+ try writer.writeAll(")");
+ },
}
}
@@ -249,8 +289,50 @@ fn writeIndent(writer: *std.Io.Writer, depth: usize) WriteError!void {
try writer.splatByteAll(' ', depth * 2);
}
+/// Writes the evaluated value of a comparison side under a proof binding.
+/// A held comparison always evaluates, so the fallback never prints for
+/// recorded provenance.
+fn writeGroundExpr(
+ writer: *std.Io.Writer,
+ interner: *const Interner,
+ expr: ast.Expr,
+ binding: *const DynTuple,
+) WriteError!void {
+ if (groundExpr(expr, binding)) |value| {
+ try writeValue(writer, interner, value);
+ } else {
+ try writer.writeAll("?");
+ }
+}
+
+/// Evaluates a comparison side under a binding, mirroring the evaluator's
+/// arithmetic semantics. Null means the value does not exist.
+fn groundExpr(expr: ast.Expr, binding: *const DynTuple) ?dyntuple.Atom {
+ return switch (expr) {
+ .term => |term| groundTerm(term, binding),
+ .binop => |binop| blk: {
+ const lhs = groundExpr(binop.lhs, binding) orelse break :blk null;
+ const rhs = groundExpr(binop.rhs, binding) orelse break :blk null;
+ if (interner_mod.isStr(lhs) or interner_mod.isStr(rhs)) break :blk null;
+ const result = switch (binop.op) {
+ .add => std.math.add(u64, lhs, rhs) catch break :blk null,
+ .sub => std.math.sub(u64, lhs, rhs) catch break :blk null,
+ .mul => std.math.mul(u64, lhs, rhs) catch break :blk null,
+ .div => if (rhs == 0) break :blk null else lhs / rhs,
+ };
+ if (result > interner_mod.PAYLOAD_MASK) break :blk null;
+ break :blk result;
+ },
+ };
+}
+
+/// Upper bound on expanded proof levels. Proof rendering recurses once per
+/// level, so depth must stay bounded even when the caller asks for no limit.
+pub const MAX_PROOF_DEPTH = 256;
+
/// Writes the proof tree of a derived tuple from recorded provenance.
-/// `max_depth` bounds the expanded rule levels; null means no bound.
+/// `max_depth` bounds the expanded rule levels; null and values above
+/// `MAX_PROOF_DEPTH` fall back to `MAX_PROOF_DEPTH`.
pub fn writeProof(
writer: *std.Io.Writer,
program: *const ast.Program,
@@ -260,7 +342,8 @@ pub fn writeProof(
tuple: DynTuple,
max_depth: ?usize,
) WriteError!void {
- try writeProofNode(writer, program, interner, evaluator, pred, tuple, 0, max_depth);
+ const bounded = @min(max_depth orelse MAX_PROOF_DEPTH, MAX_PROOF_DEPTH);
+ try writeProofNode(writer, program, interner, evaluator, pred, tuple, 0, bounded);
}
fn writeProofNode(
@@ -271,7 +354,7 @@ fn writeProofNode(
pred: ast.PredId,
tuple: DynTuple,
depth: usize,
- max_depth: ?usize,
+ max_depth: usize,
) WriteError!void {
try writeIndent(writer, depth);
try writeTupleAtom(writer, program, interner, pred, &tuple);
@@ -288,7 +371,7 @@ fn writeProofNode(
switch (derivation) {
.fact => try writer.writeAll(" (fact)\n"),
.rule => |d| {
- if (max_depth != null and depth >= max_depth.?) {
+ if (depth >= max_depth) {
try writer.writeAll(" (depth limit)\n");
return;
}
@@ -309,11 +392,18 @@ fn writeProofNode(
try writeProofNode(writer, program, interner, evaluator, literal.atom.pred, premise, depth + 1, max_depth);
}
}
+ for (rule.assigns) |assign| {
+ try writeIndent(writer, depth + 1);
+ try writeVar(writer, rule, assign.target);
+ try writer.writeAll(" = ");
+ try writeGroundExpr(writer, interner, .{ .term = .{ .variable = assign.target } }, &d.binding);
+ try writer.writeAll(" (computed)\n");
+ }
for (rule.compares) |compare| {
try writeIndent(writer, depth + 1);
- try writeValue(writer, interner, groundTerm(compare.lhs, &d.binding));
+ try writeGroundExpr(writer, interner, compare.lhs, &d.binding);
try writer.print(" {s} ", .{compare.op.symbol()});
- try writeValue(writer, interner, groundTerm(compare.rhs, &d.binding));
+ try writeGroundExpr(writer, interner, compare.rhs, &d.binding);
try writer.writeAll(" (holds)\n");
}
},
@@ -438,3 +528,63 @@ test "writePlan: scan, join, anti, and checks" {
\\
, writer.buffered());
}
+
+test "writeRule: arithmetic comparison expressions" {
+ const allocator = std.testing.allocator;
+
+ var program = ast.Program.init(allocator);
+ defer program.deinit();
+ var interner = Interner.init(allocator);
+ defer interner.deinit();
+ var builder = Builder{ .program = &program, .interner = &interner };
+
+ const edge = try builder.predicate("edge", 3);
+ const light = try builder.predicate("light", 2);
+
+ // light(X, Y) :- edge(X, Y, W), (W * 2) < 100.
+ var r = builder.rule(light);
+ const x = try r.v("X");
+ const y = try r.v("Y");
+ const w = try r.v("W");
+ try r.head(&.{ x, y });
+ try r.pos(edge, &.{ x, y, w });
+ const product = try r.binExpr(.mul, .{ .term = w }, .{ .term = try builder.int(2) });
+ try r.cmpExpr(product, .lt, .{ .term = try builder.int(100) });
+ try r.finish();
+
+ var buffer: [128]u8 = undefined;
+ var writer = std.Io.Writer.fixed(&buffer);
+ try writeRule(&writer, &program, &interner, &program.rules.items[0]);
+ try std.testing.expectEqualStrings("light(X, Y) :- edge(X, Y, W), (W * 2) < 100.", writer.buffered());
+}
+
+test "writeRule: assignments render with is" {
+ const allocator = std.testing.allocator;
+
+ var program = ast.Program.init(allocator);
+ defer program.deinit();
+ var interner = Interner.init(allocator);
+ defer interner.deinit();
+ var builder = Builder{ .program = &program, .interner = &interner };
+
+ const edge = try builder.predicate("edge", 2);
+ const dist = try builder.predicate("dist", 2);
+
+ // dist(Y, D2) :- dist(X, D), edge(X, Y), D2 is (D + 1).
+ var r = builder.rule(dist);
+ const y = try r.v("Y");
+ const d2 = try r.v("D2");
+ const x = try r.v("X");
+ const d = try r.v("D");
+ try r.head(&.{ y, d2 });
+ try r.pos(dist, &.{ x, d });
+ try r.pos(edge, &.{ x, y });
+ const succ = try r.binExpr(.add, .{ .term = d }, .{ .term = try builder.int(1) });
+ try r.assign(d2, succ);
+ try r.finish();
+
+ var buffer: [128]u8 = undefined;
+ var writer = std.Io.Writer.fixed(&buffer);
+ try writeRule(&writer, &program, &interner, &program.rules.items[0]);
+ try std.testing.expectEqualStrings("dist(Y, D2) :- dist(X, D), edge(X, Y), D2 is (D + 1).", writer.buffered());
+}
diff --git a/src/zodd/frontend/magic.zig b/src/zodd/frontend/magic.zig
new file mode 100644
index 0000000..13f9185
--- /dev/null
+++ b/src/zodd/frontend/magic.zig
@@ -0,0 +1,364 @@
+//! # Magic Sets
+//!
+//! The module builds a demand-transformed program for a single query with
+//! bound arguments: adorned copies of the relevant derived predicates are
+//! guarded by magic predicates seeded from the query's bindings, so
+//! evaluation computes only tuples the query can actually reach.
+//!
+//! The transformation covers positive rules (including comparisons and
+//! assignments). It reports `error.DemandUnsupported` when it cannot apply:
+//! no bound argument, a non-derived query predicate, or negation or
+//! aggregation in the relevant rule cone, where a naive rewrite would be
+//! unsound. Callers fall back to full evaluation.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ast = @import("ast.zig");
+const builder_mod = @import("builder.zig");
+const interner_mod = @import("interner.zig");
+const Interner = interner_mod.Interner;
+
+pub const DemandError = builder_mod.BuildError || error{DemandUnsupported};
+
+/// A demand-transformed program. The program shares the source interner;
+/// `query_pred` is the adorned predicate holding the query's answers.
+pub const Demand = struct {
+ program: ast.Program,
+ query_pred: ast.PredId,
+};
+
+/// Bound-argument mask of a query pattern or literal; bit `i` set means
+/// argument `i` is bound.
+const Mask = u32;
+
+fn boundCount(mask: Mask) u16 {
+ return @popCount(mask);
+}
+
+/// Builds the demand transformation of `?- pred(pattern)` over an analyzed
+/// `source` program (wildcards lowered, safety checked). On success the
+/// caller owns the returned program.
+pub fn transform(
+ allocator: Allocator,
+ source: *const ast.Program,
+ interner: *Interner,
+ pred: ast.PredId,
+ pattern: []const ?u64,
+) DemandError!Demand {
+ if (!source.preds.items[pred].derived) return error.DemandUnsupported;
+
+ var query_mask: Mask = 0;
+ for (pattern, 0..) |slot, i| {
+ if (slot != null) query_mask |= @as(Mask, 1) << @intCast(i);
+ }
+ if (query_mask == 0) return error.DemandUnsupported;
+
+ try checkCone(allocator, source, pred);
+
+ var state = State{
+ .source = source,
+ .interner = interner,
+ .program = ast.Program.init(allocator),
+ .adorned = .empty,
+ .magic = .empty,
+ .edb = .empty,
+ .worklist = .empty,
+ .allocator = allocator,
+ };
+ errdefer state.deinit();
+ defer {
+ state.adorned.deinit(allocator);
+ state.magic.deinit(allocator);
+ state.edb.deinit(allocator);
+ state.worklist.deinit(allocator);
+ }
+ var b = builder_mod.Builder{ .program = &state.program, .interner = interner };
+ state.builder = &b;
+
+ // Seed the query's magic predicate with the bound pattern values.
+ const query_pred = try state.adornedPred(pred, query_mask);
+ var seed_buf: std.ArrayListUnmanaged(u64) = .empty;
+ defer seed_buf.deinit(allocator);
+ for (pattern) |slot| {
+ if (slot) |value| try seed_buf.append(allocator, value);
+ }
+ try b.fact((try state.magicPred(pred, query_mask)).?, seed_buf.items);
+
+ while (state.worklist.pop()) |item| {
+ try state.emitAdorned(item.pred, item.mask);
+ }
+
+ return Demand{ .program = state.program, .query_pred = query_pred };
+}
+
+/// Rejects programs whose relevant rule cone the rewrite cannot express
+/// soundly: negated literals or aggregate heads.
+fn checkCone(allocator: Allocator, source: *const ast.Program, pred: ast.PredId) DemandError!void {
+ var in_cone = try std.DynamicBitSetUnmanaged.initEmpty(allocator, source.preds.items.len);
+ defer in_cone.deinit(allocator);
+ in_cone.set(pred);
+
+ var changed = true;
+ while (changed) {
+ changed = false;
+ for (source.rules.items) |rule| {
+ if (!in_cone.isSet(rule.head.pred())) continue;
+ for (rule.body) |literal| {
+ if (!in_cone.isSet(literal.atom.pred)) {
+ in_cone.set(literal.atom.pred);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ for (source.rules.items) |rule| {
+ if (!in_cone.isSet(rule.head.pred())) continue;
+ if (rule.head == .aggregate) return error.DemandUnsupported;
+ for (rule.body) |literal| {
+ if (literal.negated) return error.DemandUnsupported;
+ }
+ }
+}
+
+const WorkItem = struct { pred: ast.PredId, mask: Mask };
+
+const State = struct {
+ source: *const ast.Program,
+ interner: *Interner,
+ program: ast.Program,
+ builder: *builder_mod.Builder = undefined,
+ /// (source pred, mask) to adorned predicate in the new program.
+ adorned: std.AutoHashMapUnmanaged(u64, ast.PredId),
+ /// (source pred, mask) to magic predicate in the new program.
+ magic: std.AutoHashMapUnmanaged(u64, ast.PredId),
+ /// Source EDB pred to its copy in the new program.
+ edb: std.AutoHashMapUnmanaged(ast.PredId, ast.PredId),
+ worklist: std.ArrayListUnmanaged(WorkItem),
+ allocator: Allocator,
+
+ fn deinit(self: *State) void {
+ self.program.deinit();
+ }
+
+ fn key(pred: ast.PredId, mask: Mask) u64 {
+ return (@as(u64, pred) << 32) | mask;
+ }
+
+ fn sourceName(self: *State, pred: ast.PredId) []const u8 {
+ return self.interner.resolve(self.source.preds.items[pred].name_atom).str;
+ }
+
+ /// Formats the b/f adornment string of `mask` over `arity` arguments.
+ fn adornment(mask: Mask, arity: u16, buf: []u8) []const u8 {
+ for (0..arity) |i| {
+ buf[i] = if (mask & (@as(Mask, 1) << @intCast(i)) != 0) 'b' else 'f';
+ }
+ return buf[0..arity];
+ }
+
+ /// The adorned copy of a derived predicate, registering it on the
+ /// worklist on first use. `$` keeps generated names unparseable.
+ fn adornedPred(self: *State, pred: ast.PredId, mask: Mask) DemandError!ast.PredId {
+ if (self.adorned.get(key(pred, mask))) |id| return id;
+ const info = self.source.preds.items[pred];
+ var adorn_buf: [dyntuple_max]u8 = undefined;
+ const name = try std.fmt.allocPrint(self.program.allocator(), "{s}$adorned${s}", .{
+ self.sourceName(pred),
+ adornment(mask, info.arity, &adorn_buf),
+ });
+ const id = try self.builder.predicate(name, info.arity);
+ try self.adorned.put(self.allocator, key(pred, mask), id);
+ try self.worklist.append(self.allocator, .{ .pred = pred, .mask = mask });
+
+ // Base facts of a mixed predicate hold regardless of demand.
+ for (self.source.facts.items) |fact| {
+ if (fact.pred == pred) try self.builder.fact(id, fact.row);
+ }
+ return id;
+ }
+
+ /// The magic predicate of an adorned derived predicate, or null for the
+ /// all-free adornment (whose demand is everything).
+ fn magicPred(self: *State, pred: ast.PredId, mask: Mask) DemandError!?ast.PredId {
+ if (mask == 0) return null;
+ if (self.magic.get(key(pred, mask))) |id| return id;
+ const info = self.source.preds.items[pred];
+ var adorn_buf: [dyntuple_max]u8 = undefined;
+ const name = try std.fmt.allocPrint(self.program.allocator(), "{s}$magic${s}", .{
+ self.sourceName(pred),
+ adornment(mask, info.arity, &adorn_buf),
+ });
+ const id = try self.builder.predicate(name, boundCount(mask));
+ try self.magic.put(self.allocator, key(pred, mask), id);
+ return id;
+ }
+
+ /// The copy of a non-derived predicate, with its facts.
+ fn edbPred(self: *State, pred: ast.PredId) DemandError!ast.PredId {
+ if (self.edb.get(pred)) |id| return id;
+ const info = self.source.preds.items[pred];
+ const id = try self.builder.predicate(self.sourceName(pred), info.arity);
+ try self.edb.put(self.allocator, pred, id);
+ for (self.source.facts.items) |fact| {
+ if (fact.pred == pred) try self.builder.fact(id, fact.row);
+ }
+ return id;
+ }
+
+ /// Emits the rewritten rules of one adorned predicate: each source rule
+ /// guarded by the magic predicate, plus a magic rule per derived body
+ /// literal propagating demand sideways.
+ fn emitAdorned(self: *State, pred: ast.PredId, mask: Mask) DemandError!void {
+ const adorned_pred = self.adorned.get(key(pred, mask)).?;
+ for (self.source.rules.items) |*rule| {
+ if (rule.head.pred() != pred) continue;
+ const head_terms = rule.head.plain.terms;
+
+ // First pass: the adornment of every body literal, from the
+ // bound head positions and each earlier positive literal.
+ var bound = try std.DynamicBitSetUnmanaged.initEmpty(self.allocator, rule.var_count);
+ defer bound.deinit(self.allocator);
+ for (head_terms, 0..) |term, i| {
+ if (term == .variable and mask & (@as(Mask, 1) << @intCast(i)) != 0) {
+ bound.set(term.variable);
+ }
+ }
+ var lit_masks: std.ArrayListUnmanaged(Mask) = .empty;
+ defer lit_masks.deinit(self.allocator);
+ for (rule.body) |literal| {
+ var lit_mask: Mask = 0;
+ for (literal.atom.terms, 0..) |term, i| {
+ const is_bound = switch (term) {
+ .constant => true,
+ .variable => |var_id| bound.isSet(var_id),
+ .wildcard => unreachable, // Lowered by analysis.
+ };
+ if (is_bound) lit_mask |= @as(Mask, 1) << @intCast(i);
+ }
+ try lit_masks.append(self.allocator, lit_mask);
+ for (literal.atom.terms) |term| {
+ if (term == .variable) bound.set(term.variable);
+ }
+ }
+
+ // Second pass: the rewritten rule, and one magic rule per
+ // derived body literal.
+ var r = self.builder.rule(adorned_pred);
+ try r.head(try self.mapTerms(&r, head_terms));
+ if (try self.magicPred(pred, mask)) |magic_pred| {
+ try r.pos(magic_pred, try self.boundTerms(&r, head_terms, mask));
+ }
+ for (rule.body, lit_masks.items, 0..) |literal, lit_mask, lit_index| {
+ if (self.source.preds.items[literal.atom.pred].derived) {
+ const adorned_lit = try self.adornedPred(literal.atom.pred, lit_mask);
+ if (try self.magicPred(literal.atom.pred, lit_mask)) |magic_lit| {
+ try self.emitMagicRule(rule, mask, lit_masks.items, lit_index, magic_lit);
+ }
+ try r.pos(adorned_lit, try self.mapTerms(&r, literal.atom.terms));
+ } else {
+ try r.pos(try self.edbPred(literal.atom.pred), try self.mapTerms(&r, literal.atom.terms));
+ }
+ }
+ for (rule.assigns) |assign| {
+ try r.assign(try self.mapVar(&r, assign.target), try self.mapExpr(&r, assign.expr));
+ }
+ for (rule.compares) |compare| {
+ try r.cmpExpr(try self.mapExpr(&r, compare.lhs), compare.op, try self.mapExpr(&r, compare.rhs));
+ }
+ try r.finish();
+ }
+ }
+
+ /// Emits the magic rule demanding body literal `lit_index` of `rule`:
+ /// its bound arguments, given the head's demand and every earlier
+ /// positive literal. With no body items at all, the demand is a ground
+ /// fact.
+ fn emitMagicRule(
+ self: *State,
+ rule: *const ast.Rule,
+ head_mask: Mask,
+ lit_masks: []const Mask,
+ lit_index: usize,
+ magic_lit: ast.PredId,
+ ) DemandError!void {
+ const literal = rule.body[lit_index];
+ const lit_mask = lit_masks[lit_index];
+ const head_terms = rule.head.plain.terms;
+ const head_magic = try self.magicPred(rule.head.pred(), head_mask);
+
+ if (head_magic == null and lit_index == 0) {
+ // No guard and no earlier literals: the demanded binding is
+ // ground (constants only, or lit_mask would be empty).
+ var row: std.ArrayListUnmanaged(u64) = .empty;
+ defer row.deinit(self.allocator);
+ for (literal.atom.terms, 0..) |term, i| {
+ if (lit_mask & (@as(Mask, 1) << @intCast(i)) == 0) continue;
+ try row.append(self.allocator, term.constant);
+ }
+ try self.builder.fact(magic_lit, row.items);
+ return;
+ }
+
+ var r = self.builder.rule(magic_lit);
+ try r.head(try self.boundTerms(&r, literal.atom.terms, lit_mask));
+ if (head_magic) |magic_pred| {
+ try r.pos(magic_pred, try self.boundTerms(&r, head_terms, head_mask));
+ }
+ for (rule.body[0..lit_index], lit_masks[0..lit_index]) |earlier, earlier_mask| {
+ const earlier_pred = if (self.source.preds.items[earlier.atom.pred].derived)
+ try self.adornedPred(earlier.atom.pred, earlier_mask)
+ else
+ try self.edbPred(earlier.atom.pred);
+ try r.pos(earlier_pred, try self.mapTerms(&r, earlier.atom.terms));
+ }
+ try r.finish();
+ }
+
+ /// Maps one source variable to the rule builder's variable id space by
+ /// stable per-id names.
+ fn mapVar(self: *State, r: *builder_mod.RuleBuilder, var_id: ast.VarId) DemandError!ast.Term {
+ _ = self;
+ var name_buf: [8]u8 = undefined;
+ const name = std.fmt.bufPrint(&name_buf, "v{d}", .{var_id}) catch unreachable;
+ return r.v(name);
+ }
+
+ fn mapTerm(self: *State, r: *builder_mod.RuleBuilder, term: ast.Term) DemandError!ast.Term {
+ return switch (term) {
+ .variable => |var_id| try self.mapVar(r, var_id),
+ .constant => term,
+ .wildcard => unreachable, // Lowered by analysis.
+ };
+ }
+
+ fn mapTerms(self: *State, r: *builder_mod.RuleBuilder, terms: []const ast.Term) DemandError![]const ast.Term {
+ const copy = try self.program.allocator().alloc(ast.Term, terms.len);
+ for (terms, 0..) |term, i| copy[i] = try self.mapTerm(r, term);
+ return copy;
+ }
+
+ /// The terms in bound positions of `mask`, mapped into `r`.
+ fn boundTerms(self: *State, r: *builder_mod.RuleBuilder, terms: []const ast.Term, mask: Mask) DemandError![]const ast.Term {
+ var list: std.ArrayListUnmanaged(ast.Term) = .empty;
+ defer list.deinit(self.allocator);
+ for (terms, 0..) |term, i| {
+ if (mask & (@as(Mask, 1) << @intCast(i)) == 0) continue;
+ try list.append(self.allocator, try self.mapTerm(r, term));
+ }
+ return try self.program.allocator().dupe(ast.Term, list.items);
+ }
+
+ fn mapExpr(self: *State, r: *builder_mod.RuleBuilder, expr: ast.Expr) DemandError!ast.Expr {
+ return switch (expr) {
+ .term => |term| ast.Expr{ .term = try self.mapTerm(r, term) },
+ .binop => |binop| try r.binExpr(
+ binop.op,
+ try self.mapExpr(r, binop.lhs),
+ try self.mapExpr(r, binop.rhs),
+ ),
+ };
+ }
+};
+
+const dyntuple_max = @import("dyntuple.zig").MAX_ARITY;
diff --git a/src/zodd/frontend/parser.zig b/src/zodd/frontend/parser.zig
index 0c9d28f..e706867 100644
--- a/src/zodd/frontend/parser.zig
+++ b/src/zodd/frontend/parser.zig
@@ -14,24 +14,33 @@
//! query = "?-" atom "."
//! head = atom with at most one aggregate argument `func(Var)`
//! body = body_item { "," body_item }
-//! body_item = literal | comparison
+//! body_item = literal | comparison | assignment
//! literal = [ "not" ] atom
-//! comparison = term cmp_op term
+//! comparison = expr cmp_op expr
+//! assignment = Variable "is" expr
//! cmp_op = "<" | "<=" | ">" | ">=" | "=" | "!="
+//! expr = mul_expr { ("+" | "-") mul_expr }
+//! mul_expr = primary { ("*" | "/") primary }
+//! primary = term | "(" expr ")"
//! atom = pred_name "(" [ term { "," term } ] ")"
//! term = Variable | "_" | integer | string
//! ```
//!
//! Constants are integers or quoted strings; bare lowercase identifiers are
-//! not constants. `not`, `count`, `sum`, `min`, and `max` are reserved.
-//! Comparisons are filters: every comparison variable must also occur in a
-//! positive body literal, and wildcards are not allowed. Ordered operators
-//! compare integers; a string operand fails the comparison.
+//! not constants. `not`, `count`, `sum`, `min`, `max`, and `is` are
+//! reserved. Comparisons are filters: every comparison variable must also
+//! occur in a positive body literal, and wildcards are not allowed. Ordered
+//! operators compare integers; a string operand fails the comparison.
+//! Assignments bind their target variable to the expression's value per
+//! tuple. Arithmetic is unsigned: a string operand, overflow, underflow, or
+//! division by zero fails the comparison, or derives nothing for an
+//! assignment, for that tuple.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const builder_mod = @import("builder.zig");
+const dyntuple = @import("dyntuple.zig");
const interner_mod = @import("interner.zig");
const token_mod = @import("token.zig");
const Diagnostic = @import("analyze.zig").Diagnostic;
@@ -49,7 +58,7 @@ pub const ParseError = token_mod.LexError || builder_mod.BuildError || error{
MultipleAggregates,
};
-const reserved_words = [_][]const u8{ "not", "count", "sum", "min", "max" };
+const reserved_words = [_][]const u8{ "not", "count", "sum", "min", "max", "is" };
/// Parses Datalog source, appending facts, rules, and queries to `program`.
/// On error, fills `diagnostic` (when provided) with a message and source
@@ -164,6 +173,11 @@ const Parser = struct {
while (true) {
const arg = try self.parseArg(allow_aggregate, @intCast(args.items.len));
try args.append(self.arena(), arg);
+ // Reject over-long argument lists here, before the slot counter
+ // can grow past its u16 range on adversarial input.
+ if (args.items.len > dyntuple.MAX_ARITY) {
+ return self.fail(error.ArityTooLarge, self.current.span, "too many arguments");
+ }
if (self.current.kind == .comma) {
try self.advance();
continue;
@@ -220,6 +234,7 @@ const Parser = struct {
}
return self.fail(error.UnexpectedToken, token.span, "bare identifiers are not constants; use an integer or a quoted string");
},
+ .minus => return self.fail(error.NegativeInteger, token.span, "negative integers are not supported"),
else => return self.fail(error.UnexpectedToken, token.span, "expected a term"),
}
}
@@ -285,7 +300,7 @@ const Parser = struct {
// Body items: a term opens a comparison, anything else a literal.
while (true) {
switch (self.current.kind) {
- .ident_upper, .wildcard, .integer, .string => try self.comparison(&r),
+ .ident_upper, .wildcard, .integer, .string, .lparen => try self.comparison(&r),
else => try self.bodyLiteral(&r),
}
@@ -326,7 +341,24 @@ const Parser = struct {
fn comparison(self: *Parser, r: *builder_mod.RuleBuilder) ParseError!void {
const lhs_span = self.current.span;
- const lhs = try self.toTerm(r, try self.parseArg(false, 0));
+ const lhs = try self.parseCmpExpr(r, 0);
+
+ // `Var is expr` is an assignment, not a comparison.
+ if (self.current.kind == .ident_lower and std.mem.eql(u8, self.lexer.text(self.current), "is")) {
+ try self.advance();
+ const rhs = try self.parseCmpExpr(r, 0);
+ const target: ast.Term = switch (lhs) {
+ .term => |term| term,
+ .binop => return self.fail(error.InvalidAssignment, lhs_span, "assignment target must be a variable"),
+ };
+ r.assign(target, rhs) catch |err| switch (err) {
+ error.InvalidAssignment => return self.fail(err, lhs_span, "assignment target must be a variable"),
+ error.InvalidComparison => return self.fail(err, lhs_span, "wildcards are not allowed in assignments"),
+ error.ExpressionTooLarge => return self.fail(err, lhs_span, "assignment expression has too many terms"),
+ else => return err,
+ };
+ return;
+ }
const op: ast.CmpOp = switch (self.current.kind) {
.less_than => .lt,
@@ -340,18 +372,74 @@ const Parser = struct {
try self.advance();
const rhs_span = self.current.span;
- const rhs = try self.toTerm(r, try self.parseArg(false, 0));
+ const rhs = try self.parseCmpExpr(r, 0);
- r.cmp(lhs, op, rhs) catch |err| switch (err) {
+ r.cmpExpr(lhs, op, rhs) catch |err| switch (err) {
error.InvalidComparison => return self.fail(
err,
.{ .start = lhs_span.start, .end = rhs_span.end },
"wildcards are not allowed in comparisons",
),
+ error.ExpressionTooLarge => return self.fail(
+ err,
+ .{ .start = lhs_span.start, .end = rhs_span.end },
+ "comparison expression has too many terms",
+ ),
else => return err,
};
}
+ /// Maximum parenthesis nesting inside a comparison expression, so
+ /// hostile input cannot exhaust the parser's native stack.
+ const MAX_EXPR_PAREN_DEPTH = 32;
+
+ /// Parses `mul_expr { ("+" | "-") mul_expr }`.
+ fn parseCmpExpr(self: *Parser, r: *builder_mod.RuleBuilder, depth: u32) ParseError!ast.Expr {
+ var lhs = try self.parseMulExpr(r, depth);
+ while (true) {
+ const op: ast.ArithOp = switch (self.current.kind) {
+ .plus => .add,
+ .minus => .sub,
+ else => return lhs,
+ };
+ try self.advance();
+ const rhs = try self.parseMulExpr(r, depth);
+ lhs = try r.binExpr(op, lhs, rhs);
+ }
+ }
+
+ /// Parses `primary_expr { ("*" | "/") primary_expr }`.
+ fn parseMulExpr(self: *Parser, r: *builder_mod.RuleBuilder, depth: u32) ParseError!ast.Expr {
+ var lhs = try self.parsePrimaryExpr(r, depth);
+ while (true) {
+ const op: ast.ArithOp = switch (self.current.kind) {
+ .star => .mul,
+ .slash => .div,
+ else => return lhs,
+ };
+ try self.advance();
+ const rhs = try self.parsePrimaryExpr(r, depth);
+ lhs = try r.binExpr(op, lhs, rhs);
+ }
+ }
+
+ /// Parses a term or a parenthesized expression.
+ fn parsePrimaryExpr(self: *Parser, r: *builder_mod.RuleBuilder, depth: u32) ParseError!ast.Expr {
+ if (self.current.kind == .lparen) {
+ if (depth >= MAX_EXPR_PAREN_DEPTH) {
+ return self.fail(error.ExpressionTooLarge, self.current.span, "expression is nested too deeply");
+ }
+ try self.advance();
+ const inner = try self.parseCmpExpr(r, depth + 1);
+ _ = try self.expect(.rparen, "expected ')'");
+ return inner;
+ }
+ if (self.current.kind == .minus) {
+ return self.fail(error.NegativeInteger, self.current.span, "negative integers are not supported");
+ }
+ return ast.Expr{ .term = try self.toTerm(r, try self.parseArg(false, 0)) };
+ }
+
fn query(self: *Parser) ParseError!void {
const name_token = try self.predName();
const args = try self.parseArgs(false);
@@ -468,7 +556,7 @@ test "parse: comparisons" {
try std.testing.expectEqual(@as(usize, 1), rules[0].compares.len);
try std.testing.expectEqual(ast.CmpOp.ge, rules[0].compares[0].op);
- try std.testing.expectEqual(@as(u64, 18), rules[0].compares[0].rhs.constant);
+ try std.testing.expectEqual(@as(u64, 18), rules[0].compares[0].rhs.term.constant);
try std.testing.expectEqual(@as(usize, 2), rules[1].compares.len);
try std.testing.expectEqual(ast.CmpOp.ne, rules[1].compares[0].op);
@@ -476,7 +564,7 @@ test "parse: comparisons" {
// Comparison variables share ids with the literal occurrences.
try std.testing.expectEqual(
rules[1].body[0].atom.terms[0].variable,
- rules[1].compares[0].lhs.variable,
+ rules[1].compares[0].lhs.term.variable,
);
}
diff --git a/src/zodd/frontend/plan.zig b/src/zodd/frontend/plan.zig
index 29d0e18..94e2aad 100644
--- a/src/zodd/frontend/plan.zig
+++ b/src/zodd/frontend/plan.zig
@@ -66,11 +66,19 @@ pub const AntiStep = struct {
i_key_cols: []u8,
};
-/// One side of a comparison filter: a column of the current layout or a
-/// constant.
+/// One side of a comparison filter: a column of the current layout, a
+/// constant, or arithmetic over both. Recursion depth is bounded by the
+/// builder's `ast.MAX_EXPR_NODES` cap.
pub const CmpArg = union(enum) {
col: u8,
constant: dyntuple.Atom,
+ binop: *const CmpBin,
+};
+
+pub const CmpBin = struct {
+ op: ast.ArithOp,
+ lhs: CmpArg,
+ rhs: CmpArg,
};
/// Filters the intermediate with a comparison. Equality and inequality
@@ -82,11 +90,19 @@ pub const CmpStep = struct {
rhs: CmpArg,
};
+/// Computes an expression per tuple into a fresh layout column. A tuple
+/// whose expression produces no value is dropped.
+pub const AssignStep = struct {
+ dest: u8,
+ expr: CmpArg,
+};
+
pub const Step = union(enum) {
scan: AtomLoad,
join: JoinStep,
anti: AntiStep,
cmp: CmpStep,
+ assign: AssignStep,
};
/// One head column: a column of the final intermediate (or of the aggregate
@@ -97,12 +113,17 @@ pub const HeadCol = union(enum) {
};
/// Aggregation over the final intermediate: project `proj` (group variables,
-/// then the aggregated column, then all remaining columns for set-semantics
-/// folding), group by the first `group_len` columns, then assemble the head
-/// from the `[group..., result]` tuples.
+/// then the aggregated column unless it is itself a group variable, then all
+/// remaining columns for set-semantics folding), group by the first
+/// `group_len` columns, fold `val_col`, then assemble the head from the
+/// `[group..., result]` tuples.
pub const AggPlan = struct {
proj: []u8,
group_len: u8,
+ /// Projected column holding the aggregated value; `group_len` unless the
+ /// aggregated variable is a group variable, in which case it points into
+ /// the group prefix.
+ val_col: u8,
func: ast.AggFunc,
head_cols: []HeadCol,
};
@@ -114,7 +135,8 @@ pub const HeadKind = union(enum) {
/// A compiled rule.
pub const Plan = struct {
- /// Scan and join steps in body order, then cmp steps, then anti steps.
+ /// Scan and join steps in body order, then assign steps, then cmp
+ /// steps, then anti steps.
steps: []Step,
head: HeadKind,
/// Final intermediate width (number of layout columns).
@@ -149,14 +171,27 @@ pub fn compile(arena: Allocator, rule: *const ast.Rule) PlanError!Plan {
}
}
- // Comparison filters after all positives; safety guarantees their
- // variables are bound by then. None of the remaining steps change the
- // layout, so the columns stay valid.
+ // Assignments after all positives, in order: each appends its target as
+ // a fresh layout column, so later assignments, comparisons, negations,
+ // and the head can reference it. `var_count` counts assigned variables,
+ // so the MAX_ARITY check above already bounds the widened layout.
+ for (rule.assigns) |assign| {
+ const dest: u8 = @intCast(layout.items.len);
+ try layout.append(arena, assign.target);
+ try steps.append(arena, .{ .assign = .{
+ .dest = dest,
+ .expr = try cmpArg(arena, assign.expr, layout.items),
+ } });
+ }
+
+ // Comparison filters after the positives and assignments; safety
+ // guarantees their variables are bound by then. None of the remaining
+ // steps change the layout, so the columns stay valid.
for (rule.compares) |compare| {
try steps.append(arena, .{ .cmp = .{
.op = compare.op,
- .lhs = cmpArg(compare.lhs, layout.items),
- .rhs = cmpArg(compare.rhs, layout.items),
+ .lhs = try cmpArg(arena, compare.lhs, layout.items),
+ .rhs = try cmpArg(arena, compare.rhs, layout.items),
} });
}
@@ -241,11 +276,22 @@ fn buildLoad(
};
}
-fn cmpArg(term: ast.Term, layout: []const ast.VarId) CmpArg {
- return switch (term) {
- .variable => |var_id| CmpArg{ .col = colOf(layout, var_id) },
- .constant => |value| CmpArg{ .constant = value },
- .wildcard => unreachable, // Rejected by the builder.
+fn cmpArg(arena: Allocator, expr: ast.Expr, layout: []const ast.VarId) PlanError!CmpArg {
+ return switch (expr) {
+ .term => |term| switch (term) {
+ .variable => |var_id| CmpArg{ .col = colOf(layout, var_id) },
+ .constant => |value| CmpArg{ .constant = value },
+ .wildcard => unreachable, // Rejected by the builder.
+ },
+ .binop => |binop| blk: {
+ const node = try arena.create(CmpBin);
+ node.* = .{
+ .op = binop.op,
+ .lhs = try cmpArg(arena, binop.lhs, layout),
+ .rhs = try cmpArg(arena, binop.rhs, layout),
+ };
+ break :blk CmpArg{ .binop = node };
+ },
};
}
@@ -332,11 +378,19 @@ fn buildHead(
// Projection: group variables, the aggregated column, then every
// remaining layout column so folding sees distinct full bindings.
+ // When the aggregated variable is itself a group variable, its
+ // value is already in the group prefix; appending it again would
+ // grow the projection past MAX_ARITY.
var proj: std.ArrayListUnmanaged(u8) = .empty;
for (group_vars.items) |var_id| {
try proj.append(arena, colOf(layout, var_id));
}
- try proj.append(arena, colOf(layout, arg_var));
+ const val_col: u8 = if (indexOfVar(group_vars.items, arg_var)) |group_col|
+ @intCast(group_col)
+ else blk: {
+ try proj.append(arena, colOf(layout, arg_var));
+ break :blk group_len;
+ };
for (layout, 0..) |var_id, col| {
if (var_id != arg_var and indexOfVar(group_vars.items, var_id) == null) {
try proj.append(arena, @intCast(col));
@@ -366,6 +420,7 @@ fn buildHead(
return HeadKind{ .aggregate = .{
.proj = proj.items,
.group_len = group_len,
+ .val_col = val_col,
.func = agg.func,
.head_cols = head_cols,
} };
diff --git a/src/zodd/frontend/program.zig b/src/zodd/frontend/program.zig
index d053609..6c91ef8 100644
--- a/src/zodd/frontend/program.zig
+++ b/src/zodd/frontend/program.zig
@@ -32,6 +32,7 @@ const builder_mod = @import("builder.zig");
const dyntuple = @import("dyntuple.zig");
const evaluator_mod = @import("evaluator.zig");
const explain_mod = @import("explain.zig");
+const magic = @import("magic.zig");
const interner_mod = @import("interner.zig");
const parser_mod = @import("parser.zig");
const plan_mod = @import("plan.zig");
@@ -65,6 +66,10 @@ pub const Database = struct {
program: ast.Program,
interner: interner_mod.Interner,
evaluator: ?evaluator_mod.Evaluator = null,
+ /// State of the last `queryDemand`: the rewritten program and its
+ /// evaluator, torn down on the next demand query, solve, or deinit.
+ demand_program: ?ast.Program = null,
+ demand_evaluator: ?evaluator_mod.Evaluator = null,
diagnostic: Diagnostic = .{},
/// Bounds the fixed-point rounds within each stratum;
/// `error.MaxIterationsExceeded` when exceeded. Null means no limit.
@@ -82,6 +87,7 @@ pub const Database = struct {
}
pub fn deinit(self: *Database) void {
+ self.clearDemand();
if (self.evaluator) |*evaluator| evaluator.deinit();
self.program.deinit();
self.interner.deinit();
@@ -101,6 +107,38 @@ pub const Database = struct {
try b.factValues(pred, row);
}
+ /// Removes one occurrence of a previously added ground fact and
+ /// invalidates computed results; the next solve or query recomputes
+ /// from scratch. Returns true if a matching fact was removed. Only
+ /// base facts can be retracted; derived tuples disappear when the
+ /// facts deriving them do.
+ pub fn retract(self: *Database, pred_name: []const u8, row: []const Value) FrontendError!bool {
+ const name_atom = self.interner.find(pred_name) orelse return error.UnknownPredicate;
+ const pred = self.program.findPredicate(name_atom) orelse return error.UnknownPredicate;
+ const info = self.program.preds.items[pred];
+ if (row.len != info.arity) return error.ArityMismatch;
+
+ // A value the interner has never seen cannot match any stored fact.
+ var encoded: [dyntuple.MAX_ARITY]u64 = undefined;
+ for (row, 0..) |value, i| {
+ encoded[i] = switch (value) {
+ .int => |v| try interner_mod.encodeInt(v),
+ .str => |s| self.interner.find(s) orelse return false,
+ };
+ }
+
+ for (self.program.facts.items, 0..) |fact, i| {
+ if (fact.pred != pred) continue;
+ if (!std.mem.eql(u64, fact.row, encoded[0..row.len])) continue;
+ _ = self.program.facts.swapRemove(i);
+ self.clearDemand();
+ if (self.evaluator) |*evaluator| evaluator.deinit();
+ self.evaluator = null;
+ return true;
+ }
+ return false;
+ }
+
/// Parses Datalog source, appending its facts, rules, and queries to
/// the program. On error, `lastDiagnostic` has the details.
pub fn run(self: *Database, source: []const u8) FrontendError!void {
@@ -113,10 +151,27 @@ pub const Database = struct {
/// recomputes from scratch.
pub fn solve(self: *Database) FrontendError!void {
self.diagnostic = .{};
- const analysis = try analyze_mod.analyze(&self.program, &self.diagnostic);
+ // Any failure below leaves the database without results, so a later
+ // `query` or `explain` re-solves and reports the failure instead of
+ // serving partial or stale relations.
+ self.clearDemand();
if (self.evaluator) |*old| old.deinit();
+ self.evaluator = null;
+
+ const analysis = try analyze_mod.analyze(&self.program, &self.diagnostic);
+
+ // Recursive arithmetic can derive fresh values forever; refuse to
+ // evaluate it without an explicit iteration bound.
+ if (analysis.recursive_assignment and self.max_iterations == null) {
+ return error.IterationLimitRequired;
+ }
+
self.evaluator = evaluator_mod.Evaluator.init(self.allocator, &self.program);
+ errdefer {
+ self.evaluator.?.deinit();
+ self.evaluator = null;
+ }
self.evaluator.?.track_provenance = self.track_provenance;
try self.evaluator.?.solve(analysis.stratum_count, self.max_iterations);
}
@@ -143,13 +198,84 @@ pub const Database = struct {
};
}
- const relation = self.evaluator.?.relationOf(pred) orelse
- return RowIterator.empty(&self.interner, info.arity);
+ return self.rowsOf(&self.evaluator.?, pred, encoded, info.arity);
+ }
+
+ /// Answers one query with a demand-driven (magic sets) evaluation:
+ /// only tuples the bound arguments can reach are computed, instead of
+ /// the whole program. Falls back to `query` (full evaluation) when the
+ /// rewrite does not apply: no bound argument, a non-derived predicate,
+ /// or negation or aggregation among the relevant rules. Results are
+ /// identical to `query` either way. The iterator borrows the database;
+ /// it is invalidated by the next query, solve, or deinit.
+ pub fn queryDemand(self: *Database, pred_name: []const u8, pattern: []const ?Value) FrontendError!RowIterator {
+ const name_atom = self.interner.find(pred_name) orelse return error.UnknownPredicate;
+ const pred = self.program.findPredicate(name_atom) orelse return error.UnknownPredicate;
+ const info = self.program.preds.items[pred];
+ if (pattern.len != info.arity) return error.ArityMismatch;
+
+ // Encode the pattern. A string constant the interner has never seen
+ // cannot match anything.
+ var encoded: [dyntuple.MAX_ARITY]?u64 = @splat(null);
+ for (pattern, 0..) |slot, i| {
+ const value = slot orelse continue;
+ encoded[i] = switch (value) {
+ .int => |v| try interner_mod.encodeInt(v),
+ .str => |s| self.interner.find(s) orelse return RowIterator.empty(&self.interner, info.arity),
+ };
+ }
+
+ // Analysis validates the source program and lowers wildcards, which
+ // the rewrite relies on.
+ self.diagnostic = .{};
+ const analysis = try analyze_mod.analyze(&self.program, &self.diagnostic);
+ if (analysis.recursive_assignment and self.max_iterations == null) {
+ return error.IterationLimitRequired;
+ }
+
+ self.clearDemand();
+ const demand = magic.transform(
+ self.allocator,
+ &self.program,
+ &self.interner,
+ pred,
+ encoded[0..info.arity],
+ ) catch |err| switch (err) {
+ error.DemandUnsupported => return self.query(pred_name, pattern),
+ else => |other| return other,
+ };
+
+ self.demand_program = demand.program;
+ errdefer self.clearDemand();
+ const demand_analysis = try analyze_mod.analyze(&self.demand_program.?, null);
+ self.demand_evaluator = evaluator_mod.Evaluator.init(self.allocator, &self.demand_program.?);
+ try self.demand_evaluator.?.solve(demand_analysis.stratum_count, self.max_iterations);
+
+ return self.rowsOf(&self.demand_evaluator.?, demand.query_pred, encoded, info.arity);
+ }
+
+ fn clearDemand(self: *Database) void {
+ if (self.demand_evaluator) |*demand_evaluator| demand_evaluator.deinit();
+ self.demand_evaluator = null;
+ if (self.demand_program) |*demand_program| demand_program.deinit();
+ self.demand_program = null;
+ }
+
+ /// Rows of `pred` in `evaluator` matching an encoded pattern.
+ fn rowsOf(
+ self: *Database,
+ evaluator: *const evaluator_mod.Evaluator,
+ pred: ast.PredId,
+ encoded: [dyntuple.MAX_ARITY]?u64,
+ arity: u16,
+ ) RowIterator {
+ const relation = evaluator.relationOf(pred) orelse
+ return RowIterator.empty(&self.interner, arity);
// Gallop to the candidate range using the bound prefix columns.
var prefix_len: usize = 0;
var probe = dyntuple.zero_tuple;
- while (prefix_len < info.arity) : (prefix_len += 1) {
+ while (prefix_len < arity) : (prefix_len += 1) {
const bound = encoded[prefix_len] orelse break;
dyntuple.set(&probe, prefix_len, bound);
}
@@ -163,7 +289,7 @@ pub const Database = struct {
.pattern = encoded,
.prefix = probe,
.prefix_len = prefix_len,
- .arity = info.arity,
+ .arity = arity,
.interner = &self.interner,
};
}
@@ -188,7 +314,8 @@ pub const Database = struct {
/// Writes the proof tree showing how a derived tuple was obtained:
/// the rule deriving it and, recursively, the ground premises. Requires
/// `track_provenance` set before the solve. `max_depth` bounds the
- /// expanded rule levels; null means no bound.
+ /// expanded rule levels; null and values above
+ /// `explain.MAX_PROOF_DEPTH` fall back to that cap.
pub fn explain(
self: *Database,
writer: *std.Io.Writer,
@@ -689,3 +816,34 @@ test "Database: max iterations setting" {
db.max_iterations = null;
try db.solve();
}
+
+test "Database: queryDemand computes only the demanded slice" {
+ const allocator = std.testing.allocator;
+
+ var db = Database.init(allocator);
+ defer db.deinit();
+
+ // Two disconnected chains; the full closure holds 20 path tuples.
+ try db.run(
+ \\edge(1, 2). edge(2, 3). edge(3, 4). edge(4, 5).
+ \\edge(6, 7). edge(7, 8). edge(8, 9). edge(9, 10).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+
+ var it = try db.queryDemand("path", &.{ Value{ .int = 9 }, null });
+ defer it.deinit();
+ try std.testing.expect(it.next() != null);
+ try std.testing.expect(it.next() == null);
+
+ // Demand for path(9, _) reaches one path tuple and two magic bindings;
+ // everything else stays uncomputed.
+ var derived_tuples: usize = 0;
+ for (db.demand_program.?.preds.items, 0..) |info, pred| {
+ if (!info.derived) continue;
+ if (db.demand_evaluator.?.relationOf(@intCast(pred))) |relation| {
+ derived_tuples += relation.len();
+ }
+ }
+ try std.testing.expect(derived_tuples < 5);
+}
diff --git a/src/zodd/frontend/token.zig b/src/zodd/frontend/token.zig
index 5d4fa7f..fa62d6a 100644
--- a/src/zodd/frontend/token.zig
+++ b/src/zodd/frontend/token.zig
@@ -7,7 +7,8 @@
//! identifiers, `_` alone is the anonymous wildcard, integers are
//! non-negative `u64` literals, strings are double-quoted with `\"`, `\\`,
//! `\n`, and `\t` escapes, comparison operators are `<`, `<=`, `>`, `>=`,
-//! `=`, and `!=`, and `%` starts a line comment.
+//! `=`, and `!=`, arithmetic operators are `+`, `-`, `*`, and `/`, and `%`
+//! starts a line comment.
const std = @import("std");
const Span = @import("ast.zig").Span;
@@ -39,6 +40,10 @@ pub const TokenKind = enum {
equal,
/// `!=`
not_equal,
+ plus,
+ minus,
+ star,
+ slash,
eof,
};
@@ -96,7 +101,10 @@ pub const Lexer = struct {
}
return error.InvalidCharacter;
},
- '-' => return error.NegativeInteger,
+ '+' => return self.single(.plus),
+ '-' => return self.single(.minus),
+ '*' => return self.single(.star),
+ '/' => return self.single(.slash),
'<' => return self.maybeEqual(.less_than, .less_equal),
'>' => return self.maybeEqual(.greater_than, .greater_equal),
'=' => return self.single(.equal),
@@ -277,10 +285,12 @@ test "Lexer: comparison operators" {
}
test "Lexer: error cases" {
+ // `-` lexes as the minus operator; the parser rejects it in term
+ // position with error.NegativeInteger.
var negative = Lexer.init("p(-1).");
_ = try negative.next();
_ = try negative.next();
- try std.testing.expectError(error.NegativeInteger, negative.next());
+ try std.testing.expectEqual(TokenKind.minus, (try negative.next()).kind);
var unterminated = Lexer.init("p(\"abc");
_ = try unterminated.next();
diff --git a/src/zodd/index.zig b/src/zodd/index.zig
index e79bd08..1695671 100644
--- a/src/zodd/index.zig
+++ b/src/zodd/index.zig
@@ -59,10 +59,17 @@ pub fn SecondaryIndex(
var mutable_single = single;
errdefer mutable_single.deinit();
var old_rel = rel_ptr.*;
- const new_rel = try old_rel.merge(&mutable_single);
+ const new_rel = old_rel.merge(&mutable_single) catch |err| {
+ // A failed merge still consumes its inputs, so the map
+ // entry must not keep pointing at the old storage. The
+ // consumed relation is valid and empty.
+ rel_ptr.* = old_rel;
+ return err;
+ };
rel_ptr.* = new_rel;
} else {
- const rel = try Relation(Tuple).fromSlice(self.allocator, &[_]Tuple{tuple});
+ var rel = try Relation(Tuple).fromSlice(self.allocator, &[_]Tuple{tuple});
+ errdefer rel.deinit();
try self.map.put(key, rel);
}
}
@@ -195,3 +202,90 @@ test "SecondaryIndex: getRange end is inclusive" {
try std.testing.expectEqual(@as(usize, 1), point.len());
try std.testing.expectEqual(@as(u32, 3), point.elements[0][0]);
}
+
+test "SecondaryIndex: insert failure leaves the index memory-safe" {
+ // A duplicate insert routes through Relation.merge, whose shrink step can
+ // fail after the old relation's storage is gone. The map entry must never
+ // keep pointing at that freed storage.
+ const Tuple = struct { u32, u32 };
+ const Index = SecondaryIndex(Tuple, u32, struct {
+ fn extract(t: Tuple) u32 {
+ return t[1];
+ }
+ }.extract, u32Compare, 4);
+
+ // Fails every allocation, resize, and remap from `fail_index` onward
+ // until disarmed. Unlike std.testing.FailingAllocator it can be disarmed
+ // before deinit, so `deinit` (which allocates to walk the B-tree) can
+ // still free everything and the testing allocator can flag any leak,
+ // double free, or use-after-free from the insert path.
+ const OneShotFailing = struct {
+ inner: Allocator,
+ fail_index: usize,
+ index: usize = 0,
+
+ const vtable = Allocator.VTable{
+ .alloc = allocImpl,
+ .resize = resizeImpl,
+ .remap = remapImpl,
+ .free = freeImpl,
+ };
+
+ fn allocator(self: *@This()) Allocator {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+
+ fn armed(self: *const @This()) bool {
+ return self.index >= self.fail_index;
+ }
+
+ fn allocImpl(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ defer self.index += 1;
+ if (self.armed()) return null;
+ return self.inner.rawAlloc(len, alignment, ret_addr);
+ }
+
+ fn resizeImpl(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ if (self.armed()) return false;
+ return self.inner.rawResize(memory, alignment, new_len, ret_addr);
+ }
+
+ fn remapImpl(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ if (self.armed()) return null;
+ return self.inner.rawRemap(memory, alignment, new_len, ret_addr);
+ }
+
+ fn freeImpl(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ self.inner.rawFree(memory, alignment, ret_addr);
+ }
+ };
+
+ var fail_index: usize = 0;
+ while (fail_index < 12) : (fail_index += 1) {
+ var failing = OneShotFailing{ .inner = std.testing.allocator, .fail_index = fail_index };
+
+ var idx = Index.init(failing.allocator());
+ defer idx.deinit();
+
+ idx.insert(.{ 1, 10 }) catch {};
+ // Duplicate tuple: merge produces a shorter result, forcing the
+ // fallible shrink after the old storage is consumed.
+ idx.insert(.{ 1, 10 }) catch {};
+ idx.insert(.{ 2, 10 }) catch {};
+
+ // Whatever failed, lookups must not touch freed memory.
+ if (idx.get(10)) |rel| {
+ for (rel.elements) |t| {
+ std.mem.doNotOptimizeAway(t);
+ }
+ }
+
+ // Disarm before the deferred deinit: an allocation failure inside
+ // deinit's B-tree walk is a separate, documented limitation.
+ failing.fail_index = std.math.maxInt(usize);
+ }
+}
diff --git a/src/zodd/iteration.zig b/src/zodd/iteration.zig
index a08da96..5476b8e 100644
--- a/src/zodd/iteration.zig
+++ b/src/zodd/iteration.zig
@@ -52,6 +52,7 @@ pub fn Iteration(comptime Tuple: type) type {
/// Creates a new variable owned by this iteration.
pub fn variable(self: *Self) Allocator.Error!*Var {
const v = try self.allocator.create(Var);
+ errdefer self.allocator.destroy(v);
v.* = Var.init(self.allocator);
try self.variables.append(self.allocator, v);
return v;
@@ -150,3 +151,14 @@ test "Iteration: reset without new data" {
const changed3 = try iter.changed();
try std.testing.expect(!changed3);
}
+
+test "Iteration: variable creation failure does not leak" {
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
+ fn run(allocator: Allocator) !void {
+ var iter = Iteration(u32).init(allocator, null);
+ defer iter.deinit();
+ _ = try iter.variable();
+ _ = try iter.variable();
+ }
+ }.run, .{});
+}
diff --git a/src/zodd/join.zig b/src/zodd/join.zig
index a19f946..251a27f 100644
--- a/src/zodd/join.zig
+++ b/src/zodd/join.zig
@@ -96,7 +96,11 @@ fn gallopKey(comptime Key: type, comptime Val: type, slice: []const struct { Key
step = new_step;
}
- const end = @min(pos + step + 1, slice.len);
+ // Saturating arithmetic: `step` may be maxInt(usize) after the doubling
+ // loop saturated, in which case `pos + step + 1` would overflow.
+ const end_of_step = std.math.add(usize, pos, step) catch std.math.maxInt(usize);
+ const upper = std.math.add(usize, end_of_step, 1) catch std.math.maxInt(usize);
+ const end = @min(upper, slice.len);
var lo = pos + 1;
var hi = end;
diff --git a/src/zodd/relation.zig b/src/zodd/relation.zig
index bca0bf5..4b84083 100644
--- a/src/zodd/relation.zig
+++ b/src/zodd/relation.zig
@@ -199,11 +199,26 @@ pub fn Relation(comptime Tuple: type) type {
const field_info = @typeInfo(T);
if (field_info == .pointer) {
return std.math.order(@intFromPtr(a), @intFromPtr(b));
+ } else if (field_info == .@"enum") {
+ return std.math.order(@intFromEnum(a), @intFromEnum(b));
+ } else if (field_info == .float) {
+ // IEEE 754 total order via the bit-pattern trick, since
+ // std.math.order is unreachable for NaN operands. NaN sorts
+ // after +inf (or before -inf when negative), and -0.0 sorts
+ // before +0.0 as a distinct value.
+ return std.math.order(floatSortKey(T, a), floatSortKey(T, b));
} else {
return std.math.order(a, b);
}
}
+ fn floatSortKey(comptime T: type, x: T) std.meta.Int(.unsigned, @bitSizeOf(T)) {
+ const U = std.meta.Int(.unsigned, @bitSizeOf(T));
+ const sign_bit = @as(U, 1) << (@bitSizeOf(T) - 1);
+ const bits: U = @bitCast(x);
+ return if (bits & sign_bit != 0) ~bits else bits | sign_bit;
+ }
+
/// Compares two tuples.
pub fn compareTuples(a: Tuple, b: Tuple) std.math.Order {
const info = @typeInfo(Tuple);
@@ -216,12 +231,7 @@ pub fn Relation(comptime Tuple: type) type {
}
return .eq;
} else {
- const tuple_info = @typeInfo(Tuple);
- if (tuple_info == .pointer) {
- return std.math.order(@intFromPtr(a), @intFromPtr(b));
- } else {
- return std.math.order(a, b);
- }
+ return orderField(Tuple, a, b);
}
}
@@ -297,7 +307,7 @@ pub fn Relation(comptime Tuple: type) type {
const info = @typeInfo(T).@"enum";
const Tag = info.tag_type;
const bits = try reader.takeInt(Tag, .little);
- break :blk @as(T, @enumFromInt(bits));
+ break :blk std.enums.fromInt(T, bits) orelse return error.InvalidFormat;
},
.array => |info| blk: {
var result: T = undefined;
@@ -661,6 +671,51 @@ test "Relation: save/load round-trip for floats" {
try std.testing.expectEqualSlices(Tuple, original.elements, loaded.elements);
}
+test "Relation: load rejects an invalid enum tag" {
+ const allocator = std.testing.allocator;
+ const Color = enum(u8) { red, green };
+ const Tuple = struct { u32, Color };
+
+ var original = try Relation(Tuple).fromSlice(allocator, &[_]Tuple{
+ .{ 1, .red },
+ .{ 2, .green },
+ });
+ defer original.deinit();
+
+ var aw: std.Io.Writer.Allocating = .init(allocator);
+ defer aw.deinit();
+ try original.save(&aw.writer);
+
+ // Corrupt the first tuple's enum tag: 16 header bytes (magic, version,
+ // length), then the tuple's u32 field.
+ const bytes = aw.writer.buffered();
+ bytes[16 + 4] = 0xff;
+
+ var reader = std.Io.Reader.fixed(bytes);
+ try std.testing.expectError(error.InvalidFormat, Relation(Tuple).load(allocator, &reader));
+}
+
+test "Relation: NaN float fields sort and deduplicate without panicking" {
+ const allocator = std.testing.allocator;
+ const Tuple = struct { u32, f64 };
+ const nan = std.math.nan(f64);
+
+ var rel = try Relation(Tuple).fromSlice(allocator, &[_]Tuple{
+ .{ 2, 1.0 },
+ .{ 1, nan },
+ .{ 1, nan },
+ .{ 1, 0.5 },
+ });
+ defer rel.deinit();
+
+ // NaN compares consistently under the total order: the two NaN tuples
+ // deduplicate, and sorting places NaN after every finite value.
+ try std.testing.expectEqual(@as(usize, 3), rel.len());
+ try std.testing.expectEqual(@as(f64, 0.5), rel.elements[0][1]);
+ try std.testing.expect(std.math.isNan(rel.elements[1][1]));
+ try std.testing.expectEqual(@as(f64, 1.0), rel.elements[2][1]);
+}
+
test "Relation: save/load round-trip for differently-sized ints" {
const allocator = std.testing.allocator;
const Tuple = struct { u8, u64 };
diff --git a/src/zodd/variable.zig b/src/zodd/variable.zig
index 9530185..483076d 100644
--- a/src/zodd/variable.zig
+++ b/src/zodd/variable.zig
@@ -76,7 +76,8 @@ pub fn Variable(comptime Tuple: type) type {
/// Inserts a slice of tuples into the variable. The tuples are copied;
/// the caller retains ownership of `tuples`.
pub fn insertSlice(self: *Self, tuples: []const Tuple) Allocator.Error!void {
- const rel = try Rel.fromSlice(self.allocator, tuples);
+ var rel = try Rel.fromSlice(self.allocator, tuples);
+ errdefer rel.deinit();
try self.insert(rel);
}
diff --git a/tests/frontend_tests.zig b/tests/frontend_tests.zig
index fa7f8d1..70e7fc1 100644
--- a/tests/frontend_tests.zig
+++ b/tests/frontend_tests.zig
@@ -296,6 +296,541 @@ test "frontend: iteration limits surface from the engine" {
try testing.expectEqual(@as(usize, 10), count);
}
+test "frontend: aggregate whose argument is also a group variable" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ // The body binds MAX_ARITY (16) distinct variables, and the aggregated
+ // variable A is also a group variable, so the aggregate projection must
+ // not grow past MAX_ARITY columns.
+ try db.run(
+ \\e(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16).
+ \\e(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17).
+ \\e(2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16).
+ \\t(A, count(A)) :- e(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P).
+ );
+ try db.solve();
+
+ var it = try db.query("t", &.{ null, null });
+ defer it.deinit();
+ var counts: [3]u64 = .{ 0, 0, 0 };
+ var rows: usize = 0;
+ while (it.next()) |row| {
+ counts[@intCast(row.get(0).int)] = row.get(1).int;
+ rows += 1;
+ }
+ try testing.expectEqual(@as(usize, 2), rows);
+ try testing.expectEqual(@as(u64, 2), counts[1]);
+ try testing.expectEqual(@as(u64, 1), counts[2]);
+}
+
+test "frontend: query after a failed solve reports the failure" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3). edge(3, 4). edge(4, 5).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+ db.max_iterations = 1;
+ try testing.expectError(error.MaxIterationsExceeded, db.solve());
+
+ // The failed solve must not leave partial results behind: querying
+ // re-solves and surfaces the same failure instead of returning
+ // an incomplete relation.
+ try testing.expectError(error.MaxIterationsExceeded, db.query("path", &.{ null, null }));
+}
+
+test "frontend: analyze failure does not serve stale results" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2).
+ \\path(X, Y) :- edge(X, Y).
+ );
+ try db.solve();
+
+ // Adding an unsafe rule makes the next solve fail during analysis;
+ // queries must then report the failure rather than answer from the
+ // previous program version.
+ try db.run("bad(X) :- not edge(X, 1).");
+ try testing.expectError(error.UnsafeHeadVariable, db.solve());
+ try testing.expectError(error.UnsafeHeadVariable, db.query("path", &.{ null, null }));
+}
+
+test "frontend: fact with a huge arity is rejected, not a crash" {
+ const allocator = testing.allocator;
+
+ var source: std.ArrayListUnmanaged(u8) = .empty;
+ defer source.deinit(allocator);
+ try source.appendSlice(allocator, "p(1");
+ for (1..70_000) |_| {
+ try source.appendSlice(allocator, ", 1");
+ }
+ try source.appendSlice(allocator, ").");
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try testing.expectError(error.ArityTooLarge, db.run(source.items));
+}
+
+test "frontend: rule with too many wildcard variables is rejected" {
+ const allocator = testing.allocator;
+
+ // 8,250 literals with 8 wildcards each lower to 66,000 distinct
+ // variables, past the u16 VarId range.
+ var source: std.ArrayListUnmanaged(u8) = .empty;
+ defer source.deinit(allocator);
+ try source.appendSlice(allocator, "t(X) :- q(X, 1, 1, 1, 1, 1, 1, 1)");
+ for (0..8_250) |_| {
+ try source.appendSlice(allocator, ", q(_, _, _, _, _, _, _, _)");
+ }
+ try source.appendSlice(allocator, ".");
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(source.items);
+ try testing.expectError(error.TooManyVariables, db.solve());
+}
+
+test "frontend: arithmetic in comparison filters" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2, 30). edge(2, 3, 60). edge(3, 4, 70).
+ \\light(X, Y) :- edge(X, Y, W), W * 2 < 125.
+ \\heavy(X, Y) :- edge(X, Y, W), W + 10 >= 70.
+ \\middle(X) :- edge(X, _, W), (W + 10) * 2 = 140.
+ \\precedence(X) :- edge(X, _, _), 2 + 3 * 4 = 14.
+ );
+ try db.solve();
+
+ var light = try db.query("light", &.{ null, null });
+ defer light.deinit();
+ var light_count: usize = 0;
+ while (light.next()) |row| {
+ try testing.expect(row.get(0).int == 1 or row.get(0).int == 2);
+ light_count += 1;
+ }
+ try testing.expectEqual(@as(usize, 2), light_count);
+
+ var heavy = try db.query("heavy", &.{ null, null });
+ defer heavy.deinit();
+ var heavy_count: usize = 0;
+ while (heavy.next()) |row| {
+ try testing.expect(row.get(0).int == 2 or row.get(0).int == 3);
+ heavy_count += 1;
+ }
+ try testing.expectEqual(@as(usize, 2), heavy_count);
+
+ var middle = try db.query("middle", &.{null});
+ defer middle.deinit();
+ const middle_row = middle.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 2), middle_row.get(0).int);
+ try testing.expect(middle.next() == null);
+
+ // 2 + 3 * 4 must parse as 2 + (3 * 4): every edge source qualifies.
+ var prec = try db.query("precedence", &.{null});
+ defer prec.deinit();
+ var prec_count: usize = 0;
+ while (prec.next()) |_| prec_count += 1;
+ try testing.expectEqual(@as(usize, 3), prec_count);
+}
+
+test "frontend: arithmetic failure filters the tuple instead of erroring" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\v(0). v(3). v("s").
+ \\division(X) :- v(X), 6 / X > 1.
+ \\subtraction(X) :- v(X), X - 1 < 5.
+ \\overflow(X) :- v(X), X * 4611686018427387904 >= 0.
+ );
+ try db.solve();
+
+ // Division by zero and string operands fail the filter; only X = 3
+ // passes either rule. The overflow rule keeps nothing: X = 3 pushes the
+ // product past the 63-bit atom range, X = 0 gives 0 >= 0 but 0 * ... is
+ // fine, so X = 0 stays.
+ var division = try db.query("division", &.{null});
+ defer division.deinit();
+ const div_row = division.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 3), div_row.get(0).int);
+ try testing.expect(division.next() == null);
+
+ var subtraction = try db.query("subtraction", &.{null});
+ defer subtraction.deinit();
+ const sub_row = subtraction.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 3), sub_row.get(0).int);
+ try testing.expect(subtraction.next() == null);
+
+ var overflow = try db.query("overflow", &.{null});
+ defer overflow.deinit();
+ const ovf_row = overflow.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 0), ovf_row.get(0).int);
+ try testing.expect(overflow.next() == null);
+}
+
+test "frontend: oversized arithmetic expressions are rejected" {
+ const allocator = testing.allocator;
+
+ // Too many operands on one comparison side.
+ var wide: std.ArrayListUnmanaged(u8) = .empty;
+ defer wide.deinit(allocator);
+ try wide.appendSlice(allocator, "p(X) :- v(X), 1");
+ for (0..70) |_| {
+ try wide.appendSlice(allocator, " + 1");
+ }
+ try wide.appendSlice(allocator, " < X.");
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run("v(1).");
+ try testing.expectError(error.ExpressionTooLarge, db.run(wide.items));
+
+ // Too deeply parenthesized.
+ var deep: std.ArrayListUnmanaged(u8) = .empty;
+ defer deep.deinit(allocator);
+ try deep.appendSlice(allocator, "p(X) :- v(X), ");
+ for (0..40) |_| {
+ try deep.appendSlice(allocator, "(");
+ }
+ try deep.appendSlice(allocator, "1");
+ for (0..40) |_| {
+ try deep.appendSlice(allocator, ")");
+ }
+ try deep.appendSlice(allocator, " < X.");
+ try testing.expectError(error.ExpressionTooLarge, db.run(deep.items));
+}
+
+test "frontend: unbound variable inside an arithmetic expression is unsafe" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\v(1).
+ \\bad(X) :- v(X), X + Y < 10.
+ );
+ try testing.expectError(error.UnsafeComparisonVariable, db.solve());
+}
+
+test "frontend: is-assignments compute per-tuple values" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3). edge(3, 4).
+ \\dist(1, 0).
+ \\dist(Y, D2) :- dist(X, D), edge(X, Y), D2 is D + 1.
+ );
+ db.max_iterations = 10;
+ try db.solve();
+
+ var it = try db.query("dist", &.{ null, null });
+ defer it.deinit();
+ var hops: [5]u64 = @splat(99);
+ var rows: usize = 0;
+ while (it.next()) |row| {
+ hops[@intCast(row.get(0).int)] = row.get(1).int;
+ rows += 1;
+ }
+ try testing.expectEqual(@as(usize, 4), rows);
+ try testing.expectEqual(@as(u64, 0), hops[1]);
+ try testing.expectEqual(@as(u64, 1), hops[2]);
+ try testing.expectEqual(@as(u64, 2), hops[3]);
+ try testing.expectEqual(@as(u64, 3), hops[4]);
+}
+
+test "frontend: assignments chain and feed comparisons" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\v(1). v(40).
+ \\r(X, B) :- v(X), A is X + 1, B is A * 2, B < 20.
+ );
+ try db.solve();
+
+ // X = 1 gives B = 4; X = 40 gives B = 82, filtered by B < 20.
+ var it = try db.query("r", &.{ null, null });
+ defer it.deinit();
+ const row = it.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 1), row.get(0).int);
+ try testing.expectEqual(@as(u64, 4), row.get(1).int);
+ try testing.expect(it.next() == null);
+}
+
+test "frontend: assignment failure derives nothing for the tuple" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\v(0). v(3). v("s").
+ \\dec(X, Y) :- v(X), Y is X - 1.
+ );
+ try db.solve();
+
+ // X = 0 underflows and X = "s" is not an integer; only X = 3 derives.
+ var it = try db.query("dec", &.{ null, null });
+ defer it.deinit();
+ const row = it.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 3), row.get(0).int);
+ try testing.expectEqual(@as(u64, 2), row.get(1).int);
+ try testing.expect(it.next() == null);
+}
+
+test "frontend: unsound assignments are rejected" {
+ const allocator = testing.allocator;
+
+ // An unbound variable on the right-hand side.
+ {
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(
+ \\v(1).
+ \\bad(X, Y) :- v(X), Y is Z + 1.
+ );
+ try testing.expectError(error.UnsafeAssignmentVariable, db.solve());
+ }
+
+ // A target already bound by a positive literal.
+ {
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(
+ \\v(1).
+ \\bad(X) :- v(X), X is 1 + 1.
+ );
+ try testing.expectError(error.InvalidAssignment, db.solve());
+ }
+
+ // A target assigned twice.
+ {
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(
+ \\v(1).
+ \\bad(X, A) :- v(X), A is X + 1, A is X + 2.
+ );
+ try testing.expectError(error.InvalidAssignment, db.solve());
+ }
+}
+
+test "frontend: recursive arithmetic requires an iteration limit" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ // On a cyclic graph this program would count hops forever; solving
+ // without an iteration limit must be refused up front.
+ try db.run(
+ \\edge(1, 2). edge(2, 1).
+ \\dist(1, 0).
+ \\dist(Y, D2) :- dist(X, D), edge(X, Y), D2 is D + 1.
+ );
+ try testing.expectError(error.IterationLimitRequired, db.solve());
+
+ db.max_iterations = 5;
+ try testing.expectError(error.MaxIterationsExceeded, db.solve());
+}
+
+test "frontend: predicates up to arity 16" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\wide(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16).
+ \\wide(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17).
+ \\ends(A, P) :- wide(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P).
+ );
+ try db.solve();
+
+ var it = try db.query("ends", &.{ null, null });
+ defer it.deinit();
+ var lasts: [2]u64 = undefined;
+ var rows: usize = 0;
+ while (it.next()) |row| : (rows += 1) {
+ try testing.expectEqual(@as(u64, 1), row.get(0).int);
+ lasts[rows] = row.get(1).int;
+ }
+ try testing.expectEqual(@as(usize, 2), rows);
+ try testing.expectEqualSlices(u64, &.{ 16, 17 }, &lasts);
+
+ // Arity 17 stays out of range.
+ var over = zodd.Database.init(allocator);
+ defer over.deinit();
+ try testing.expectError(
+ error.ArityTooLarge,
+ over.run("p(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)."),
+ );
+}
+
+test "frontend: retract removes a base fact and recomputes" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+ try db.solve();
+
+ var before = try db.query("path", &.{ null, null });
+ defer before.deinit();
+ var count: usize = 0;
+ while (before.next()) |_| count += 1;
+ try testing.expectEqual(@as(usize, 3), count);
+
+ // Retracting edge(2, 3) removes path(2, 3) and path(1, 3).
+ try testing.expect(try db.retract("edge", &.{ .{ .int = 2 }, .{ .int = 3 } }));
+ var after = try db.query("path", &.{ null, null });
+ defer after.deinit();
+ const row = after.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 1), row.get(0).int);
+ try testing.expectEqual(@as(u64, 2), row.get(1).int);
+ try testing.expect(after.next() == null);
+
+ // A second retract of the same fact, or of a fact never added, removes
+ // nothing.
+ try testing.expect(!try db.retract("edge", &.{ .{ .int = 2 }, .{ .int = 3 } }));
+ try testing.expect(!try db.retract("edge", &.{ .{ .int = 9 }, .{ .int = 9 } }));
+
+ // Unknown predicates and arity mismatches are errors.
+ try testing.expectError(error.UnknownPredicate, db.retract("nope", &.{.{ .int = 1 }}));
+ try testing.expectError(error.ArityMismatch, db.retract("edge", &.{.{ .int = 1 }}));
+}
+
+test "frontend: queryDemand matches query on recursive programs" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3). edge(3, 4). edge(2, 5). edge(5, 6).
+ \\edge(6, 3). edge(7, 1). edge(4, 8). edge(8, 9). edge(9, 4).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+
+ // Demand-driven answers for a bound first argument match the full
+ // evaluation, for every source node.
+ var source: u64 = 1;
+ while (source <= 9) : (source += 1) {
+ var full = try db.query("path", &.{ zodd.Value{ .int = source }, null });
+ defer full.deinit();
+ var expected: std.ArrayListUnmanaged(u64) = .empty;
+ defer expected.deinit(allocator);
+ while (full.next()) |row| {
+ try expected.append(allocator, row.get(1).int);
+ }
+
+ var demand = try db.queryDemand("path", &.{ zodd.Value{ .int = source }, null });
+ defer demand.deinit();
+ var got: std.ArrayListUnmanaged(u64) = .empty;
+ defer got.deinit(allocator);
+ while (demand.next()) |row| {
+ try testing.expectEqual(source, row.get(0).int);
+ try got.append(allocator, row.get(1).int);
+ }
+
+ try testing.expectEqualSlices(u64, expected.items, got.items);
+ }
+}
+
+test "frontend: queryDemand falls back when demand does not apply" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3).
+ \\blocked(3).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ \\open(X, Y) :- path(X, Y), not blocked(Y).
+ \\deg(N, count(M)) :- edge(N, M).
+ );
+
+ // Negation in the cone: falls back to full evaluation, same answers.
+ var open = try db.queryDemand("open", &.{ zodd.Value{ .int = 1 }, null });
+ defer open.deinit();
+ const open_row = open.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 2), open_row.get(1).int);
+ try testing.expect(open.next() == null);
+
+ // Aggregates in the cone.
+ var deg = try db.queryDemand("deg", &.{ zodd.Value{ .int = 1 }, null });
+ defer deg.deinit();
+ try testing.expectEqual(@as(u64, 1), (deg.next() orelse return error.TestUnexpectedResult).get(1).int);
+
+ // No bound argument.
+ var all = try db.queryDemand("path", &.{ null, null });
+ defer all.deinit();
+ var count: usize = 0;
+ while (all.next()) |_| count += 1;
+ try testing.expectEqual(@as(usize, 3), count);
+
+ // A base (non-derived) predicate.
+ var base = try db.queryDemand("edge", &.{ zodd.Value{ .int = 1 }, null });
+ defer base.deinit();
+ try testing.expectEqual(@as(u64, 2), (base.next() orelse return error.TestUnexpectedResult).get(1).int);
+}
+
+test "frontend: queryDemand evaluates only the demanded cone" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ // Two disconnected chains; demand for a node in the first chain must
+ // not derive paths in the second one. `path(9, X)` from the far end of
+ // a chain demands far fewer tuples than the full closure.
+ try db.run(
+ \\edge(1, 2). edge(2, 3). edge(3, 4). edge(4, 5).
+ \\edge(6, 7). edge(7, 8). edge(8, 9). edge(9, 10).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+
+ var it = try db.queryDemand("path", &.{ zodd.Value{ .int = 9 }, null });
+ defer it.deinit();
+ const row = it.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 10), row.get(1).int);
+ try testing.expect(it.next() == null);
+ // An inline test in program.zig checks that this evaluation computed
+ // only the demanded slice of the closure.
+}
+
test "frontend: stored queries parse and survive analysis" {
const allocator = testing.allocator;
diff --git a/web/index.html b/web/index.html
index dff8222..e6730bb 100644
--- a/web/index.html
+++ b/web/index.html
@@ -135,8 +135,23 @@ Comparison Filters
=, and !=. Every variable in a comparison must be bound by a positive subgoal
in the rule body, and wildcards are not allowed. Ordered comparisons compare integers; a string operand
will fail the comparison.
+ Either side of a comparison may be an arithmetic expression over unsigned integers using +,
+ -, *, /, and parentheses. Arithmetic that does not produce a value
+ (a string operand, overflow, underflow, or division by zero) fails the comparison for that tuple.
hop(X, Y) :- edge(X, Y, W), W < 60.
-reach(X, Z) :- reach(X, Y), hop(Y, Z), X != Z.
+reach(X, Z) :- reach(X, Y), hop(Y, Z), X != Z.
+cheap(X, Y) :- edge(X, Y, W), (W + 10) * 2 < 140.
+
+
+
+ Assignments
+ Var is expr binds a fresh variable to the expression's value for each tuple. Every variable
+ on the right-hand side must already be bound, and the target must not be bound anywhere else in the
+ rule. An assignment whose expression produces no value derives nothing for that tuple. A recursive rule
+ that uses an assignment requires an iteration limit, because it can otherwise derive new values
+ forever.
+ dist(1, 0).
+dist(Y, D2) :- dist(X, D), edge(X, Y), D2 is D + 1.
diff --git a/web/zodd_wasm.zig b/web/zodd_wasm.zig
index 5b5a809..be6f66f 100644
--- a/web/zodd_wasm.zig
+++ b/web/zodd_wasm.zig
@@ -18,7 +18,7 @@ const builtin = @import("builtin");
const allocator = std.heap.wasm_allocator;
/// Maximum predicate arity, mirroring the frontend's `MAX_ARITY`.
-const max_arity = 8;
+const max_arity = 16;
/// Fixed-point rounds allowed per stratum before reporting an error.
const iteration_limit = 100_000;
From e9c9ba9a682efd5b7e26ae7b7f6e2a787f9c05d1 Mon Sep 17 00:00:00 2001
From: Hassan Abedi
Date: Sat, 1 Aug 2026 13:12:09 +0200
Subject: [PATCH 3/5] Implement more features (like CLI and Parallel execution
support)
---
AGENTS.md | 4 +-
Makefile | 6 +-
ROADMAP.md | 7 +-
build.zig | 45 +++-
build.zig.zon | 4 +
src/cli/main.zig | 441 +++++++++++++++++++++++++++++++
src/zodd/frontend/evaluator.zig | 448 ++++++++++++++++++++++++++++++--
src/zodd/frontend/program.zig | 135 +++++++++-
src/zodd/variable.zig | 30 +++
tests/frontend_tests.zig | 138 ++++++++++
tests/incremental_tests.zig | 70 +++++
11 files changed, 1277 insertions(+), 51 deletions(-)
create mode 100644 src/cli/main.zig
diff --git a/AGENTS.md b/AGENTS.md
index a824494..9bb9b28 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,7 @@ Priorities, in order:
`ast.zig` and `builder.zig` hold the shared IR and the programmatic builder; `analyze.zig` checks safety and stratification;
`dyntuple.zig`, `plan.zig`, `join_runtime.zig`, and `evaluator.zig` compile and run rules on the engine core; `explain.zig` renders rule
plans and provenance proof trees; `magic.zig` builds the demand-transformed (magic sets) program behind `Database.queryDemand`.
+- `src/cli/main.zig`: The `zodd` CLI executable (`run`, `query`, `plan`, `explain`, and `repl` subcommands), built via `zig build cli`.
- `tests/`: Non-unit tests (`integration_tests.zig`, `regression_tests.zig`, `property_tests.zig`, `incremental_tests.zig`, `frontend_tests.zig`).
- `web/`: Web frontend. `zodd_wasm.zig` is the Wasm wrapper built by `zig build wasm`; `index.html`, `main.js`, and `style.css` are the UI;
`smoke_test.mjs` is the Node.js smoke test run by `make web-test`.
@@ -86,10 +87,11 @@ The rest of `src/zodd/` is internal and may be refactored freely as long as the
### Dependencies
-Zodd depends on two sibling Zig packages declared in `build.zig.zon`:
+Zodd depends on three sibling Zig packages declared in `build.zig.zon`:
- `ordered`: sorted container primitives, linked into the `zodd` module for all builds.
- `minish`: property-testing framework, used only by `tests/property_tests.zig` and lazy-loaded in `build.zig`.
+- `chilli`: CLI framework, used only by the `zodd` CLI executable (`src/cli/main.zig`).
Please do not add further dependencies without prior discussion.
diff --git a/Makefile b/Makefile
index 5fef6f3..7c455c6 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ SHELL := /usr/bin/env bash
################################################################################
.PHONY: all build rebuild example test lint format docs docs-serve clean install-deps release help coverage \
- setup-hooks test-hooks web web-serve web-test
+ setup-hooks test-hooks web web-serve web-test cli
.DEFAULT_GOAL := help
help: ## Show the help messages for all targets
@@ -68,6 +68,10 @@ lint: ## Check code style and formatting of Zig files
@echo "Running code style checks..."
$(ZIG) fmt --check $(SRC_DIR) $(TEST_DIR) web/zodd_wasm.zig
+cli: ## Build the zodd CLI into zig-out/bin
+ @echo "Building the zodd CLI..."
+ $(ZIG) build cli $(BUILD_OPTS)
+
web: ## Build the web frontend Wasm module and stage it under `web/`
@echo "Building the web frontend Wasm module..."
$(ZIG) build wasm
diff --git a/ROADMAP.md b/ROADMAP.md
index 648d8f1..e3038d7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -25,9 +25,10 @@ This document lists the completed and planned features for Zodd.
- [x] Recursion limits
- [x] Persistence
- [x] Secondary indices
-- [x] Incremental maintenance
-- [ ] Parallel execution
-- [ ] CLI
+- [x] Incremental maintenance (`Database.update` after `addFact`/`retract`: additions propagate as semi-naive deltas, deletions and
+ negation/aggregate effects recompute only the affected strata; tuple-level deletion (DRed) is a possible future refinement)
+- [x] Parallel execution (opt-in worker threads per fixed-point round via `Database.parallelism`; identical results to sequential evaluation)
+- [x] CLI (`zodd run`, `query` with demand-driven evaluation, `plan`, `explain`, and a `repl`, built on Chilli)
- [ ] Streaming input
- [x] Rule DSL (textual Datalog frontend with a parser, a builder API, stratified negation, and aggregates)
- [x] Comparison operators (`<`, `<=`, `>`, `>=`, `=`, and `!=` as body filters)
diff --git a/build.zig b/build.zig
index a0bbd3f..129e175 100644
--- a/build.zig
+++ b/build.zig
@@ -26,6 +26,12 @@ pub fn build(b: *std.Build) void {
});
b.installArtifact(lib);
+ // Version and commit information, for the CLI and the Wasm module.
+ const build_options = b.addOptions();
+ build_options.addOption([]const u8, "version", getVersion(b));
+ build_options.addOption([]const u8, "commit", getGitInfo(b));
+ const build_options_mod = build_options.createModule();
+
// Unit tests (embedded in src/lib.zig)
const lib_tests = b.addTest(.{
.root_module = zodd_mod,
@@ -36,6 +42,37 @@ pub fn build(b: *std.Build) void {
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
+ // CLI executable (see src/cli/)
+ {
+ const chilli_dep = b.dependency("chilli", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ const cli_mod = b.createModule(.{
+ .root_source_file = b.path("src/cli/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ cli_mod.addImport("zodd", zodd_mod);
+ cli_mod.addImport("chilli", chilli_dep.module("chilli"));
+ cli_mod.addImport("build_options", build_options_mod);
+
+ const cli_exe = b.addExecutable(.{
+ .name = "zodd",
+ .root_module = cli_mod,
+ });
+ b.installArtifact(cli_exe);
+
+ const cli_step = b.step("cli", "Build the zodd CLI");
+ cli_step.dependOn(&b.addInstallArtifact(cli_exe, .{}).step);
+
+ const cli_tests = b.addTest(.{
+ .root_module = cli_mod,
+ .name = "cli-tests",
+ });
+ test_step.dependOn(&b.addRunArtifact(cli_tests).step);
+ }
+
const io = b.graph.io;
// Discover and add tests from tests/ directory
@@ -132,19 +169,13 @@ pub fn build(b: *std.Build) void {
});
zodd_wasm_mod.addImport("ordered", ordered_wasm_dep.module("ordered"));
- const build_options = b.addOptions();
- const version = getVersion(b);
- build_options.addOption([]const u8, "version", version);
- const commit = getGitInfo(b);
- build_options.addOption([]const u8, "commit", commit);
-
const wasm_mod = b.createModule(.{
.root_source_file = b.path("web/zodd_wasm.zig"),
.target = wasm_target,
.optimize = wasm_optimize,
});
wasm_mod.addImport("zodd", zodd_wasm_mod);
- wasm_mod.addImport("build_options", build_options.createModule());
+ wasm_mod.addImport("build_options", build_options_mod);
const wasm_exe = b.addExecutable(.{
.name = "zodd",
diff --git a/build.zig.zon b/build.zig.zon
index c25b20a..6d0657f 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -12,6 +12,10 @@
.url = "https://github.com/CogitatorTech/ordered/archive/refs/tags/v0.2.1.tar.gz",
.hash = "ordered-0.2.1-Gy41sDCgAgA_QUoMf8Upew_Yx8sAPe7Fw_A2v5qP8GMK",
},
+ .chilli = .{
+ .url = "https://github.com/CogitatorTech/chilli/archive/refs/tags/v0.3.2.tar.gz",
+ .hash = "chilli-0.3.2-c19PrireAQAXEfA7xrmJW2lrSiNiuuG8wtJI-MJVZgFU",
+ },
},
.paths = .{ "build.zig", "build.zig.zon", "src", "LICENSE", "README.md" },
}
diff --git a/src/cli/main.zig b/src/cli/main.zig
new file mode 100644
index 0000000..f88214d
--- /dev/null
+++ b/src/cli/main.zig
@@ -0,0 +1,441 @@
+//! # Zodd CLI
+//!
+//! A command-line interface for the Datalog frontend: run programs, answer
+//! ad-hoc queries (demand-driven by default), print rule plans and proof
+//! trees, and explore interactively in a REPL.
+
+const std = @import("std");
+const chilli = @import("chilli");
+const zodd = @import("zodd");
+const build_options = @import("build_options");
+
+const io = std.Options.debug_io;
+
+/// Rows printed per query before truncating, matching the web frontend.
+const max_rows = 10_000;
+
+pub fn main(init: std.process.Init.Minimal) !void {
+ var gpa: std.heap.DebugAllocator(.{}) = .init;
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ var root = try chilli.Command.init(allocator, .{
+ .name = "zodd",
+ .description = "An embeddable Datalog engine",
+ .version = build_options.version,
+ .exec = execRoot,
+ });
+ defer root.deinit();
+
+ const run_cmd = try chilli.Command.init(allocator, .{
+ .name = "run",
+ .description = "Solve a program and answer its stored (?-) queries",
+ .exec = execRun,
+ });
+ try run_cmd.addPositional(.{ .name = "file", .description = "Datalog source file", .is_required = true });
+ try addCommonFlags(run_cmd);
+ try root.addSubcommand(run_cmd);
+
+ const query_cmd = try chilli.Command.init(allocator, .{
+ .name = "query",
+ .description = "Answer one goal against a program, demand-driven by default",
+ .exec = execQuery,
+ });
+ try query_cmd.addPositional(.{ .name = "file", .description = "Datalog source file", .is_required = true });
+ try query_cmd.addPositional(.{ .name = "goal", .description = "Goal, like 'path(1, X)'", .is_required = true });
+ try query_cmd.addFlag(.{
+ .name = "full",
+ .description = "Evaluate the whole program instead of the demanded slice",
+ .type = .Bool,
+ .default_value = .{ .Bool = false },
+ });
+ try addCommonFlags(query_cmd);
+ try root.addSubcommand(query_cmd);
+
+ const plan_cmd = try chilli.Command.init(allocator, .{
+ .name = "plan",
+ .description = "Print the compiled join plan of every rule",
+ .exec = execPlan,
+ });
+ try plan_cmd.addPositional(.{ .name = "file", .description = "Datalog source file", .is_required = true });
+ try root.addSubcommand(plan_cmd);
+
+ const explain_cmd = try chilli.Command.init(allocator, .{
+ .name = "explain",
+ .description = "Print the proof tree of a derived fact",
+ .exec = execExplain,
+ });
+ try explain_cmd.addPositional(.{ .name = "file", .description = "Datalog source file", .is_required = true });
+ try explain_cmd.addPositional(.{ .name = "fact", .description = "Ground fact, like 'path(1, 3)'", .is_required = true });
+ try explain_cmd.addFlag(.{
+ .name = "depth",
+ .description = "Proof levels to expand",
+ .type = .Int,
+ .default_value = .{ .Int = 16 },
+ });
+ try addCommonFlags(explain_cmd);
+ try root.addSubcommand(explain_cmd);
+
+ const repl_cmd = try chilli.Command.init(allocator, .{
+ .name = "repl",
+ .description = "Interactive session; facts and rules end with '.', goals start with '?-'",
+ .exec = execRepl,
+ });
+ try repl_cmd.addPositional(.{
+ .name = "file",
+ .description = "Datalog source file to preload",
+ .default_value = .{ .String = "" },
+ });
+ try addCommonFlags(repl_cmd);
+ try root.addSubcommand(repl_cmd);
+
+ try root.run(init.args, null);
+}
+
+fn addCommonFlags(cmd: *chilli.Command) !void {
+ try cmd.addFlag(.{
+ .name = "max-iterations",
+ .description = "Fixed-point rounds allowed per stratum (0 means no limit)",
+ .type = .Int,
+ .default_value = .{ .Int = 0 },
+ });
+ try cmd.addFlag(.{
+ .name = "parallel",
+ .shortcut = 'j',
+ .description = "Worker threads per fixed-point round (0 means one per CPU)",
+ .type = .Int,
+ .default_value = .{ .Int = 1 },
+ });
+}
+
+fn execRoot(ctx: chilli.CommandContext) !void {
+ try ctx.command.printHelp();
+}
+
+/// Reads a source file, reporting the path on failure.
+fn loadSource(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
+ return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .unlimited) catch |err| {
+ std.debug.print("error: cannot read '{s}': {t}\n", .{ path, err });
+ std.process.exit(1);
+ };
+}
+
+/// Loads a program into `db`, printing the diagnostic on failure.
+fn loadProgram(db: *zodd.Database, source: []const u8, ctx: chilli.CommandContext) !void {
+ const max_iterations = try ctx.getFlag("max-iterations", i64);
+ if (max_iterations > 0) db.max_iterations = @intCast(max_iterations);
+ const parallel = try ctx.getFlag("parallel", i64);
+ if (parallel >= 0) db.parallelism = @intCast(parallel);
+ db.run(source) catch |err| fatal(db, source, err);
+}
+
+/// Prints the database diagnostic (with line and column when available) to
+/// stderr, or the bare error when there is none.
+fn reportDiagnostic(db: *const zodd.Database, source: ?[]const u8, err: anyerror) void {
+ const diag = db.lastDiagnostic() orelse {
+ std.debug.print("error: {t}\n", .{err});
+ return;
+ };
+ if (diag.message.len == 0) {
+ std.debug.print("error: {t}\n", .{err});
+ return;
+ }
+ if (source != null and diag.span != null) {
+ const location = lineColumn(source.?, diag.span.?.start);
+ std.debug.print("error at {d}:{d}: {s}\n", .{ location.line, location.column, diag.message });
+ } else {
+ std.debug.print("error: {s}\n", .{diag.message});
+ }
+}
+
+/// Reports and exits: command handlers call this instead of propagating, so
+/// the framework does not print a second, generic error message.
+fn fatal(db: *const zodd.Database, source: ?[]const u8, err: anyerror) noreturn {
+ reportDiagnostic(db, source, err);
+ std.process.exit(1);
+}
+
+const Location = struct { line: usize, column: usize };
+
+fn lineColumn(source: []const u8, offset: usize) Location {
+ var line: usize = 1;
+ var column: usize = 1;
+ const end = @min(offset, source.len);
+ for (source[0..end]) |byte| {
+ if (byte == '\n') {
+ line += 1;
+ column = 1;
+ } else {
+ column += 1;
+ }
+ }
+ return .{ .line = line, .column = column };
+}
+
+/// Appends "?- " and "." around a bare goal so it parses as a stored query.
+/// Already-wrapped goals pass through.
+fn normalizeGoal(allocator: std.mem.Allocator, goal: []const u8) ![]u8 {
+ const trimmed = std.mem.trim(u8, goal, " \t\r\n");
+ const without_prefix = if (std.mem.startsWith(u8, trimmed, "?-"))
+ std.mem.trimStart(u8, trimmed["?-".len..], " \t")
+ else
+ trimmed;
+ const without_dot = if (std.mem.endsWith(u8, without_prefix, "."))
+ without_prefix[0 .. without_prefix.len - 1]
+ else
+ without_prefix;
+ return std.fmt.allocPrint(allocator, "?- {s}.", .{without_dot});
+}
+
+/// Decodes the last stored query of `db` into a name and Value pattern.
+const Goal = struct {
+ name: []const u8,
+ pattern: [16]?zodd.Value,
+ arity: u16,
+};
+
+fn lastStoredGoal(db: *zodd.Database) ?Goal {
+ if (db.program.queries.items.len == 0) return null;
+ const stored = db.program.queries.items[db.program.queries.items.len - 1];
+ const info = db.program.preds.items[stored.pred];
+ var goal = Goal{
+ .name = db.interner.resolve(info.name_atom).str,
+ .pattern = @splat(null),
+ .arity = info.arity,
+ };
+ for (stored.pattern, 0..) |maybe_atom, i| {
+ if (maybe_atom) |atom| goal.pattern[i] = db.interner.resolve(atom);
+ }
+ return goal;
+}
+
+fn printRows(writer: *std.Io.Writer, it: *zodd.RowIterator) !usize {
+ var printed: usize = 0;
+ while (it.next()) |row| {
+ if (printed == max_rows) {
+ try writer.print("... (truncated at {d} rows)\n", .{max_rows});
+ break;
+ }
+ try writer.print("{f}\n", .{row});
+ printed += 1;
+ }
+ if (printed == 0) try writer.writeAll("(no rows)\n");
+ return printed;
+}
+
+fn execRun(ctx: chilli.CommandContext) !void {
+ const allocator = ctx.tmp_allocator;
+ const path = try ctx.getArg("file", []const u8);
+ const source = try loadSource(allocator, path);
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try loadProgram(&db, source, ctx);
+ db.solve() catch |err| fatal(&db, source, err);
+
+ var buffer: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &buffer);
+ const writer = &stdout.interface;
+ defer stdout.flush() catch {};
+
+ if (db.program.queries.items.len > 0) {
+ for (db.program.queries.items) |stored| {
+ const info = db.program.preds.items[stored.pred];
+ const name = db.interner.resolve(info.name_atom).str;
+ var pattern: [16]?zodd.Value = @splat(null);
+ for (stored.pattern, 0..) |maybe_atom, i| {
+ if (maybe_atom) |atom| pattern[i] = db.interner.resolve(atom);
+ }
+ try writer.print("?- {s}:\n", .{name});
+ var it = try db.query(name, pattern[0..info.arity]);
+ defer it.deinit();
+ _ = try printRows(writer, &it);
+ }
+ } else {
+ for (db.program.preds.items) |info| {
+ if (!info.derived) continue;
+ const name = db.interner.resolve(info.name_atom).str;
+ try writer.print("{s}:\n", .{name});
+ var pattern: [16]?zodd.Value = @splat(null);
+ var it = try db.query(name, pattern[0..info.arity]);
+ defer it.deinit();
+ _ = try printRows(writer, &it);
+ }
+ }
+}
+
+fn execQuery(ctx: chilli.CommandContext) !void {
+ const allocator = ctx.tmp_allocator;
+ const path = try ctx.getArg("file", []const u8);
+ const goal_text = try ctx.getArg("goal", []const u8);
+ const full = try ctx.getFlag("full", bool);
+ const source = try loadSource(allocator, path);
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try loadProgram(&db, source, ctx);
+
+ const normalized = try normalizeGoal(allocator, goal_text);
+ db.run(normalized) catch |err| fatal(&db, normalized, err);
+ const goal = lastStoredGoal(&db).?;
+
+ var buffer: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &buffer);
+ const writer = &stdout.interface;
+ defer stdout.flush() catch {};
+
+ var it = (if (full)
+ db.query(goal.name, goal.pattern[0..goal.arity])
+ else
+ db.queryDemand(goal.name, goal.pattern[0..goal.arity])) catch |err| fatal(&db, source, err);
+ defer it.deinit();
+ _ = try printRows(writer, &it);
+}
+
+fn execPlan(ctx: chilli.CommandContext) !void {
+ const allocator = ctx.tmp_allocator;
+ const path = try ctx.getArg("file", []const u8);
+ const source = try loadSource(allocator, path);
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ db.run(source) catch |err| fatal(&db, source, err);
+
+ var buffer: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &buffer);
+ const writer = &stdout.interface;
+ defer stdout.flush() catch {};
+
+ db.explainPlan(writer) catch |err| fatal(&db, source, err);
+}
+
+fn execExplain(ctx: chilli.CommandContext) !void {
+ const allocator = ctx.tmp_allocator;
+ const path = try ctx.getArg("file", []const u8);
+ const fact_text = try ctx.getArg("fact", []const u8);
+ const depth = try ctx.getFlag("depth", i64);
+ const source = try loadSource(allocator, path);
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ db.track_provenance = true;
+ try loadProgram(&db, source, ctx);
+
+ const normalized = try normalizeGoal(allocator, fact_text);
+ db.run(normalized) catch |err| fatal(&db, normalized, err);
+ const goal = lastStoredGoal(&db).?;
+
+ var values: [16]zodd.Value = undefined;
+ for (goal.pattern[0..goal.arity], 0..) |slot, i| {
+ values[i] = slot orelse {
+ std.debug.print("error: explain needs a ground fact; '{s}' has free arguments\n", .{fact_text});
+ std.process.exit(1);
+ };
+ }
+
+ var buffer: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &buffer);
+ const writer = &stdout.interface;
+ defer stdout.flush() catch {};
+
+ db.explain(writer, goal.name, values[0..goal.arity], @intCast(@max(depth, 1))) catch |err| {
+ stdout.flush() catch {};
+ if (err == error.TupleNotFound) {
+ std.debug.print("error: '{s}' is not in the result set\n", .{fact_text});
+ std.process.exit(1);
+ }
+ fatal(&db, source, err);
+ };
+}
+
+fn execRepl(ctx: chilli.CommandContext) !void {
+ const allocator = ctx.tmp_allocator;
+ const path = try ctx.getArg("file", []const u8);
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ if (path.len > 0) {
+ const source = try loadSource(allocator, path);
+ try loadProgram(&db, source, ctx);
+ } else {
+ const max_iterations = try ctx.getFlag("max-iterations", i64);
+ if (max_iterations > 0) db.max_iterations = @intCast(max_iterations);
+ }
+
+ var out_buffer: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &out_buffer);
+ const writer = &stdout.interface;
+
+ var in_buffer: [64 * 1024]u8 = undefined;
+ var stdin = std.Io.File.stdin().reader(io, &in_buffer);
+ const reader = &stdin.interface;
+
+ try writer.writeAll("zodd repl; statements end with '.', goals start with '?-', :quit exits\n");
+ while (true) {
+ try writer.writeAll("zodd> ");
+ try stdout.flush();
+ const raw = (try reader.takeDelimiter('\n')) orelse break;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ if (std.mem.eql(u8, line, ":quit") or std.mem.eql(u8, line, ":q")) break;
+
+ if (std.mem.startsWith(u8, line, "?-")) {
+ const normalized = try normalizeGoal(allocator, line);
+ defer allocator.free(normalized);
+ db.run(normalized) catch |err| {
+ reportDiagnostic(&db, normalized, err);
+ continue;
+ };
+ const goal = lastStoredGoal(&db).?;
+ var it = db.queryDemand(goal.name, goal.pattern[0..goal.arity]) catch |err| {
+ reportDiagnostic(&db, null, err);
+ continue;
+ };
+ defer it.deinit();
+ _ = try printRows(writer, &it);
+ } else {
+ const owned = try allocator.dupe(u8, line);
+ defer allocator.free(owned);
+ db.run(owned) catch |err| {
+ reportDiagnostic(&db, owned, err);
+ continue;
+ };
+ db.solve() catch |err| {
+ reportDiagnostic(&db, owned, err);
+ continue;
+ };
+ }
+ try stdout.flush();
+ }
+ try writer.writeAll("\n");
+ try stdout.flush();
+}
+
+test "normalizeGoal wraps bare goals and passes wrapped ones through" {
+ const allocator = std.testing.allocator;
+
+ const bare = try normalizeGoal(allocator, "path(1, X)");
+ defer allocator.free(bare);
+ try std.testing.expectEqualStrings("?- path(1, X).", bare);
+
+ const dotted = try normalizeGoal(allocator, "path(1, X).");
+ defer allocator.free(dotted);
+ try std.testing.expectEqualStrings("?- path(1, X).", dotted);
+
+ const wrapped = try normalizeGoal(allocator, "?- path(1, X).");
+ defer allocator.free(wrapped);
+ try std.testing.expectEqualStrings("?- path(1, X).", wrapped);
+
+ const padded = try normalizeGoal(allocator, " ?- path(1, X) ");
+ defer allocator.free(padded);
+ try std.testing.expectEqualStrings("?- path(1, X).", padded);
+}
+
+test "lineColumn locates offsets" {
+ const source = "abc\ndef\nghi";
+ try std.testing.expectEqual(Location{ .line = 1, .column = 1 }, lineColumn(source, 0));
+ try std.testing.expectEqual(Location{ .line = 1, .column = 3 }, lineColumn(source, 2));
+ try std.testing.expectEqual(Location{ .line = 2, .column = 1 }, lineColumn(source, 4));
+ try std.testing.expectEqual(Location{ .line = 3, .column = 2 }, lineColumn(source, 9));
+ try std.testing.expectEqual(Location{ .line = 3, .column = 4 }, lineColumn(source, 99));
+}
diff --git a/src/zodd/frontend/evaluator.zig b/src/zodd/frontend/evaluator.zig
index 8f1d6ad..f5d364b 100644
--- a/src/zodd/frontend/evaluator.zig
+++ b/src/zodd/frontend/evaluator.zig
@@ -12,6 +12,7 @@
//! rules) read only frozen relations and run once per stratum.
const std = @import("std");
+const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const Relation = @import("../relation.zig").Relation;
const Variable = @import("../variable.zig").Variable;
@@ -54,6 +55,11 @@ pub const Evaluator = struct {
results: std.AutoHashMapUnmanaged(ast.PredId, DynRelation),
/// When true, `solve` records a `Derivation` per derived tuple.
track_provenance: bool = false,
+ /// Worker threads for rule evaluation within a round: 1 evaluates
+ /// sequentially, 0 uses one thread per CPU. Values above 1 require a
+ /// thread-safe allocator. Ignored (sequential) on single-threaded
+ /// targets and while provenance is tracked.
+ parallelism: usize = 1,
/// First recorded derivation per derived tuple. First-wins keeps proofs
/// well founded: a derivation only cites tuples from earlier rounds.
provenance: std.AutoHashMapUnmanaged(ProvKey, Derivation) = .empty,
@@ -142,12 +148,7 @@ pub const Evaluator = struct {
/// `results` immediately; derived predicates keep their rows as seeds
/// for their stratum's fixed point.
fn loadFacts(self: *Evaluator, arena: Allocator) EvalError![]std.ArrayListUnmanaged(DynTuple) {
- const rows = try arena.alloc(std.ArrayListUnmanaged(DynTuple), self.program.preds.items.len);
- for (rows) |*list| list.* = .empty;
-
- for (self.program.facts.items) |fact| {
- try rows[fact.pred].append(arena, dyntuple.fromSlice(fact.row));
- }
+ const rows = try self.groupFactRows(arena);
for (self.program.preds.items, 0..) |info, pred| {
if (info.derived) continue;
@@ -170,6 +171,267 @@ pub const Evaluator = struct {
return rows;
}
+ /// Incrementally maintains a previous `solve` after base-fact changes.
+ /// Additions propagate as semi-naive deltas: every stratum resumes from
+ /// its frozen result, so unchanged tuples are never re-derived. A
+ /// stratum whose inputs saw deletions, or whose rules read changed
+ /// predicates through negation or aggregation, is recomputed from
+ /// scratch instead; strata unaffected by any change are not touched at
+ /// all. Results equal a full re-solve. Not compatible with provenance
+ /// tracking (the caller falls back to `solve`).
+ pub fn maintain(
+ self: *Evaluator,
+ stratum_count: u16,
+ max_iterations: ?usize,
+ added: []const ast.Fact,
+ deleted: []const ast.Fact,
+ ) EvalError!void {
+ var solve_arena = std.heap.ArenaAllocator.init(self.allocator);
+ defer solve_arena.deinit();
+ var round_arena = std.heap.ArenaAllocator.init(self.allocator);
+ defer round_arena.deinit();
+ const arena = solve_arena.allocator();
+
+ const pred_count = self.program.preds.items.len;
+ const plans = try arena.alloc(plan_mod.Plan, self.program.rules.items.len);
+ for (self.program.rules.items, 0..) |*rule, i| {
+ plans[i] = try plan_mod.compile(arena, rule);
+ }
+
+ // Per-predicate changes: added tuples (read at delta positions of
+ // downstream rules) and a deletion flag (forces recompute).
+ var add_rows = try arena.alloc(std.ArrayListUnmanaged(DynTuple), pred_count);
+ @memset(add_rows, .empty);
+ for (added) |fact| try add_rows[fact.pred].append(arena, dyntuple.fromSlice(fact.row));
+ var del_rows = try arena.alloc(std.ArrayListUnmanaged(DynTuple), pred_count);
+ @memset(del_rows, .empty);
+ for (deleted) |fact| try del_rows[fact.pred].append(arena, dyntuple.fromSlice(fact.row));
+
+ const deltas = try arena.alloc([]const DynTuple, pred_count);
+ @memset(deltas, &.{});
+ var has_del = try std.DynamicBitSetUnmanaged.initEmpty(arena, pred_count);
+
+ const seed_rows = try self.groupFactRows(arena);
+
+ // Rebuild changed base relations from the fact list, which stays
+ // the source of truth: retracting one of two identical facts must
+ // leave the tuple present, so set arithmetic on the old relation
+ // would be wrong.
+ for (self.program.preds.items, 0..) |info, pred_usize| {
+ const pred: ast.PredId = @intCast(pred_usize);
+ if (info.derived) continue;
+ if (add_rows[pred].items.len == 0 and del_rows[pred].items.len == 0) continue;
+
+ var old = if (self.results.fetchRemove(pred)) |entry| entry.value else DynRelation.empty(self.allocator);
+ var rebuilt = try DynRelation.fromSlice(self.allocator, seed_rows[pred].items);
+ errdefer rebuilt.deinit();
+
+ const diff = try diffRelations(arena, old.elements, rebuilt.elements);
+ deltas[pred] = diff.added;
+ if (diff.removed) has_del.set(pred);
+ old.deinit();
+ try self.results.put(self.allocator, pred, rebuilt);
+ }
+
+ var stratum: u16 = 0;
+ while (stratum < stratum_count) : (stratum += 1) {
+ var touched_add = false;
+ var touched_del = false;
+ var unsound_read = false;
+ for (self.program.rules.items) |rule| {
+ if (self.program.preds.items[rule.head.pred()].stratum != stratum) continue;
+ const head_pred = rule.head.pred();
+ if (add_rows[head_pred].items.len > 0) touched_add = true;
+ if (del_rows[head_pred].items.len > 0) touched_del = true;
+ for (rule.body) |literal| {
+ const changed_input = deltas[literal.atom.pred].len > 0;
+ if (changed_input) {
+ touched_add = true;
+ // Additions below a negation or an aggregate can
+ // remove tuples here; the delta path cannot express
+ // that.
+ if (literal.negated or rule.head == .aggregate) unsound_read = true;
+ }
+ if (has_del.isSet(literal.atom.pred)) touched_del = true;
+ }
+ }
+ if (!touched_add and !touched_del) continue;
+
+ if (touched_del or unsound_read) {
+ try self.recomputeStratum(stratum, seed_rows, plans, max_iterations, arena, &round_arena, deltas, &has_del);
+ } else {
+ try self.extendStratum(stratum, plans, max_iterations, arena, &round_arena, add_rows, deltas);
+ }
+ }
+ }
+
+ /// Recomputes one stratum from scratch and records how its predicates
+ /// changed for downstream strata.
+ fn recomputeStratum(
+ self: *Evaluator,
+ stratum: u16,
+ seed_rows: []std.ArrayListUnmanaged(DynTuple),
+ plans: []const plan_mod.Plan,
+ max_iterations: ?usize,
+ arena: Allocator,
+ round_arena: *std.heap.ArenaAllocator,
+ deltas: [][]const DynTuple,
+ has_del: *std.DynamicBitSetUnmanaged,
+ ) EvalError!void {
+ // Capture and drop the old relations of this stratum's predicates.
+ var old = std.AutoHashMapUnmanaged(ast.PredId, DynRelation).empty;
+ defer {
+ var it = old.valueIterator();
+ while (it.next()) |relation| relation.deinit();
+ old.deinit(self.allocator);
+ }
+ for (self.program.preds.items, 0..) |info, pred| {
+ if (!info.derived or info.stratum != stratum) continue;
+ if (self.results.fetchRemove(@intCast(pred))) |entry| {
+ try old.put(self.allocator, @intCast(pred), entry.value);
+ }
+ }
+
+ try self.solveStratum(stratum, seed_rows, plans, max_iterations, arena, round_arena);
+
+ for (self.program.preds.items, 0..) |info, pred_usize| {
+ const pred: ast.PredId = @intCast(pred_usize);
+ if (!info.derived or info.stratum != stratum) continue;
+ const old_elements: []const DynTuple = if (old.getPtr(pred)) |relation| relation.elements else &.{};
+ const new_elements: []const DynTuple = if (self.results.getPtr(pred)) |relation| relation.elements else &.{};
+ const diff = try diffRelations(arena, old_elements, new_elements);
+ deltas[pred] = diff.added;
+ if (diff.removed) has_del.set(pred);
+ }
+ }
+
+ /// Extends one stratum with pure additions: the frozen result seeds the
+ /// stable set, delta rules fire once per changed frozen input, and the
+ /// usual semi-naive loop propagates recursion. Only genuinely new
+ /// tuples are derived and recorded for downstream strata.
+ fn extendStratum(
+ self: *Evaluator,
+ stratum: u16,
+ plans: []const plan_mod.Plan,
+ max_iterations: ?usize,
+ arena: Allocator,
+ round_arena: *std.heap.ArenaAllocator,
+ add_rows: []std.ArrayListUnmanaged(DynTuple),
+ deltas: [][]const DynTuple,
+ ) EvalError!void {
+ var iter = Iteration(DynTuple).init(self.allocator, max_iterations);
+ defer iter.deinit();
+
+ var vars: std.AutoHashMapUnmanaged(ast.PredId, *DynVariable) = .empty;
+ defer vars.deinit(self.allocator);
+
+ // Old element copies for the post-freeze diff; the originals move
+ // into the variables.
+ var old_elements: std.AutoHashMapUnmanaged(ast.PredId, []const DynTuple) = .empty;
+ defer old_elements.deinit(self.allocator);
+
+ for (self.program.preds.items, 0..) |info, pred_usize| {
+ const pred: ast.PredId = @intCast(pred_usize);
+ if (!info.derived or info.stratum != stratum) continue;
+ const variable = try iter.variable();
+ if (self.results.fetchRemove(pred)) |entry| {
+ var relation = entry.value;
+ try old_elements.put(self.allocator, pred, try arena.dupe(DynTuple, relation.elements));
+ variable.seedStable(relation) catch |err| {
+ relation.deinit();
+ return err;
+ };
+ } else {
+ try old_elements.put(self.allocator, pred, &.{});
+ }
+ try variable.insertSlice(add_rows[pred].items);
+ try vars.put(self.allocator, pred, variable);
+ }
+ if (vars.count() == 0) return;
+
+ // Delta tasks against changed frozen inputs (lower strata and base
+ // facts), then the recursive tasks for the fixed-point loop.
+ var seed_tasks: std.ArrayListUnmanaged(Task) = .empty;
+ var loop_tasks: std.ArrayListUnmanaged(Task) = .empty;
+ for (self.program.rules.items, 0..) |rule, i| {
+ if (self.program.preds.items[rule.head.pred()].stratum != stratum) continue;
+ for (rule.body, 0..) |literal, lit_index| {
+ if (self.isDeltaPosition(literal, stratum)) {
+ try loop_tasks.append(arena, .{ .rule_index = @intCast(i), .delta_lit = @intCast(lit_index) });
+ } else if (!literal.negated and deltas[literal.atom.pred].len > 0) {
+ try seed_tasks.append(arena, .{ .rule_index = @intCast(i), .delta_lit = @intCast(lit_index) });
+ }
+ }
+ }
+
+ var ctx = Context{
+ .evaluator = self,
+ .vars = &vars,
+ .stratum = stratum,
+ .arena = round_arena.allocator(),
+ .full_cache = .empty,
+ .deltas = deltas,
+ };
+
+ try self.evalTasks(&ctx, seed_tasks.items, plans);
+ _ = round_arena.reset(.retain_capacity);
+
+ while (try iter.changed()) {
+ ctx.full_cache = .empty;
+ try self.evalTasks(&ctx, loop_tasks.items, plans);
+ _ = round_arena.reset(.retain_capacity);
+ }
+
+ var var_it = vars.iterator();
+ while (var_it.next()) |entry| {
+ var relation = try entry.value_ptr.*.complete();
+ errdefer relation.deinit();
+ try self.results.put(self.allocator, entry.key_ptr.*, relation);
+ const diff = try diffRelations(arena, old_elements.get(entry.key_ptr.*).?, relation.elements);
+ deltas[entry.key_ptr.*] = diff.added;
+ }
+ }
+
+ const Diff = struct { added: []const DynTuple, removed: bool };
+
+ /// Sorted-set walk producing the tuples of `new` missing from `old` and
+ /// whether `old` holds tuples missing from `new`.
+ fn diffRelations(arena: Allocator, old: []const DynTuple, new: []const DynTuple) Allocator.Error!Diff {
+ var added: std.ArrayListUnmanaged(DynTuple) = .empty;
+ var removed = false;
+ var i: usize = 0;
+ var j: usize = 0;
+ while (i < old.len and j < new.len) {
+ switch (DynRelation.compareTuples(old[i], new[j])) {
+ .lt => {
+ removed = true;
+ i += 1;
+ },
+ .gt => {
+ try added.append(arena, new[j]);
+ j += 1;
+ },
+ .eq => {
+ i += 1;
+ j += 1;
+ },
+ }
+ }
+ if (i < old.len) removed = true;
+ try added.appendSlice(arena, new[j..]);
+ return .{ .added = added.items, .removed = removed };
+ }
+
+ /// Groups the program's fact rows by predicate.
+ fn groupFactRows(self: *Evaluator, arena: Allocator) EvalError![]std.ArrayListUnmanaged(DynTuple) {
+ const rows = try arena.alloc(std.ArrayListUnmanaged(DynTuple), self.program.preds.items.len);
+ for (rows) |*list| list.* = .empty;
+ for (self.program.facts.items) |fact| {
+ try rows[fact.pred].append(arena, dyntuple.fromSlice(fact.row));
+ }
+ return rows;
+ }
+
fn solveStratum(
self: *Evaluator,
stratum: u16,
@@ -194,17 +456,23 @@ pub const Evaluator = struct {
}
if (vars.count() == 0) return;
- // Split this stratum's rules: non-recursive ones read only frozen
- // relations and run once; recursive ones run per round, once per
- // same-stratum body atom holding the delta.
- var once_rules: std.ArrayListUnmanaged(u32) = .empty;
- var delta_rules: std.ArrayListUnmanaged(u32) = .empty;
+ // Split this stratum's rule evaluations into tasks: non-recursive
+ // rules read only frozen relations and run once; recursive rules
+ // run per round, once per same-stratum body atom holding the delta.
+ var once_tasks: std.ArrayListUnmanaged(Task) = .empty;
+ var delta_tasks: std.ArrayListUnmanaged(Task) = .empty;
for (self.program.rules.items, 0..) |rule, i| {
if (self.program.preds.items[rule.head.pred()].stratum != stratum) continue;
if (self.countDeltaPositions(&rule, stratum) == 0) {
- try once_rules.append(solve_arena, @intCast(i));
+ try once_tasks.append(solve_arena, .{ .rule_index = @intCast(i), .delta_lit = null });
} else {
- try delta_rules.append(solve_arena, @intCast(i));
+ for (rule.body, 0..) |literal, lit_index| {
+ if (!self.isDeltaPosition(literal, stratum)) continue;
+ try delta_tasks.append(solve_arena, .{
+ .rule_index = @intCast(i),
+ .delta_lit = @intCast(lit_index),
+ });
+ }
}
}
@@ -216,20 +484,12 @@ pub const Evaluator = struct {
.full_cache = .empty,
};
- for (once_rules.items) |rule_index| {
- try self.evalRule(&ctx, rule_index, &plans[rule_index], null);
- }
+ try self.evalTasks(&ctx, once_tasks.items, plans);
_ = round_arena.reset(.retain_capacity);
while (try iter.changed()) {
ctx.full_cache = .empty;
- for (delta_rules.items) |rule_index| {
- const rule = &self.program.rules.items[rule_index];
- for (rule.body, 0..) |literal, lit_index| {
- if (!self.isDeltaPosition(literal, stratum)) continue;
- try self.evalRule(&ctx, rule_index, &plans[rule_index], @intCast(lit_index));
- }
- }
+ try self.evalTasks(&ctx, delta_tasks.items, plans);
_ = round_arena.reset(.retain_capacity);
}
@@ -242,6 +502,124 @@ pub const Evaluator = struct {
}
}
+ /// One rule evaluation within a round.
+ const Task = struct { rule_index: u32, delta_lit: ?u16 };
+
+ /// Threading is unavailable on single-threaded builds and freestanding
+ /// targets like the Wasm module.
+ const can_thread = !builtin.single_threaded and builtin.target.os.tag != .freestanding;
+
+ /// Evaluates one round's tasks, in parallel when `parallelism` allows.
+ /// Parallel workers only append to head variables (serialized by a
+ /// mutex), so the derived tuple sets are identical to sequential
+ /// evaluation; provenance stays sequential because first-wins recording
+ /// depends on evaluation order.
+ fn evalTasks(
+ self: *Evaluator,
+ ctx: *Context,
+ tasks: []const Task,
+ plans: []const plan_mod.Plan,
+ ) EvalError!void {
+ if (comptime can_thread) {
+ const threads = if (self.parallelism == 0)
+ (std.Thread.getCpuCount() catch 1)
+ else
+ self.parallelism;
+ if (threads > 1 and tasks.len > 1 and !self.track_provenance) {
+ return self.evalTasksParallel(ctx, tasks, plans, @min(threads, tasks.len));
+ }
+ }
+
+ for (tasks) |task| {
+ try self.evalRule(ctx, task.rule_index, &plans[task.rule_index], task.delta_lit);
+ }
+ }
+
+ fn evalTasksParallel(
+ self: *Evaluator,
+ ctx: *Context,
+ tasks: []const Task,
+ plans: []const plan_mod.Plan,
+ threads: usize,
+ ) EvalError!void {
+ // Merge every same-stratum variable up front so workers never write
+ // the shared cache.
+ var var_it = ctx.vars.iterator();
+ while (var_it.next()) |entry| {
+ _ = try ctx.full(entry.key_ptr.*, entry.value_ptr.*);
+ }
+
+ // Per-task output lists; their buffers live in the worker arenas,
+ // which stay alive until the coordinator has inserted everything.
+ const outputs = try ctx.arena.alloc(std.ArrayListUnmanaged(DynTuple), tasks.len);
+ @memset(outputs, .empty);
+ const worker_arenas = try ctx.arena.alloc(std.heap.ArenaAllocator, threads);
+ for (worker_arenas) |*arena| arena.* = std.heap.ArenaAllocator.init(self.allocator);
+ defer for (worker_arenas) |*arena| arena.deinit();
+
+ const handles = try ctx.arena.alloc(std.Thread, threads);
+ const failures = try ctx.arena.alloc(?EvalError, threads);
+ @memset(failures, null);
+
+ var spawned: usize = 0;
+ for (0..threads) |i| {
+ handles[i] = std.Thread.spawn(.{}, evalWorker, .{
+ self, ctx, tasks, plans, outputs, &worker_arenas[i], i, threads, &failures[i],
+ }) catch break;
+ spawned += 1;
+ }
+ // Strides of threads that failed to spawn run on this one.
+ for (spawned..threads) |i| {
+ evalWorker(self, ctx, tasks, plans, outputs, &worker_arenas[i], i, threads, &failures[i]);
+ }
+ for (handles[0..spawned]) |handle| handle.join();
+
+ for (failures) |failure| {
+ if (failure) |err| return err;
+ }
+
+ // Insert in task order, on this thread, so evaluation matches the
+ // sequential path exactly.
+ for (tasks, outputs) |task, output| {
+ if (output.items.len == 0) continue;
+ const rule = &self.program.rules.items[task.rule_index];
+ const head_var = ctx.vars.get(rule.head.pred()).?;
+ try head_var.insertSlice(output.items);
+ }
+ }
+
+ fn evalWorker(
+ self: *Evaluator,
+ shared: *const Context,
+ tasks: []const Task,
+ plans: []const plan_mod.Plan,
+ outputs: []std.ArrayListUnmanaged(DynTuple),
+ arena: *std.heap.ArenaAllocator,
+ start: usize,
+ stride: usize,
+ failure: *?EvalError,
+ ) void {
+ var ctx = Context{
+ .evaluator = self,
+ .vars = shared.vars,
+ .stratum = shared.stratum,
+ .arena = arena.allocator(),
+ // Shared and fully precomputed: reads only.
+ .full_cache = shared.full_cache,
+ .deltas = shared.deltas,
+ };
+
+ var i = start;
+ while (i < tasks.len) : (i += stride) {
+ const task = tasks[i];
+ ctx.collect = &outputs[i];
+ self.evalRule(&ctx, task.rule_index, &plans[task.rule_index], task.delta_lit) catch |err| {
+ failure.* = err;
+ return;
+ };
+ }
+ }
+
fn isDeltaPosition(self: *const Evaluator, literal: ast.Literal, stratum: u16) bool {
if (literal.negated) return false;
const info = self.program.preds.items[literal.atom.pred];
@@ -263,16 +641,30 @@ pub const Evaluator = struct {
arena: Allocator,
/// Per-round cache of merged stable+recent contents per predicate.
full_cache: std.AutoHashMapUnmanaged(ast.PredId, []const DynTuple),
+ /// When set, derived head tuples are collected here (allocated from
+ /// `arena`) instead of inserted into the head variable; parallel
+ /// workers use this so all inserts happen on the coordinating
+ /// thread, in task order.
+ collect: ?*std.ArrayListUnmanaged(DynTuple) = null,
+
+ /// Per-predicate added tuples of frozen inputs (lower strata and
+ /// base facts), read at delta positions during incremental
+ /// maintenance; null during a full solve.
+ deltas: ?[]const []const DynTuple = null,
/// The tuples a body literal reads this round.
fn source(ctx: *Context, literal: ast.Literal, delta_lit: ?u16, lit_index: u16) Allocator.Error![]const DynTuple {
const pred = literal.atom.pred;
+ const is_delta = delta_lit != null and delta_lit.? == lit_index;
if (ctx.vars.get(pred)) |variable| {
- if (delta_lit != null and delta_lit.? == lit_index) {
+ if (is_delta) {
return variable.recent.elements;
}
return ctx.full(pred, variable);
}
+ if (is_delta) {
+ if (ctx.deltas) |deltas| return deltas[pred];
+ }
if (ctx.evaluator.results.getPtr(pred)) |relation| {
return relation.elements;
}
@@ -413,8 +805,12 @@ pub const Evaluator = struct {
}
if (head_tuples.items.len > 0) {
- const head_var = ctx.vars.get(rule.head.pred()).?;
- try head_var.insertSlice(head_tuples.items);
+ if (ctx.collect) |out| {
+ try out.appendSlice(ctx.arena, head_tuples.items);
+ } else {
+ const head_var = ctx.vars.get(rule.head.pred()).?;
+ try head_var.insertSlice(head_tuples.items);
+ }
}
}
diff --git a/src/zodd/frontend/program.zig b/src/zodd/frontend/program.zig
index 6c91ef8..c603aea 100644
--- a/src/zodd/frontend/program.zig
+++ b/src/zodd/frontend/program.zig
@@ -77,6 +77,19 @@ pub const Database = struct {
/// When true, `solve` records how each derived tuple was first obtained,
/// enabling `explain`. Costs one map entry per derived tuple.
track_provenance: bool = false,
+ /// Worker threads for rule evaluation within a fixed-point round: 1
+ /// (the default) evaluates sequentially, 0 uses one thread per CPU.
+ /// Values above 1 require a thread-safe allocator. Results are
+ /// identical to sequential evaluation. Ignored on single-threaded
+ /// targets and while `track_provenance` is set.
+ parallelism: usize = 1,
+ /// Facts and rules covered by the current `evaluator`; facts appended
+ /// past `solved_facts` are pending additions for `update`.
+ solved_facts: usize = 0,
+ solved_rules: usize = 0,
+ /// Facts retracted since the last solve; rows live in the program
+ /// arena.
+ pending_deletes: std.ArrayListUnmanaged(ast.Fact) = .empty,
pub fn init(allocator: Allocator) Database {
return Database{
@@ -89,6 +102,7 @@ pub const Database = struct {
pub fn deinit(self: *Database) void {
self.clearDemand();
if (self.evaluator) |*evaluator| evaluator.deinit();
+ self.pending_deletes.deinit(self.allocator);
self.program.deinit();
self.interner.deinit();
}
@@ -107,11 +121,11 @@ pub const Database = struct {
try b.factValues(pred, row);
}
- /// Removes one occurrence of a previously added ground fact and
- /// invalidates computed results; the next solve or query recomputes
- /// from scratch. Returns true if a matching fact was removed. Only
- /// base facts can be retracted; derived tuples disappear when the
- /// facts deriving them do.
+ /// Removes one occurrence of a previously added ground fact. Returns
+ /// true if a matching fact was removed. Computed results are
+ /// maintained on the next `update`, `query`, or `solve`. Only base
+ /// facts can be retracted; derived tuples disappear when the facts
+ /// deriving them do.
pub fn retract(self: *Database, pred_name: []const u8, row: []const Value) FrontendError!bool {
const name_atom = self.interner.find(pred_name) orelse return error.UnknownPredicate;
const pred = self.program.findPredicate(name_atom) orelse return error.UnknownPredicate;
@@ -130,15 +144,63 @@ pub const Database = struct {
for (self.program.facts.items, 0..) |fact, i| {
if (fact.pred != pred) continue;
if (!std.mem.eql(u64, fact.row, encoded[0..row.len])) continue;
- _ = self.program.facts.swapRemove(i);
- self.clearDemand();
- if (self.evaluator) |*evaluator| evaluator.deinit();
- self.evaluator = null;
+ // Order-preserving removal keeps facts past `solved_facts` a
+ // contiguous suffix of pending additions.
+ const removed = self.program.facts.orderedRemove(i);
+ if (i < self.solved_facts) {
+ // A solved fact: its retraction must be maintained.
+ self.solved_facts -= 1;
+ if (self.evaluator != null) {
+ try self.pending_deletes.append(self.allocator, removed);
+ }
+ }
return true;
}
return false;
}
+ /// Incrementally maintains computed results after `addFact` and
+ /// `retract`: strata unaffected by the changes are left untouched, and
+ /// additions propagate as semi-naive deltas without re-deriving
+ /// existing tuples. Falls back to a full `solve` when rules changed,
+ /// no solve exists yet, or provenance is tracked.
+ pub fn update(self: *Database) FrontendError!void {
+ if (self.evaluator == null or self.track_provenance) return self.solve();
+ if (self.program.rules.items.len != self.solved_rules) return self.solve();
+
+ const added = self.program.facts.items[self.solved_facts..];
+ if (added.len == 0 and self.pending_deletes.items.len == 0) return;
+
+ self.diagnostic = .{};
+ const analysis = try analyze_mod.analyze(&self.program, &self.diagnostic);
+ if (analysis.recursive_assignment and self.max_iterations == null) {
+ return error.IterationLimitRequired;
+ }
+
+ self.clearDemand();
+ self.evaluator.?.parallelism = self.parallelism;
+ self.evaluator.?.maintain(
+ analysis.stratum_count,
+ self.max_iterations,
+ added,
+ self.pending_deletes.items,
+ ) catch |err| {
+ // Results may be partially maintained; drop them so the next
+ // query re-solves instead of serving inconsistent relations.
+ self.evaluator.?.deinit();
+ self.evaluator = null;
+ return err;
+ };
+ self.solved_facts = self.program.facts.items.len;
+ self.pending_deletes.clearRetainingCapacity();
+ }
+
+ fn hasPendingChanges(self: *const Database) bool {
+ return self.program.facts.items.len != self.solved_facts or
+ self.pending_deletes.items.len > 0 or
+ self.program.rules.items.len != self.solved_rules;
+ }
+
/// Parses Datalog source, appending its facts, rules, and queries to
/// the program. On error, `lastDiagnostic` has the details.
pub fn run(self: *Database, source: []const u8) FrontendError!void {
@@ -173,14 +235,23 @@ pub const Database = struct {
self.evaluator = null;
}
self.evaluator.?.track_provenance = self.track_provenance;
+ self.evaluator.?.parallelism = self.parallelism;
try self.evaluator.?.solve(analysis.stratum_count, self.max_iterations);
+ self.solved_facts = self.program.facts.items.len;
+ self.solved_rules = self.program.rules.items.len;
+ self.pending_deletes.clearRetainingCapacity();
}
/// Queries a predicate with a partial binding: null columns are free.
- /// Solves first if needed. The iterator borrows the database; it is
- /// invalidated by the next `solve` or `deinit`.
+ /// Solves first if needed, and incrementally maintains results when
+ /// facts changed since the last solve. The iterator borrows the
+ /// database; it is invalidated by the next solve, update, or deinit.
pub fn query(self: *Database, pred_name: []const u8, pattern: []const ?Value) FrontendError!RowIterator {
- if (self.evaluator == null) try self.solve();
+ if (self.evaluator == null) {
+ try self.solve();
+ } else if (self.hasPendingChanges()) {
+ try self.update();
+ }
const name_atom = self.interner.find(pred_name) orelse return error.UnknownPredicate;
const pred = self.program.findPredicate(name_atom) orelse return error.UnknownPredicate;
@@ -249,6 +320,7 @@ pub const Database = struct {
errdefer self.clearDemand();
const demand_analysis = try analyze_mod.analyze(&self.demand_program.?, null);
self.demand_evaluator = evaluator_mod.Evaluator.init(self.allocator, &self.demand_program.?);
+ self.demand_evaluator.?.parallelism = self.parallelism;
try self.demand_evaluator.?.solve(demand_analysis.stratum_count, self.max_iterations);
return self.rowsOf(&self.demand_evaluator.?, demand.query_pred, encoded, info.arity);
@@ -323,7 +395,13 @@ pub const Database = struct {
row: []const Value,
max_depth: ?usize,
) ExplainError!void {
- if (self.evaluator == null) try self.solve();
+ if (self.evaluator == null) {
+ try self.solve();
+ } else if (self.hasPendingChanges()) {
+ // Provenance forces `update` into a full re-solve, keeping the
+ // recorded derivations consistent with the relations.
+ try self.update();
+ }
const evaluator = &self.evaluator.?;
if (!evaluator.track_provenance) return error.ProvenanceNotTracked;
@@ -847,3 +925,34 @@ test "Database: queryDemand computes only the demanded slice" {
}
try std.testing.expect(derived_tuples < 5);
}
+
+test "Database: update leaves unaffected strata untouched" {
+ const allocator = std.testing.allocator;
+
+ var db = Database.init(allocator);
+ defer db.deinit();
+
+ // Two independent derivations plus an aggregate stratum; adding an
+ // edge must not touch the `owns` predicate's stratum.
+ try db.run(
+ \\edge(1, 2). edge(2, 3).
+ \\thing("a", 1).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ \\owns(O, T) :- thing(O, T).
+ \\fan(N, count(M)) :- path(N, M).
+ );
+ try db.solve();
+
+ const owns_pred = db.program.findPredicate(db.interner.find("owns").?).?;
+ const path_pred = db.program.findPredicate(db.interner.find("path").?).?;
+ const owns_before = db.evaluator.?.relationOf(owns_pred).?.elements.ptr;
+ const path_before_len = db.evaluator.?.relationOf(path_pred).?.len();
+
+ try db.addFact("edge", &.{ .{ .int = 3 }, .{ .int = 4 } });
+ try db.update();
+
+ // `owns` kept its exact storage; `path` grew by the delta.
+ try std.testing.expectEqual(owns_before, db.evaluator.?.relationOf(owns_pred).?.elements.ptr);
+ try std.testing.expectEqual(path_before_len + 3, db.evaluator.?.relationOf(path_pred).?.len());
+}
diff --git a/src/zodd/variable.zig b/src/zodd/variable.zig
index 483076d..dd35a4b 100644
--- a/src/zodd/variable.zig
+++ b/src/zodd/variable.zig
@@ -73,6 +73,14 @@ pub fn Variable(comptime Tuple: type) type {
try self.to_add.append(self.allocator, relation);
}
+ /// Seeds the variable with an already-computed relation as stable
+ /// data: unlike `insert`, the tuples never enter the recent delta,
+ /// so a fixed point can resume from a previous result and re-derive
+ /// nothing. Takes ownership of the relation's storage.
+ pub fn seedStable(self: *Self, relation: Rel) Allocator.Error!void {
+ try self.stable.append(self.allocator, relation);
+ }
+
/// Inserts a slice of tuples into the variable. The tuples are copied;
/// the caller retains ownership of `tuples`.
pub fn insertSlice(self: *Self, tuples: []const Tuple) Allocator.Error!void {
@@ -456,3 +464,25 @@ test "Variable: complete folds to_add when nothing has been processed yet" {
try std.testing.expectEqualSlices(u32, &[_]u32{ 1, 2, 3 }, result.elements);
}
+
+test "Variable: seedStable resumes without re-deriving the seed" {
+ const allocator = std.testing.allocator;
+ const Tuple = struct { u32 };
+
+ var v = Variable(Tuple).init(allocator);
+ defer v.deinit();
+
+ const seed = try Relation(Tuple).fromSlice(allocator, &[_]Tuple{ .{1}, .{2} });
+ try v.seedStable(seed);
+ try v.insertSlice(&[_]Tuple{ .{2}, .{3} });
+
+ // Only the genuinely new tuple surfaces as recent.
+ try std.testing.expect(try v.changed());
+ try std.testing.expectEqual(@as(usize, 1), v.recent.len());
+ try std.testing.expectEqual(@as(u32, 3), v.recent.elements[0][0]);
+ try std.testing.expect(!(try v.changed()));
+
+ var complete = try v.complete();
+ defer complete.deinit();
+ try std.testing.expectEqual(@as(usize, 3), complete.len());
+}
diff --git a/tests/frontend_tests.zig b/tests/frontend_tests.zig
index 70e7fc1..110ca0e 100644
--- a/tests/frontend_tests.zig
+++ b/tests/frontend_tests.zig
@@ -831,6 +831,144 @@ test "frontend: queryDemand evaluates only the demanded cone" {
// only the demanded slice of the closure.
}
+test "frontend: parallel evaluation matches sequential results" {
+ const allocator = testing.allocator;
+
+ // Mutually recursive rules across two predicates, negation and an
+ // aggregate in later strata, and arithmetic: several rule evaluations
+ // per round, so parallel workers get real overlap.
+ var source: std.ArrayListUnmanaged(u8) = .empty;
+ defer source.deinit(allocator);
+ var seed: u64 = 0x9e3779b97f4a7c15;
+ for (0..120) |_| {
+ seed = seed *% 6364136223846793005 +% 1442695040888963407;
+ const a = (seed >> 33) % 30;
+ const b = (seed >> 13) % 30;
+ try source.print(allocator, "edge({d}, {d}).\n", .{ a, b });
+ }
+ try source.appendSlice(allocator,
+ \\blocked(7). blocked(13).
+ \\even(X, Y) :- edge(X, Y).
+ \\even(X, Z) :- odd(X, Y), edge(Y, Z).
+ \\odd(X, Y) :- edge(X, Y).
+ \\odd(X, Z) :- even(X, Y), edge(Y, Z), X != Z.
+ \\open(X, Y) :- even(X, Y), not blocked(Y).
+ \\fan(N, count(M)) :- open(N, M).
+ \\big(N, C2) :- fan(N, C), C2 is C * 2, C2 > 2.
+ );
+
+ var sequential = zodd.Database.init(allocator);
+ defer sequential.deinit();
+ try sequential.run(source.items);
+ try sequential.solve();
+
+ var parallel = zodd.Database.init(allocator);
+ defer parallel.deinit();
+ parallel.parallelism = 4;
+ try parallel.run(source.items);
+ try parallel.solve();
+
+ for ([_][]const u8{ "even", "odd", "open", "fan", "big" }) |pred| {
+ var seq_rows: std.ArrayListUnmanaged([2]u64) = .empty;
+ defer seq_rows.deinit(allocator);
+ var seq_it = try sequential.query(pred, &.{ null, null });
+ defer seq_it.deinit();
+ while (seq_it.next()) |row| {
+ try seq_rows.append(allocator, .{ row.get(0).int, row.get(1).int });
+ }
+
+ var par_it = try parallel.query(pred, &.{ null, null });
+ defer par_it.deinit();
+ var i: usize = 0;
+ while (par_it.next()) |row| : (i += 1) {
+ try testing.expect(i < seq_rows.items.len);
+ try testing.expectEqual(seq_rows.items[i][0], row.get(0).int);
+ try testing.expectEqual(seq_rows.items[i][1], row.get(1).int);
+ }
+ try testing.expectEqual(seq_rows.items.len, i);
+ try testing.expect(seq_rows.items.len > 0);
+ }
+}
+
+test "frontend: facts added after solve become visible to queries" {
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\edge(1, 2). edge(2, 3).
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ );
+ try db.solve();
+
+ try db.addFact("edge", &.{ .{ .int = 3 }, .{ .int = 4 } });
+
+ // The new edge extends the closure: 3 old paths plus (3,4), (2,4),
+ // and (1,4).
+ var it = try db.query("path", &.{ null, null });
+ defer it.deinit();
+ var count: usize = 0;
+ while (it.next()) |_| count += 1;
+ try testing.expectEqual(@as(usize, 6), count);
+}
+
+test "frontend: update maintains mixed additions and retractions" {
+ const allocator = testing.allocator;
+
+ const rules =
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ \\reachable(Y) :- path(1, Y).
+ \\isolated(X) :- node(X), not reachable(X).
+ \\fan(N, count(M)) :- path(N, M).
+ ;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(rules);
+ try db.run("node(1). node(2). node(3). node(4). node(5).");
+ try db.run("edge(1, 2). edge(2, 3). edge(4, 5).");
+ try db.solve();
+
+ // A batch of changes: connect 3 to 4, drop 4 to 5, add a node.
+ try db.addFact("edge", &.{ .{ .int = 3 }, .{ .int = 4 } });
+ try testing.expect(try db.retract("edge", &.{ .{ .int = 4 }, .{ .int = 5 } }));
+ try db.addFact("node", &.{.{ .int = 6 }});
+ try db.update();
+
+ // A fresh database with the same final facts must agree on every
+ // derived predicate.
+ var fresh = zodd.Database.init(allocator);
+ defer fresh.deinit();
+ try fresh.run(rules);
+ try fresh.run("node(1). node(2). node(3). node(4). node(5). node(6).");
+ try fresh.run("edge(1, 2). edge(2, 3). edge(3, 4).");
+ try fresh.solve();
+
+ const preds = [_]struct { name: []const u8, arity: usize }{
+ .{ .name = "path", .arity = 2 },
+ .{ .name = "reachable", .arity = 1 },
+ .{ .name = "isolated", .arity = 1 },
+ .{ .name = "fan", .arity = 2 },
+ };
+ for (preds) |pred| {
+ var pattern: [2]?zodd.Value = .{ null, null };
+ var expect_it = try fresh.query(pred.name, pattern[0..pred.arity]);
+ defer expect_it.deinit();
+ var got_it = try db.query(pred.name, pattern[0..pred.arity]);
+ defer got_it.deinit();
+ while (expect_it.next()) |want| {
+ const got = got_it.next() orelse return error.TestUnexpectedResult;
+ for (0..pred.arity) |i| {
+ try testing.expectEqual(want.get(i).int, got.get(i).int);
+ }
+ }
+ try testing.expect(got_it.next() == null);
+ }
+}
+
test "frontend: stored queries parse and survive analysis" {
const allocator = testing.allocator;
diff --git a/tests/incremental_tests.zig b/tests/incremental_tests.zig
index f0eb728..e1a691d 100644
--- a/tests/incremental_tests.zig
+++ b/tests/incremental_tests.zig
@@ -194,3 +194,73 @@ test "incremental maintenance: iteration reset with multiple variables" {
try testing.expectEqual(@as(usize, 4), v1.totalLen());
try testing.expectEqual(@as(usize, 2), v2.totalLen());
}
+
+test "incremental maintenance: interleaved updates match fresh solves" {
+ const allocator = testing.allocator;
+
+ const rules =
+ \\path(X, Y) :- edge(X, Y).
+ \\path(X, Z) :- path(X, Y), edge(Y, Z).
+ \\dead(X) :- node(X), not alive(X).
+ \\alive(X) :- path(_, X).
+ \\load(N, count(M)) :- path(N, M).
+ ;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+ try db.run(rules);
+ for (0..12) |i| {
+ try db.addFact("node", &.{.{ .int = i }});
+ }
+ try db.solve();
+
+ // A deterministic pseudo-random walk of additions and retractions;
+ // after each step the maintained database must agree with a fresh one
+ // built from the same fact set.
+ var live_edges: std.ArrayListUnmanaged([2]u64) = .empty;
+ defer live_edges.deinit(allocator);
+ var seed: u64 = 0x853c49e6748fea9b;
+ for (0..40) |_| {
+ seed = seed *% 6364136223846793005 +% 1442695040888963407;
+ const a = (seed >> 33) % 12;
+ const b = (seed >> 13) % 12;
+ const drop = live_edges.items.len > 4 and (seed >> 3) % 3 == 0;
+ if (drop) {
+ const victim = live_edges.swapRemove((seed >> 23) % live_edges.items.len);
+ try testing.expect(try db.retract("edge", &.{ .{ .int = victim[0] }, .{ .int = victim[1] } }));
+ } else {
+ try db.addFact("edge", &.{ .{ .int = a }, .{ .int = b } });
+ try live_edges.append(allocator, .{ a, b });
+ }
+
+ var fresh = zodd.Database.init(allocator);
+ defer fresh.deinit();
+ try fresh.run(rules);
+ for (0..12) |i| {
+ try fresh.addFact("node", &.{.{ .int = i }});
+ }
+ for (live_edges.items) |edge| {
+ try fresh.addFact("edge", &.{ .{ .int = edge[0] }, .{ .int = edge[1] } });
+ }
+ try fresh.solve();
+
+ for ([_]struct { name: []const u8, arity: usize }{
+ .{ .name = "path", .arity = 2 },
+ .{ .name = "dead", .arity = 1 },
+ .{ .name = "load", .arity = 2 },
+ }) |pred| {
+ var pattern: [2]?zodd.Value = .{ null, null };
+ var want_it = try fresh.query(pred.name, pattern[0..pred.arity]);
+ defer want_it.deinit();
+ var got_it = try db.query(pred.name, pattern[0..pred.arity]);
+ defer got_it.deinit();
+ while (want_it.next()) |want| {
+ const got = got_it.next() orelse return error.TestUnexpectedResult;
+ for (0..pred.arity) |col| {
+ try testing.expectEqual(want.get(col).int, got.get(col).int);
+ }
+ }
+ try testing.expect(got_it.next() == null);
+ }
+ }
+}
From 391e81c19d5738f84a402a8ea9e4c5082f34b808 Mon Sep 17 00:00:00 2001
From: Hassan Abedi
Date: Sat, 1 Aug 2026 13:37:29 +0200
Subject: [PATCH 4/5] Add differential testing support to project
---
.github/workflows/tests.yml | 20 +++
AGENTS.md | 3 +
Makefile | 7 +-
ROADMAP.md | 1 +
pyproject.toml | 5 +-
src/zodd/frontend/magic.zig | 21 ++-
tests/differential/difftest.py | 279 +++++++++++++++++++++++++++++++++
tests/frontend_tests.zig | 26 +++
uv.lock | 198 +++++++++++++++++++++++
9 files changed, 556 insertions(+), 4 deletions(-)
create mode 100644 tests/differential/difftest.py
create mode 100644 uv.lock
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4369363..abd492c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -61,3 +61,23 @@ jobs:
zig build wasm
cp zig-out/web/zodd.wasm web/zodd.wasm
node web/smoke_test.mjs
+
+ differential:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install Zig 0.16.0
+ run: |
+ curl -sSfL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ
+ echo "$PWD/zig-x86_64-linux-0.16.0" >> "$GITHUB_PATH"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Build the Zodd CLI
+ run: zig build cli
+
+ - name: Run differential tests against Clingo
+ run: uv run tests/differential/difftest.py --runs 500
diff --git a/AGENTS.md b/AGENTS.md
index 9bb9b28..dab831e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,6 +47,8 @@ Priorities, in order:
plans and provenance proof trees; `magic.zig` builds the demand-transformed (magic sets) program behind `Database.queryDemand`.
- `src/cli/main.zig`: The `zodd` CLI executable (`run`, `query`, `plan`, `explain`, and `repl` subcommands), built via `zig build cli`.
- `tests/`: Non-unit tests (`integration_tests.zig`, `regression_tests.zig`, `property_tests.zig`, `incremental_tests.zig`, `frontend_tests.zig`).
+- `tests/differential/difftest.py`: Differential testing against Clingo; random stratified programs evaluated by both engines must
+ agree. Run via `make diff-test` (needs `uv`; the Clingo dependency is declared in the root `pyproject.toml` and pinned by `uv.lock`).
- `web/`: Web frontend. `zodd_wasm.zig` is the Wasm wrapper built by `zig build wasm`; `index.html`, `main.js`, and `style.css` are the UI;
`smoke_test.mjs` is the Node.js smoke test run by `make web-test`.
- `examples/`: Self-contained example programs (`e1_network_reachability.zig` through `e8_comparison_filters.zig`) built as executables via
@@ -114,6 +116,7 @@ Run the relevant targets for any change:
| Examples | `make example` | Builds and runs every example under `examples/` |
| Single example | `make example EXAMPLE=e1_network_reachability` | Runs one example program |
| Docs | `make docs` | Generates API docs into `docs/api` |
+| Differential | `make diff-test` | Compares Zodd against Clingo on random programs (needs `uv`) |
| Everything | `make all` | Runs `build`, `test`, `lint`, and `docs` |
## First Contribution Flow
diff --git a/Makefile b/Makefile
index 7c455c6..6469ee9 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ SHELL := /usr/bin/env bash
################################################################################
.PHONY: all build rebuild example test lint format docs docs-serve clean install-deps release help coverage \
- setup-hooks test-hooks web web-serve web-test cli
+ setup-hooks test-hooks web web-serve web-test cli diff-test
.DEFAULT_GOAL := help
help: ## Show the help messages for all targets
@@ -72,6 +72,11 @@ cli: ## Build the zodd CLI into zig-out/bin
@echo "Building the zodd CLI..."
$(ZIG) build cli $(BUILD_OPTS)
+DIFF_RUNS ?= 200
+
+diff-test: cli ## Differential-test zodd against clingo via uv (DIFF_RUNS=200)
+ uv run tests/differential/difftest.py --runs $(DIFF_RUNS)
+
web: ## Build the web frontend Wasm module and stage it under `web/`
@echo "Building the web frontend Wasm module..."
$(ZIG) build wasm
diff --git a/ROADMAP.md b/ROADMAP.md
index e3038d7..4803637 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -44,4 +44,5 @@ This document lists the completed and planned features for Zodd.
- [x] Unit tests in each module
- [x] Integration, regression, property-based tests, etc. in `tests` directory
+- [x] Differential testing against clingo (`make diff-test`; random stratified programs must agree between the engines)
- [ ] Benchmarks
diff --git a/pyproject.toml b/pyproject.toml
index 1e41cad..4826a1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,6 +5,7 @@ description = "The Python environment for the Zodd project"
requires-python = ">=3.10,<4.0"
dependencies = [
- "pre-commit (>=4.2.0,<5.0.0)",
- "icecream (>=2.1.4,<3.0.0)",
+ "pre-commit >=4.2.0",
+ "icecream >=2.1.4",
+ "clingo>=5.7",
]
diff --git a/src/zodd/frontend/magic.zig b/src/zodd/frontend/magic.zig
index 13f9185..0bde3f4 100644
--- a/src/zodd/frontend/magic.zig
+++ b/src/zodd/frontend/magic.zig
@@ -261,7 +261,26 @@ const State = struct {
}
}
for (rule.assigns) |assign| {
- try r.assign(try self.mapVar(&r, assign.target), try self.mapExpr(&r, assign.expr));
+ // A target bound by the magic guard cannot stay an
+ // assignment; the equality filter has the same semantics
+ // over the already-bound value, including failure.
+ var target_bound = false;
+ for (head_terms, 0..) |term, i| {
+ if (term == .variable and term.variable == assign.target and
+ mask & (@as(Mask, 1) << @intCast(i)) != 0)
+ {
+ target_bound = true;
+ }
+ }
+ if (target_bound) {
+ try r.cmpExpr(
+ .{ .term = try self.mapVar(&r, assign.target) },
+ .eq,
+ try self.mapExpr(&r, assign.expr),
+ );
+ } else {
+ try r.assign(try self.mapVar(&r, assign.target), try self.mapExpr(&r, assign.expr));
+ }
}
for (rule.compares) |compare| {
try r.cmpExpr(try self.mapExpr(&r, compare.lhs), compare.op, try self.mapExpr(&r, compare.rhs));
diff --git a/tests/differential/difftest.py b/tests/differential/difftest.py
new file mode 100644
index 0000000..034909f
--- /dev/null
+++ b/tests/differential/difftest.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""Differential testing of Zodd against clingo.
+
+Generates random stratified Datalog programs in the dialect subset both
+engines share, evaluates them with the zodd CLI and with clingo, and
+compares every derived predicate row for row. A random bound-argument
+query is also answered through zodd's demand-driven path (magic sets) and
+checked against the clingo model.
+
+The generator stays inside the common semantics on purpose: arithmetic is
+limited to + and * over small non-negative integers, because zodd's
+unsigned fail-on-underflow subtraction and fail-on-zero division
+legitimately differ from clingo's signed integers.
+
+Usage: difftest.py [--runs N] [--seed S] [--zodd PATH]
+"""
+
+import argparse
+import random
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import clingo
+
+CMP_OPS = ["<", "<=", ">", ">=", "!="]
+AGG_FUNCS = ["count", "sum", "min", "max"]
+
+
+class Pred:
+ def __init__(self, name, arity, tier):
+ self.name = name
+ self.arity = arity
+ self.tier = tier
+
+
+def fresh_vars(n):
+ return [f"V{i}" for i in range(n)]
+
+
+class Generator:
+ """Builds one random layered program: tier 0 holds facts, higher tiers
+ hold rules over strictly lower tiers (negation, comparisons,
+ assignments) or the same tier (positive recursion only)."""
+
+ def __init__(self, rng):
+ self.rng = rng
+ self.zodd_lines = []
+ self.clingo_lines = []
+ self.derived = []
+
+ def emit(self, zodd, clingo_line=None):
+ self.zodd_lines.append(zodd)
+ self.clingo_lines.append(clingo_line if clingo_line is not None else zodd)
+
+ def generate(self):
+ rng = self.rng
+ domain = rng.randint(6, 12)
+
+ # Tier 0: base facts.
+ edb = []
+ for i in range(rng.randint(2, 3)):
+ pred = Pred(f"b{i}", rng.randint(1, 3), 0)
+ edb.append(pred)
+ for _ in range(rng.randint(3, 14)):
+ row = [rng.randrange(domain) for _ in range(pred.arity)]
+ self.emit(f"{pred.name}({', '.join(map(str, row))}).")
+
+ lower = list(edb)
+ for tier in range(1, rng.randint(2, 4)):
+ tier_preds = []
+ for i in range(rng.randint(1, 3)):
+ pred = Pred(f"t{tier}p{i}", rng.randint(1, 3), tier)
+ tier_preds.append(pred)
+ for pred in tier_preds:
+ for _ in range(rng.randint(1, 2)):
+ self.rule(pred, lower, tier_preds)
+ self.derived.extend(tier_preds)
+ lower.extend(tier_preds)
+
+ # Top tier: one aggregate over a binary lower predicate, when one
+ # exists.
+ binary = [p for p in lower if p.arity == 2 and p.tier > 0]
+ if binary and rng.random() < 0.7:
+ src = rng.choice(binary)
+ func = rng.choice(AGG_FUNCS)
+ agg = Pred("agg0", 2, 99)
+ self.derived.append(agg)
+ self.emit(
+ f"{agg.name}(G, {func}(M)) :- {src.name}(G, M).",
+ f"{agg.name}(G, C) :- {src.name}(G, _), "
+ f"C = #{func}{{ M : {src.name}(G, M) }}.",
+ )
+ return self.derived
+
+ def rule(self, head, lower, tier_preds):
+ rng = self.rng
+ recursive = rng.random() < 0.35 and any(p is not head for p in tier_preds)
+
+ body = []
+ bound = []
+ next_var = 0
+
+ def pick_terms(arity):
+ nonlocal next_var
+ terms = []
+ for _ in range(arity):
+ if bound and rng.random() < 0.5:
+ terms.append(rng.choice(bound))
+ else:
+ var = f"V{next_var}"
+ next_var += 1
+ terms.append(var)
+ bound.append(var)
+ return terms
+
+ for i in range(rng.randint(1, 3)):
+ src = rng.choice(lower)
+ if recursive and i == 0:
+ src = rng.choice([p for p in tier_preds if p is not head] or lower)
+ body.append(f"{src.name}({', '.join(pick_terms(src.arity))})")
+
+ items = list(body)
+
+ # Assignment (non-recursive rules only, so both engines reach the
+ # same finite fixed point).
+ assigned = None
+ if not recursive and bound and rng.random() < 0.4:
+ assigned = f"V{next_var}"
+ next_var += 1
+ expr = f"{rng.choice(bound)} {rng.choice(['+', '*'])} {rng.randint(1, 5)}"
+ items.append((f"{assigned} is {expr}", f"{assigned} = {expr}"))
+ bound.append(assigned)
+
+ # Negated literal over a strictly lower tier, all arguments bound.
+ candidates = [p for p in lower if p.tier < head.tier and p.arity <= len(bound)]
+ if candidates and rng.random() < 0.5:
+ src = rng.choice(candidates)
+ args = rng.sample(bound, src.arity)
+ items.append(f"not {src.name}({', '.join(args)})")
+
+ # Comparison filter, sometimes with arithmetic.
+ if bound and rng.random() < 0.6:
+ lhs = rng.choice(bound)
+ if rng.random() < 0.5:
+ lhs = f"{lhs} {rng.choice(['+', '*'])} {rng.randint(1, 4)}"
+ rhs = str(rng.randint(0, 16)) if rng.random() < 0.6 else rng.choice(bound)
+ items.append(f"{lhs} {rng.choice(CMP_OPS)} {rhs}")
+
+ head_args = [rng.choice(bound) for _ in range(head.arity)]
+ if assigned and rng.random() < 0.7:
+ head_args[rng.randrange(head.arity)] = assigned
+
+ zodd_body = ", ".join(i if isinstance(i, str) else i[0] for i in items)
+ clingo_body = ", ".join(i if isinstance(i, str) else i[1] for i in items)
+ head_text = f"{head.name}({', '.join(head_args)})"
+ self.emit(f"{head_text} :- {zodd_body}.", f"{head_text} :- {clingo_body}.")
+
+
+def solve_clingo(program):
+ """Grounds and solves; returns {pred: sorted list of int tuples}."""
+ ctl = clingo.Control(["--warn", "no-atom-undefined"])
+ ctl.add("base", [], program)
+ ctl.ground([("base", [])])
+ rows = {}
+ with ctl.solve(yield_=True) as handle:
+ for model in handle:
+ for atom in model.symbols(atoms=True):
+ rows.setdefault(atom.name, set()).add(
+ tuple(arg.number for arg in atom.arguments)
+ )
+ break
+ return {name: sorted(tuples) for name, tuples in rows.items()}
+
+
+def parse_zodd_run(output):
+ """Parses `zodd run` output: `pred:` headers followed by `(a, b)` rows."""
+ rows = {}
+ current = None
+ for line in output.splitlines():
+ if line.endswith(":") and not line.startswith(" ") and not line.startswith("("):
+ current = line[:-1].removeprefix("?- ")
+ rows.setdefault(current, set())
+ elif line.startswith("(") and current is not None:
+ if line.startswith("(no rows"):
+ continue
+ body = line.strip("()")
+ rows[current].add(tuple(int(v) for v in body.split(", ")) if body else ())
+ return {name: sorted(tuples) for name, tuples in rows.items()}
+
+
+def run_zodd(zodd, args):
+ result = subprocess.run([zodd, *args], capture_output=True, text=True, timeout=120)
+ if result.returncode != 0:
+ raise RuntimeError(f"zodd {' '.join(args)} failed:\n{result.stderr}")
+ return result.stdout
+
+
+def check_seed(zodd, seed, workdir):
+ rng = random.Random(seed)
+ gen = Generator(rng)
+ derived = gen.generate()
+ zodd_text = "\n".join(gen.zodd_lines) + "\n"
+ clingo_text = "\n".join(gen.clingo_lines) + "\n"
+
+ program = workdir / f"seed{seed}.dl"
+ program.write_text(zodd_text)
+
+ expected = solve_clingo(clingo_text)
+ parallel = ["-j", "4"] if seed % 2 == 0 else []
+ got = parse_zodd_run(run_zodd(zodd, ["run", str(program), *parallel]))
+
+ failures = []
+ for pred in derived:
+ want = expected.get(pred.name, [])
+ have = got.get(pred.name, [])
+ if want != have:
+ failures.append((f"run: {pred.name}", want, have))
+
+ # Demand-driven (magic sets) spot check: bind the first argument of one
+ # derived predicate to a value clingo derived for it.
+ demand_targets = [p for p in derived if expected.get(p.name)]
+ if demand_targets and not failures:
+ target = rng.choice(demand_targets)
+ rows = expected[target.name]
+ bound_value = rng.choice(rows)[0]
+ goal = f"{target.name}({bound_value}{', _' * (target.arity - 1)})"
+ out = run_zodd(zodd, ["query", str(program), goal])
+ have = sorted(
+ tuple(int(v) for v in line.strip("()").split(", "))
+ for line in out.splitlines()
+ if line.startswith("(") and not line.startswith("(no rows")
+ )
+ want = sorted(r for r in rows if r[0] == bound_value)
+ if want != have:
+ failures.append((f"queryDemand: {goal}", want, have))
+
+ if failures:
+ print(f"\nseed {seed}: MISMATCH")
+ print("--- program (zodd) ---")
+ print(zodd_text)
+ print("--- program (clingo) ---")
+ print(clingo_text)
+ for what, want, have in failures:
+ print(f"[{what}]")
+ print(f" clingo: {want}")
+ print(f" zodd: {have}")
+ return False
+ return True
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--runs", type=int, default=200)
+ parser.add_argument("--seed", type=int, default=None, help="run one specific seed")
+ parser.add_argument("--zodd", default="zig-out/bin/zodd")
+ args = parser.parse_args()
+
+ zodd = Path(args.zodd)
+ if not zodd.exists():
+ sys.exit(f"zodd CLI not found at {zodd}; run `make cli` first")
+
+ seeds = [args.seed] if args.seed is not None else range(args.runs)
+ failures = 0
+ with tempfile.TemporaryDirectory() as tmp:
+ for seed in seeds:
+ if not check_seed(str(zodd), seed, Path(tmp)):
+ failures += 1
+ else:
+ print(".", end="", flush=True)
+ print()
+ if failures:
+ sys.exit(f"{failures} mismatching seed(s)")
+ print(f"all {len(list(seeds))} seeds agree")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/frontend_tests.zig b/tests/frontend_tests.zig
index 110ca0e..88b387b 100644
--- a/tests/frontend_tests.zig
+++ b/tests/frontend_tests.zig
@@ -766,6 +766,32 @@ test "frontend: queryDemand matches query on recursive programs" {
}
}
+test "frontend: queryDemand binds a head position computed by an assignment" {
+ // Found by differential testing against clingo: the magic guard binds
+ // the queried head position, so the rewritten rule must turn the
+ // assignment into an equality filter instead of an invalid
+ // re-assignment of a bound variable.
+ const allocator = testing.allocator;
+
+ var db = zodd.Database.init(allocator);
+ defer db.deinit();
+
+ try db.run(
+ \\b(1). b(4). b(8).
+ \\shifted(A, X) :- b(X), A is X + 3.
+ );
+
+ var hit = try db.queryDemand("shifted", &.{ zodd.Value{ .int = 7 }, null });
+ defer hit.deinit();
+ const row = hit.next() orelse return error.TestUnexpectedResult;
+ try testing.expectEqual(@as(u64, 4), row.get(1).int);
+ try testing.expect(hit.next() == null);
+
+ var miss = try db.queryDemand("shifted", &.{ zodd.Value{ .int = 6 }, null });
+ defer miss.deinit();
+ try testing.expect(miss.next() == null);
+}
+
test "frontend: queryDemand falls back when demand does not apply" {
const allocator = testing.allocator;
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..5880841
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,198 @@
+version = 1
+revision = 2
+requires-python = ">=3.10"
+
+[[package]]
+name = "cffi"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" },
+ { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" },
+ { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" },
+ { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
+ { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
+ { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
+ { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
+ { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
+ { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
+ { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
+ { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
+ { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
+ { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
+ { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
+ { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
+ { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
+ { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
+]
+
+[[package]]
+name = "clingo"
+version = "5.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f0/05/1a8a34eea27e73e4c6f7c9d0654b4fd7f509a5fbbb7b3eae7735e5ecd1c2/clingo-5.8.0.tar.gz", hash = "sha256:e557d0ab209bfe185e4ae9dde531ca777be1b72a1a13a5d169926a1f9e4c4480", size = 1910050, upload-time = "2025-04-03T14:08:36.354Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/e9/464d322f0542cba53d943506b0c79d9a48758485ef590e28fa1b4e4bfb7a/clingo-5.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97374310533395b7f94810a57f2431539065b36dfe415350f672f08e7f9be53e", size = 1766163, upload-time = "2025-04-03T14:05:50.46Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/42/0d7f5b5712f12486e40f7d7d45a0e98b38554875bdf070db64f8548af057/clingo-5.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:af495b6454ca4b95085c16367f662f19bb7ffa0962f6e8f07a664ae559334aa1", size = 1620603, upload-time = "2025-04-03T14:05:53.671Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e4/6a2718d3d33db570938f668bba90ef14ca34a421c0e3d408eb12953012fd/clingo-5.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cafb2af48ab7aef04821b4a41112303dbe01131ed9b75930c859c23440cfbb3d", size = 2156621, upload-time = "2025-04-03T14:05:56.225Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c3/ae9192c3db02f499a97d71fb877f7fccddc787e77d515f7fec89d37ce713/clingo-5.8.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c6252b5917e0026049765147496819a1a52d5308531cc73f863974a9a6a2fdf", size = 2428110, upload-time = "2025-04-03T14:05:58.112Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/a6/e779a41abfa8916ef53430ad322c946e337f82ab76ee3525978ffac5d23e/clingo-5.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6bc30b961b9dccc78aad1703578fed1a5f6f6344470cbab2b0be2c34a2f173", size = 2518003, upload-time = "2025-04-03T14:05:59.722Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6d/c64f7998a93e06ea3b890081e1d70c0cf6e31304afd952747390348a6f62/clingo-5.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34cc0cff7574b4d43ab03e652c1ae2db1e55eeb688b0c7875247a3669d2e1977", size = 2259787, upload-time = "2025-04-03T14:06:01.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/0c/9d067ee1b9587b91a04ef92d0597fd153e401008c2883816b5140dd283a1/clingo-5.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:965084dc0803e745fc8eaf4831f325382d5e6e0c4cf257ce8fd2c1b20185f566", size = 3542447, upload-time = "2025-04-03T14:06:03.057Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/af/a63057b6f38b69f29ffe980093fb214a1e964b14132fe0c4437f6ab655f8/clingo-5.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:288107756f1db8b4d0a50d51a17f30e08cea3e2fe7584cfd69f9f7bd4b0bbe6c", size = 3265059, upload-time = "2025-04-03T14:06:04.713Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/70/ef87ce01a3bd42cd27201f17b5234a37fe90ab1e0c8ecb9653496908c55a/clingo-5.8.0-cp310-cp310-win32.whl", hash = "sha256:791948b71bbafd942de90ce42c4227ceec7cf6bbfc26b4dd1e99ee6ed5756d67", size = 1167000, upload-time = "2025-04-03T14:06:06.486Z" },
+ { url = "https://files.pythonhosted.org/packages/03/af/b0687e6ec181d0d5436dadb623320121d18aefaa8594d0fa0be46a8dcde2/clingo-5.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:4f887d5b9717e0255818415766e914dff20ac1491374d3f718101dfd8f22a646", size = 1369273, upload-time = "2025-04-03T14:06:07.772Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/09/03426bb22f1627cfb83615369cb525ccf8c94484a27364a95fdf89102dcc/clingo-5.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34945cbc2c3abe99948982252810ebcee01a04af2edc224bc424acd4e597a127", size = 1766163, upload-time = "2025-04-03T14:06:09.171Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/df/4647e798b1e5d5568d013abb64fc0caae8e7227227b72b623f2ef75212c5/clingo-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7357cbd15c1851c07e4a7dd55cc0622939d91ae06fe84f2ae0440ba3145bb17", size = 1620602, upload-time = "2025-04-03T14:06:10.561Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d8/37c5af6dee398ab66d3137e344fd4a2a576d8299f108a6e01982993624d2/clingo-5.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87aada17b0b43b9636103100277cf38ea83e22d5d93db289d1b717c6e1971585", size = 2156622, upload-time = "2025-04-03T14:06:12.128Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/51/0bd1fb108ca71110825e84d6dd00cfb865815879ff423ff9d1a47a87bb7d/clingo-5.8.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2a08dbc56fc29e22ad082b66ef24abdf423c847fe797e68a30a0889884f29c6", size = 2428112, upload-time = "2025-04-03T14:06:14.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/73/5bed7af81c27c4752fa60bc2d8ff0b199f90f0a246eed8231334c416c478/clingo-5.8.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9924675187c0d27e21c9ee326ce0dffa4c024036197262aa247290703689530", size = 2518003, upload-time = "2025-04-03T14:06:15.489Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/bb/9b8f19f83ae4a67aea83a8674651efc9b941b0d3bc1952a24ac03d82fa8a/clingo-5.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15a26058206c9c396cf564e2e230d14cb4d0f46f500707df4350eedb1547aec", size = 2259790, upload-time = "2025-04-03T14:06:16.972Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1d/e35ab0b1894bee9a83cdf1f88fa04ba4cd3c794cfaf0c81a18ab54eba862/clingo-5.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0985b1ba1d20395e88d51451494ab91ac8e24056fb6d805d52e096e733ae75db", size = 3542450, upload-time = "2025-04-03T14:06:18.593Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c6/70e618cdb31c3ff9cea10f91f224bd91cfeb6a9ec4657f4c821c09db8885/clingo-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56b61c58615eabdd316594d345d237befdd4c837969ed4fa5656b1e5a298b30a", size = 3265061, upload-time = "2025-04-03T14:06:20.122Z" },
+ { url = "https://files.pythonhosted.org/packages/81/43/535b249a3399e428369541a3ae1691824968f1bdd10ec8c554bf1e0d06d4/clingo-5.8.0-cp311-cp311-win32.whl", hash = "sha256:918e816a1d9226029d5f40e41e76836a5de8d0cfdbef958b5d7975297fc7325b", size = 1166956, upload-time = "2025-04-03T14:06:21.435Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ba/5637a0a44de2bfd6b04bfe560d1a452fc19ab7f60c581bbdcaf0479db460/clingo-5.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f56c7c8c39b009987b726dbc4ded8d8672037c749cc9f467dfb05f8ffc49330", size = 1369299, upload-time = "2025-04-03T14:06:23Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/91/07e2a0bfb0bd95e11872ade8d48d44d698f958b929e578bfe43d9cea5841/clingo-5.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e77f1a13f26023f1c5f944175ab6f503a00281f47377336d680093c9c2d095bf", size = 1766131, upload-time = "2025-04-03T14:06:24.418Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/1e/22fa4b240a85d387ad9928e2e0644637a5015785fa78763788847291fa19/clingo-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d9ff85adc097ee8989954249a9d1401cb51434640fef8d49619e16d4e61874", size = 1620692, upload-time = "2025-04-03T14:06:25.843Z" },
+ { url = "https://files.pythonhosted.org/packages/37/5e/3580a1edc73743443c47ea4a6aee53134bd16844720603c1ce3cab2e66e3/clingo-5.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbc347646d78807381f8215de329d7691999abdaac2c6c68cffeb0441d195c54", size = 2156565, upload-time = "2025-04-03T14:06:27.418Z" },
+ { url = "https://files.pythonhosted.org/packages/90/6d/b1c7c8c965bcd9a25c4ca5d1468856a645899e087ac8bd20514c9eeaf056/clingo-5.8.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:207c2e3fc3315cef2475cb1cbd0a143f70145079c09db76af58d5ed41fb9936e", size = 2428016, upload-time = "2025-04-03T14:06:28.783Z" },
+ { url = "https://files.pythonhosted.org/packages/72/25/6e644329e627b252aacf800fcabe71904ebe5012e5b4ee1f4f9ba7c1cbfc/clingo-5.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32410a2120f77aa2865fdffa0fba6c593ccb835a6875364f1999cad27dcb3934", size = 2518051, upload-time = "2025-04-03T14:06:30.16Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/91/af816e99939d0931d374b39acfb7b9f85eee1972a3e4c293a7230f424da6/clingo-5.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d9f85bf0385a364465cdeb8e0b0ed4087d7ad8ed849f1b0d55e709324a25f19", size = 2259925, upload-time = "2025-04-03T14:06:31.601Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/3b/abab5f23f530c2251afffa93fe58ebdbd26aa6bc0dfcf76ecbdd7fc20160/clingo-5.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cb4262b80bff8eaf3a0f86308c914692d18d928a4a2fa6a52c060b0a13bb9c79", size = 3542575, upload-time = "2025-04-03T14:06:33.479Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/fa/f5e672cc4408483dd911fa2814056aba990f20459bde6272f6803231aa7a/clingo-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f233880f18783c3a174784264cb8fd0705c5615c88a50f36b5e330b3b0dc0335", size = 3265135, upload-time = "2025-04-03T14:06:34.856Z" },
+ { url = "https://files.pythonhosted.org/packages/45/fd/9705cdded1766ed060dc4ff231e3a9c0c0bc8f711ac2bd66a328423c08eb/clingo-5.8.0-cp312-cp312-win32.whl", hash = "sha256:e4aa08205f47cd9257c31789d8161aa691296afbb661dac3ff10ccdc8c56ca90", size = 1167043, upload-time = "2025-04-03T14:06:36.215Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1f/2b0fc067ee191fe9fcb1cfc50a69d329e5234cec3c7b62331bb97e1a4b0b/clingo-5.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:44c3d719d21307ef2dc6da9a720e2d65ad40cdb096ecbf4bba026d654a63a022", size = 1369363, upload-time = "2025-04-03T14:06:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/be/24/a9e3d88607823580169484857c53ffb01640d1acb8c602b0bf9de3e14f4f/clingo-5.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:461ce0a8081fc5a0ec5e45719a505a4971e50c6e8fee3e422ea8bbaadc5384c9", size = 1765677, upload-time = "2025-04-03T14:06:39.474Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8b/dfbd483f4405f951837d4911f9591a020624011c29a2ffe3e665f2764486/clingo-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88bf93b0f99fb9e42b3eb794e5ee221f3d90f128ecec421dd117eeb76940fc83", size = 1620692, upload-time = "2025-04-03T14:06:40.824Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/43/981c62bfd69af05228a1940e9ffa537cde561ddcbc59cc62bf81bdbd74e9/clingo-5.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7897f1acdab9628e1e427b88149519accd4b27b5a0004106c9e306c47727d72", size = 2156565, upload-time = "2025-04-03T14:06:42.759Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/52/36ea8e6c3a739af35a1106c10cc6f65db6c1363895ae0c678c1213610481/clingo-5.8.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024b3ed20d85da41956438a42c8b0ac347da14db7456a544bf58983ad5975d8c", size = 2428019, upload-time = "2025-04-03T14:06:45.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/49/8e74859dc653cf20a45bf43a32639c7bca722d34ff5131ba3e8c9176c9a5/clingo-5.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2bebf7a0bee043100d5ed1493703e43e1dd43de97b362375deca8cf707ae196", size = 2518052, upload-time = "2025-04-03T14:06:47.607Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/a1/2945dc1a688d988d4d6d33dea337ba925356350d73d796616e9f65afb30e/clingo-5.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:967583dff5218adb4ed2cda56a53ec06eb518b52ece961bcf4bb84320e7334f5", size = 2259928, upload-time = "2025-04-03T14:06:49.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/c9/8f71e8d9a1c0c5c3d790361ebe4b810d86edc752e4f5c529c0a9f52b765c/clingo-5.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1a8d0b56b1399b2d548e1fe1a27ca5af10bc8d03ad475416f3d4bf1c75b231bd", size = 3542581, upload-time = "2025-04-03T14:06:51.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/09/bcd1a74c3459c21d0f74416e36fd9d41b660c0fa87fc2cc4698cd68cc14f/clingo-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:167a204ea123b1c9f7524934c898553943274f460103b96a2c451d3ea6f9fd2c", size = 3265131, upload-time = "2025-04-03T14:06:53.937Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b5/3ec2544305d450ac937489d831049e56f6fcefadc2d09a1db6627f74ae2a/clingo-5.8.0-cp313-cp313-win32.whl", hash = "sha256:9db5a00458c755ce170b4c7320aaa2a24a984e7f6759a414476b33fa9622a407", size = 1166933, upload-time = "2025-04-03T14:06:56.064Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/f19b240f7778e965916e4a5304ee038cf1b35cfa7df1836567c37ebfa279/clingo-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:6297a12c20bfe12405dd04c4505fcf72c855696018faed32b50cd0c6a4fd7499", size = 1369297, upload-time = "2025-04-03T14:06:57.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/9709d548e13a24915cc1f8f411ecb3188fccb6984edab0d2899efcad7cd0/clingo-5.8.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:b9bdd742a5ed7a151cbc4afbb415f791158b55f85f58782f69765447095465b8", size = 1615275, upload-time = "2026-04-14T15:30:50.169Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/54/6dbc154d48df8c1c010d801ab40a28294ff63e8bf015a174a206d8379435/clingo-5.8.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:95b79ae14461417d011d034f85b98ebdda5558c8c1a3894959315cdc1a6af387", size = 1768590, upload-time = "2026-04-14T15:30:52.864Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/00/c8af30fb0489c16ac214a69aa7ccb83baef3ed6bf863428c3785922d80de/clingo-5.8.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01abafda1c3e079fadeb68dd812c1a132e6e42f9e25aadc6f1a787e2c91ed9f1", size = 2122680, upload-time = "2026-04-14T15:30:54.338Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6f/6863c475d72861e7c0f6c919e6e3b4cf4523338359f641dd5fe197cb24d6/clingo-5.8.0-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6f8836a9e2df0b201e2a29b0cd2351d251146efe80e5b55b2f92e7f4501118fb", size = 2418616, upload-time = "2026-04-14T15:30:56.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6d/c7a55294dbfc89f21a3c856d68cfe1d1ef810fc56782bdbf190259e74445/clingo-5.8.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:62136d2fa4325b3cbc7023be19f721aa0b3ff0be07eca61158963659b5ac4271", size = 2525797, upload-time = "2026-04-14T15:30:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/66/04e27bfafb020bea26fdac7101df25a237fa7737618c1f454beed9b1effa/clingo-5.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99efafa32d37e0ea5a82af1325a7b2a0ad0ab299fe6e68528fa6069b8c53e109", size = 2256742, upload-time = "2026-04-14T15:30:59.996Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/15/471203b61ecffe3318e952dcdbc4239786e0ba906dfe8c82876a3f0d4b77/clingo-5.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3094642aa65f5e1c1aeb791d0843bc9eb3c72dfd07683cd7b3768280d45fdc51", size = 3624012, upload-time = "2026-04-14T15:31:01.468Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/33/2c0319f994d2b8d1dba427f33ca460c89108d7882a0927045c8b04786de2/clingo-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:386d028fd6e775f5d3fd6d6f71d1473e164199e44131f6326060ca447531e95d", size = 3341468, upload-time = "2026-04-14T15:31:03.437Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/31/ad2c0e62cea5519bf8fdf5769e086d61a99850ff9d268ff849155675b708/clingo-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:51042c1abbf7ee2fed20207b6cd39bd65224654afc315edda14e2baadf8991ed", size = 1414851, upload-time = "2026-04-14T15:31:05.294Z" },
+ { url = "https://files.pythonhosted.org/packages/37/f1/56e42efcf0ae9876a974557851dc2a32f1070c2a57f08f512bb5c4dc89f4/clingo-5.8.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a4e1ecc1ac4d66d2e8375e4b25991181e965d3d648d3d4456d21032589901ca9", size = 1723211, upload-time = "2025-04-03T14:08:01.147Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ea/37d61ebc49ebc380f2e7969b5e8caa8886ee115feff59a2772baf20fc44a/clingo-5.8.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:42833290b7eddc4d19133d90e8d0b053431a53a08a2f25e74d26402d767bc088", size = 1585976, upload-time = "2025-04-03T14:08:03.972Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/81/946e2b9161232f47c4e9cd073fc6979ace2752f7b36b27065de20c60d944/clingo-5.8.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79f84ffe8a9764a223df74f2a12032379962b2cf0feb85093c98849d412cd976", size = 2123772, upload-time = "2025-04-03T14:08:05.502Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/53/f9b1baa64c14ba8e94d65e229d87ba150690f94dee8f1f711e90dda8cce4/clingo-5.8.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faad2d14781b521909530570fb4b4ee5629043262a149cb777a99fe37dcd1b54", size = 2394863, upload-time = "2025-04-03T14:08:07.599Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/4f/0d49c6955c7d0b521df4e22e0243de9fcc9b40ad1c8c09f9f25abf0cacac/clingo-5.8.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c92e8964f6abca79aa104ad2d6664762b3e86bc21c076f771708bb0930b4f39", size = 2219460, upload-time = "2025-04-03T14:08:09.543Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "zodd-dev"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "clingo" },
+]
+
+[package.metadata]
+requires-dist = [{ name = "clingo", specifier = ">=5.7" }]
From 3d492dc5731ef10f571e54ef0b8867350137fd2e Mon Sep 17 00:00:00 2001
From: Hassan Abedi
Date: Sat, 1 Aug 2026 15:39:01 +0200
Subject: [PATCH 5/5] WIP
---
ROADMAP.md | 18 ++---
uv.lock | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 239 insertions(+), 13 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 4803637..a7430c5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -25,24 +25,22 @@ This document lists the completed and planned features for Zodd.
- [x] Recursion limits
- [x] Persistence
- [x] Secondary indices
-- [x] Incremental maintenance (`Database.update` after `addFact`/`retract`: additions propagate as semi-naive deltas, deletions and
- negation/aggregate effects recompute only the affected strata; tuple-level deletion (DRed) is a possible future refinement)
-- [x] Parallel execution (opt-in worker threads per fixed-point round via `Database.parallelism`; identical results to sequential evaluation)
-- [x] CLI (`zodd run`, `query` with demand-driven evaluation, `plan`, `explain`, and a `repl`, built on Chilli)
+- [x] Incremental maintenance support
+- [x] Parallel execution support
+- [x] CLI (`zodd run`, `query` with demand-driven evaluation, `plan`, `explain`, and a `repl`)
- [ ] Streaming input
- [x] Rule DSL (textual Datalog frontend with a parser, a builder API, stratified negation, and aggregates)
- [x] Comparison operators (`<`, `<=`, `>`, `>=`, `=`, and `!=` as body filters)
- [x] Arithmetic in comparison filters (`+`, `-`, `*`, `/`, and parentheses on either side of a comparison)
-- [x] Arithmetic assignments (`D2 is D + 1` binds a fresh variable per tuple; recursive use requires an iteration limit)
+- [x] Arithmetic assignments support
- [x] Query planner
-- [x] Explain (rule plan rendering and tuple provenance proof trees)
-- [x] Magic sets (demand-driven queries via `Database.queryDemand`, with a full-evaluation fallback for negation and aggregates)
-- [x] Fact retraction (`Database.retract` removes a base fact and recomputes on the next solve or query)
-- [x] Predicate arity up to 16 columns
+- [x] Explain support
+- [x] Magic sets support
+- [x] Fact retraction support
### Development and Testing
- [x] Unit tests in each module
- [x] Integration, regression, property-based tests, etc. in `tests` directory
-- [x] Differential testing against clingo (`make diff-test`; random stratified programs must agree between the engines)
+- [x] Differential testing against Clingo
- [ ] Benchmarks
diff --git a/uv.lock b/uv.lock
index 5880841..9828cad 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,15 @@
version = 1
revision = 2
-requires-python = ">=3.10"
+requires-python = ">=3.10, <4.0"
+
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
+]
[[package]]
name = "cffi"
@@ -112,6 +121,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
+[[package]]
+name = "cfgv"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+]
+
[[package]]
name = "clingo"
version = "5.8.0"
@@ -177,6 +195,100 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/4f/0d49c6955c7d0b521df4e22e0243de9fcc9b40ad1c8c09f9f25abf0cacac/clingo-5.8.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c92e8964f6abca79aa104ad2d6664762b3e86bc21c076f771708bb0930b4f39", size = 2219460, upload-time = "2025-04-03T14:08:09.543Z" },
]
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "distlib"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.32.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" },
+]
+
+[[package]]
+name = "icecream"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "colorama" },
+ { name = "executing" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/84/6ebc95844feae8a6a29c7fd57e9e3a7ac4817ffab384dc4f0ed53b8e3c46/icecream-2.2.0.tar.gz", hash = "sha256:9d7f244187f00a13f4ac77d176990e187e9c279d6cac4f7548e338291ad97343", size = 14267, upload-time = "2026-04-03T17:42:51.387Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/82/9707c7b0336bca53b75f52fc350956a93da66eb6be632b370bc933216fb4/icecream-2.2.0-py3-none-any.whl", hash = "sha256:f8df7343b3e787023eec22f42fbe4722df2f93099d394fd820b91e16b2e6cb56", size = 16707, upload-time = "2026-04-03T17:42:50.001Z" },
+]
+
+[[package]]
+name = "identify"
+version = "2.6.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
+]
+
+[[package]]
+name = "pre-commit"
+version = "4.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -187,12 +299,128 @@ wheels = [
]
[[package]]
-name = "zodd-dev"
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "python-discovery"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "virtualenv"
+version = "21.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+ { name = "python-discovery" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" },
+]
+
+[[package]]
+name = "zodd"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "clingo" },
+ { name = "icecream" },
+ { name = "pre-commit" },
]
[package.metadata]
-requires-dist = [{ name = "clingo", specifier = ">=5.7" }]
+requires-dist = [
+ { name = "clingo", specifier = ">=5.7" },
+ { name = "icecream", specifier = ">=2.1.4" },
+ { name = "pre-commit", specifier = ">=4.2.0" },
+]