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 1bde233..dab831e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,11 @@ 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`. +- `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 @@ -86,10 +89,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. @@ -112,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 5fef6f3..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 + setup-hooks test-hooks web web-serve web-test cli diff-test .DEFAULT_GOAL := help help: ## Show the help messages for all targets @@ -68,6 +68,15 @@ 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) + +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 6957530..a7430c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,18 +25,22 @@ 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 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 support - [x] Query planner -- [x] Explain (rule plan rendering and tuple provenance proof trees) -- [ ] Magic sets +- [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 - [ ] Benchmarks 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 06f7b35..6d0657f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,12 @@ .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", + }, + .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/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/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/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/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..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; } @@ -357,6 +749,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 +790,7 @@ pub const Evaluator = struct { arena, grouped.elements, agg.group_len, - agg.group_len, + agg.val_col, agg.func, &folded, ); @@ -403,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); + } } } @@ -413,8 +819,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 +836,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..0bde3f4 --- /dev/null +++ b/src/zodd/frontend/magic.zig @@ -0,0 +1,383 @@ +//! # 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| { + // 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)); + } + 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..c603aea 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. @@ -72,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{ @@ -82,7 +100,9 @@ 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(); } @@ -101,6 +121,86 @@ pub const Database = struct { try b.factValues(pred, row); } + /// 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; + 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; + // 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 { @@ -113,19 +213,45 @@ 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; + 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; @@ -143,13 +269,85 @@ 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.?); + 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); + } + + 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 +361,7 @@ pub const Database = struct { .pattern = encoded, .prefix = probe, .prefix_len = prefix_len, - .arity = info.arity, + .arity = arity, .interner = &self.interner, }; } @@ -188,7 +386,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, @@ -196,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; @@ -689,3 +894,65 @@ 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); +} + +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/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..dd35a4b 100644 --- a/src/zodd/variable.zig +++ b/src/zodd/variable.zig @@ -73,10 +73,19 @@ 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 { - const rel = try Rel.fromSlice(self.allocator, tuples); + var rel = try Rel.fromSlice(self.allocator, tuples); + errdefer rel.deinit(); try self.insert(rel); } @@ -455,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/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 fa7f8d1..88b387b 100644 --- a/tests/frontend_tests.zig +++ b/tests/frontend_tests.zig @@ -296,6 +296,705 @@ 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 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; + + 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: 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); + } + } +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9828cad --- /dev/null +++ b/uv.lock @@ -0,0 +1,426 @@ +version = 1 +revision = 2 +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" +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 = "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" +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 = "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" +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 = "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" }, + { name = "icecream", specifier = ">=2.1.4" }, + { name = "pre-commit", specifier = ">=4.2.0" }, +] 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 @@
=, 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.
+
+
+ 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.