V0.2.0/constraint storage foundations - #86
Merged
Conversation
- closed #80 Phase 1 — Storage Backend Abstraction - closed #81 Phase 1.1: Create ExDatalog.Capabilities module - closed #82 Phase 1.2: Update ExDatalog.Storage behaviour with capabilities/1 and teardown/1 - closed #83 Phase 1.3: Update ExDatalog.Storage.Map to implement new callbacks + deterministic stream - closed #84 Phase 1.4: Create backend conformance tests - closed #85 Start preparation for 0.2.0
- closed #87 Phase 2 — ETS Storage Backend - closed #88 Phase 2.1: Implement ExDatalog.Storage.ETS - closed #89 Phase 2.2: ETS conformance tests + ETS-specific tests - closed #90 Phase 2.3: Update Engine.Naive — teardown + telemetry metadata - closed #91 Phase 2.4: Portability tests (Map vs ETS parity) - closed #92 Phase 2.5: Run tests, lint, dialyzer and produce phase2_review_guide.md
…th dispatch-based evaluation, replacing inline constraint logic in the engine with a clean module-per-constraint-type architecture. The engine pipeline now dispatches IR constraints through the behaviour to dedicated implementor modules.
- `lib/ex_datalog/constraint/context.ex` — `Constraint.Context` struct carrying `capabilities` and `provenance`
- `lib/ex_datalog/constraints/arithmetic.ex` — Arithmetic constraint module (`:add`, `:sub`, `:mul`, `:div`)
- `lib/ex_datalog/constraints/comparison.ex` — Comparison constraint module (`:gt`, `:lt`, `:gte`, `:lte`, `:eq`, `:neq`)
- `test/ex_datalog/constraint/context_test.exs`
- `test/ex_datalog/constraints/arithmetic_test.exs`
- `test/ex_datalog/constraints/comparison_test.exs`
- `lib/ex_datalog/constraint.ex` — Added `@callback evaluate/3`, dispatch function `evaluate/3` that handles both `ExDatalog.Constraint` (public API) and `IR.Constraint` (engine pipeline) struct types, converting the former via `IR.from_constraint/1`
- `lib/ex_datalog/engine/constraint_eval.ex` — Rewritten to dispatch through `ConstraintAPI.evaluate/3` instead of inline logic
The `ExDatalog.Constraint.evaluate/3` function has two clauses:
1. **Public API path** — receives `%ExDatalog.Constraint{}` (from doctests or manual construction), converts to `IR.Constraint` via `IR.from_constraint/1`, then dispatches to the appropriate implementor module.
2. **Engine pipeline path** — receives `%IR.Constraint{}` directly (from `constraint_eval.ex`), dispatches to the implementor without conversion.
This ensures the public API continues to accept `ExDatalog.Constraint` structs created by the constructor functions (`gt/2`, `add/3`, etc.) while the engine pipeline works efficiently with `IR.Constraint` structs.
```elixir
@callback evaluate(
constraint :: ExDatalog.IR.Constraint.t(),
bindings :: map(),
context :: ExDatalog.Constraint.Context.t()
) ::
{:ok, map()} | :filter
```
All implementor modules accept `IR.Constraint` structs (the canonical type produced by the compiler and used internally by the engine).
`ExDatalog.Constraint.Context` carries:
- `capabilities` — `Capabilities.t()` from the storage backend (reserved for Phase 5)
- `provenance` — boolean flag for provenance tracking (reserved for future use)
For v0.2.0, no implementor uses the context, but it's passed through for extensibility.
1. **IR.Constraint as the canonical evaluation type** — Implementor modules receive `IR.Constraint` (not `ExDatalog.Constraint`) because that's what flows through the engine pipeline after compilation. This avoids redundant conversion in the hot path.
2. **Conversion at dispatch boundary** — When the public API `ExDatalog.Constraint.evaluate/3` receives an `ExDatalog.Constraint` struct, it converts to `IR.Constraint` once at the boundary. The engine pipeline bypasses this conversion entirely.
3. **`ir_value_to_native/1` in both implementors** — Both `Arithmetic` and `Comparison` have `ir_value_to_native/1` helpers to convert `{:int, n}`, `{:str, s}`, `{:atom, a}` IR values to native Elixir types before comparison/arithmetic.
4. **No regex `matches/2`** — Per the plan, no regex constraint will be included in Phase 4 due to determinism concerns.
```
473 tests, 0 failures
87 doctests pass
9 properties pass
credo: 0 issues
dialyzer: 0 warnings
formatter: clean
```
| File | What to Check |
|------|--------------|
| `lib/ex_datalog/constraint.ex` | Dual-clause `evaluate/3`, `@callback` type spec, dispatch logic |
| `lib/ex_datalog/constraint/context.ex` | Context struct with `capabilities` and `provenance` |
| `lib/ex_datalog/constraints/arithmetic.ex` | `IR.Constraint` pattern match, `@behaviour`, `ir_value_to_native` |
| `lib/ex_datalog/constraints/comparison.ex` | `IR.Constraint` pattern match, `@behaviour`, `ir_value_to_native` |
| `lib/ex_datalog/engine/constraint_eval.ex` | Dispatches through `ConstraintAPI.evaluate/3` |
| `test/ex_datalog/constraints/arithmetic_test.exs` | Uses `IR.Constraint` structs for test data |
| `test/ex_datalog/constraints/comparison_test.exs` | Uses `IR.Constraint` structs for test data |
| `test/ex_datalog/constraint/context_test.exs` | Context construction tests |
- constraint.ex — 6 new constructors, 3 new op categories, import Kernel, except: [is_integer: 1, is_binary: 1, is_atom: 1], valid_right?/2 for unary constraints, dispatch entries - ir.ex — from_constraint/1 handles right: nil, from_term/1 handles list constants - term.ex — const/1 and valid?/1 accept lists for membership - capabilities.ex — type_predicates and string_predicates default to true Verification: 509 tests, 0 failures / credo: 0 failures (3 intentional naming warnings) / dialyzer: 0 warnings / formatter: clean Closed - closed #106 Create test files for Type, String, Membership constraints and write Phase 4 review guide - closed #105 Update capabilities.ex defaults for type_predicates and string_predicates - closed #104 Update validator/safety.ex for unary and membership constraints - closed #103 Update ir.ex to handle unary constraints in from_constraint/1 - closed #102 Create constraints/membership.ex for :member - closed #101 Create constraints/type.ex for :is_integer, :is_binary, :is_atom - closed #100 Create constraints/string.ex for :starts_with, :contains - closed #99 Add new ops, constructors, types, validation, and dispatch to constraint.ex - closed #98 Phase 4 — Built-in Pure Constraints
…ew tests covering: - Fact rules (k=0 path) with constraints - Empty delta optimization - Multi-atom bodies with old/delta positions - Constraint + negation combined in multi-atom bodies - Type predicate, string predicate, and membership constraints in evaluation - Additional check_negative_atom/3 edge cases (wildcards, multiple matches, missing relations)
…o is clean, dialyzer is clean, and formatting is applied. Here's a summary of all changes made:
Summary of Changes
Critical (C1, C2)
- C1: Fixed Storage.ETS.member?/3 to use :ets.member(ref, tuple) instead of incorrect :ets.match_object/3
- C2: Replaced misleading "returns same state struct" ETS tests with meaningful tests that verify member? and insert_many behavior
High (H1–H12)
- H1: Map.update_index/4 now raises ArgumentError on unknown relations (consistent with build_index/3)
- H2: ETS upsert_index_entry now uses MapSet instead of list in/++; index entries stored as MapSet
- H3: ETS init/2 now accepts :write_concurrency and :read_concurrency options; simplified options construction
- H4: Engine.Naive.evaluate/2 now passes storage_opts to storage_mod.init(schemas, storage_opts); Map backend implements init/2
- H5: Storage behaviour docs now note that build_index/3 and update_index/4 raise on unknown relations
- H6: Engine evaluation now wrapped in try/after to ensure ETS teardown on exceptions
- H7: ETS operations now guard against torn-down state with guard_not_tombstoned! using :ets.info checks
- H8: Extracted resolve_operand/2 and value_to_native/1 to ExDatalog.IR; all 5 constraint modules now use shared implementations
- H9: IR.from_term({:const, list}) now produces {:const, {:list, [tagged_elements]}}
- H10: Constraint.evaluate/3 public struct clause now delegates to IR clause via recursion
- H11: valid_right?(:member, ...) now requires {:const, value} where is_list(value)
- H12: Constraint.Context documented as informational/reserved forfutureuse
Documentation (D1, D2)
- D1: Both backends now report type_predicates: true and string_predicates: true
- D2: ETS member? docstring updated to reference :ets.member/2
TestUpdates
- Updated membership constraint tests to use new tagged IR format {:const, {:list, [...]}}
- Updated evaluator coverage test for membership IRformat
- Added describe "type predicate constructors" with tests for type_integer/1, type_binary/1, type_atom/1 - Added describe "string predicate constructors" with tests for starts_with/2, contains/2 - Added describe "membership constructor" with test for member/2 - Added describe "type_predicate?/1", describe "string_predicate?/1", describe "membership?/1" with introspection tests - Added valid?/1 tests for new constraint types including :member right validation, type predicate nil right, nil result validation - Added input_variables/1 and result_variable/1 tests for new constraint types - Added dispatch consistency test T3: Portability tests for new constraint types - Added type_predicate_program (integer type filter), string_predicate_program (atom type), and membership_program (member filter) - Added 3 new portability tests for type predicates, string predicates, and membership - Extended iteration-parity check (D5) to cover all 8 programs M1: Replace bare if/nil in IR - IR.from_constraint/1 now uses maybe_from_term/1 pattern matching helper instead of if(right != nil, ...) M8: Refactor apply_arithmetic - Replaced nested case/computed pattern with guard-clause function heads that check is_integer - Division-by-zero returns :filter before computing (guard right == 0) - Non-integer operands fall through to catch-all :filter D4: Dispatch consistency test - Added test that every op in @all_ops dispatches via Constraint.evaluate/3 without error D5: Extended iteration-parity - All 8 portability programs (including type predicates, string predicates, membership) now check iterations parity Doc2: Fix broken Capabilities doctests - Replaced ... ellipsis in merge/2 doctest with actual field assertions - Replaced from_backend/1 doctest with concrete field check - Added doctest ExDatalog.Capabilities to test file Doc4: CHANGELOG v0.2.0 - Added full v0.2.0 entry covering all Added, Changed, and Fixed items
Medium issues: - M2: Changed @callback evaluate and all @SPEC evaluate from map() to Binding.t() across all 5 constraint modules + constraint.ex - M4: Replaced false "extensibility" claim with accurate description of closed dispatch - M7: Refactored conformance macro to pass backend module directly via unquote instead of injecting alias/@schemas into the caller Low issues: - L2: Added Logger.debug for unknown relation in both Storage.Map.size/2 and Storage.ETS.size/2 - L3: Extracted build_result/8 and emit_result_telemetry/5 from do_evaluate_inner - L4: Widened teardown callback return type to :ok | {:error, term()} - L5: Added :set rationale section to ETS moduledoc - L6: Widened from_backend spec from {module(), term()} to {atom(), term()} - L7: Added alias ExDatalog.IR to constraint.ex and used IR.from_constraint/1 instead of full path Fix also: Telemetry test emit_stop/4 call updated to emit_stop/5 with explicit storage type (leftover from prior M9 fix) 601 tests, 0 failures, credo clean, dialyzer clean, formatter clean.
…d) callback that was running ETS teardown in the ExUnit supervisor process (which doesn't own the tables). ETS tables with :private access are automatically cleaned up when the owning test process exits, so explicit teardown in on_exit was unnecessary and incorrect.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ExDatalog v0.2.0 — Implementation Plan
Architectural Overview
ExDatalog v0.1.0 provides a complete Datalog evaluation pipeline: Program → Validator → Compiler → Engine.Naive → Result. The storage layer uses an immutable
Storage.Mapbackend (Maps + MapSets). Constraints are a flat struct (ExDatalog.Constraint) with comparison and arithmetic ops interleaved, evaluated inline byEngine.ConstraintEval.v0.2.0 introduces three cross-cutting concerns:
ExDatalog.Constraintbehaviour that current arithmetic/comparison constraints implement, enabling future constraint types without engine changes.The key invariant: no existing API breaks. The
ExDatalogtop-level API,Programbuilder, andEngine.Naiveevaluation path remain unchanged for all existing users.Pipeline Change Diagram
Module-by-Module Changes
Phase 1 — Storage Backend Abstraction
ExDatalog.Storage(modify:lib/ex_datalog/storage.ex)Current state: A
@callback-based behaviour with 10 callbacks includinginit/1,insert/3,insert_many/3,member?/3,size/2,stream/2,get_indexed/4,build_index/3,update_index/4,relations/1.Changes:
@callback capabilities(state) :: ExDatalog.Capabilities.t()to the behaviour. This lets engines query what a backend supports.get_indexed/4,build_index/3,update_index/4from@doc falseto documented callbacks — they are needed by ETS and any future indexed backend.@callback teardown(state) :: :okfor ETS table cleanup (no-op for Map).Storage.Mapgainscapabilities/1andteardown/1implementations.ExDatalog.Storage.Backend(new:lib/ex_datalog/storage/backend.ex)A convenience module that:
default_capabilities/0returning the baseline capability map.__using__/1macro that generates boilerplate for backend modules (delegating to default implementations where appropriate).This is optional and can be deferred to after Phase 1 if it adds complexity. The behaviour in
Storageitself is sufficient. Decision: defer the macro — the behaviour alone is enough. Just add documentation.ExDatalog.Storage.Map(modify:lib/ex_datalog/storage/map.ex)Current state: Implements
ExDatalog.Storageusing Maps + MapSets. 248 lines. Well-structured.Changes:
@impl true def capabilities(_state)returning%Capabilities{map_storage: true, indexed_lookup: false}.@impl true def teardown(_state), do: :ok(no-op for immutable data).@doc falsefromget_indexed,build_index,update_index— they're part of the public contract now.@moduledocto remove "Phase 8" language and reflect that ETS is now a peer implementation.No semantic changes to any existing function.
ExDatalog.Capabilities(new:lib/ex_datalog/capabilities.ex)A struct defining what a backend/constraint set supports:
Design notes:
storage_typedistinguishes Map (immutable, process-local) from ETS (off-heap, concurrent reads) from future external stores.arithmetic_constraintsandcomparison_constraintsdefault totruebecause v0.1.0 already supports them.type_predicatesandstring_predicatesdefault tofalsebecause they're new in Phase 4.provenancedefaults totruebecause v0.1.0 already supports it.external_executiondefaults tofalse— reserved for Z3, Soufflé, etc.Phase 2 — ETS Storage Backend
ExDatalog.Storage.ETS(new:lib/ex_datalog/storage/ets.ex)Design:
{:ex_datalog, relation_name}using:named_tablewith:ordered_settype for deterministic iteration order.:ordered_setgives O(log n) membership test and ordered first/next iteration.:ets.first/1/:ets.next/2over:ordered_settables is deterministic for term ordering. This is critical — the prompt explicitly calls out that ETS iteration order is nondeterministic, and we must normalize. Using:ordered_setgives us term-ordered iteration for free. However, for cross-relation ordering guarantees, we still sortstream/2output before returning.init/1.teardown/1deletes all tables. Options may include:access(default:private), passed through to:ets.new/2.Callbacks:
init/1insert/3:ets.insert(table_ref, tuple)(idempotent for:ordered_set).insert_many/3:ets.inserteach tuple.member?/3:ets.member(table_ref, tuple)— O(log n).size/2:ets.info(table_ref, :size)— O(1).stream/2build_index/3get_indexed/4update_index/4relations/1capabilities/1%Capabilities{storage_type: :ets, indexed_lookup: true, concurrent_reads: true, ...}teardown/1Determinism strategy:
:ordered_setgives term-ordered traversal.stream/2explicitly sorts viaEnum.sort/1for defense-in-depth.relations/1returnsEnum.sort/1.Concurrency:
:private(owning process only). The:publicoption allows concurrent reads.:concurrent_readscapability indicates this.ExDatalog.Engine.Naive(modify:lib/ex_datalog/engine/naive.ex)Current state: Accepts
storageoption (defaultExDatalog.Storage.Map). Usesstorage_mod.init/1,storage_mod.insert/3,storage_mod.stream/2, etc.Changes:
storage_mod.teardown(state_final)after building the result, before returning{:ok, result}. For Map this is a no-op. For ETS this cleans up tables.storage_mod.capabilities(state_final)and store capabilities in telemetry metadata (optional, informational).ExDatalog.Storage.Map— Conformance Test (new:test/ex_datalog/storage/backend_conformance_test.exs)A shared conformance test suite that both
Storage.MapandStorage.ETSmust pass. Uses ExUnit'sdescribepattern:Then:
Phase 3 — Constraint Behaviour
ExDatalog.Constraint(modify:lib/ex_datalog/constraint.ex)Current state: A single struct
%Constraint{op, left, right, result}with comparison and arithmetic constructors. All ops are in one module.Changes:
The key architectural change. Currently,
Constraintis a concrete struct. v0.2.0 introduces aConstraintbehaviour that constraint modules implement. The existing struct remains for backward compatibility — it becomes the "built-in IR constraint" format, and the behaviour wraps it.Strategy — Wrapper approach:
ExDatalog.Constraintbehaviour with@callback evaluate(term(), bindings :: map(), context :: map()) :: {:ok, boolean() | map()} | {:error, term()}.Wait — this needs more thought. The prompt suggests:
But the existing
Constraintmodule is a struct module, not a behaviour. We need to:Constraintstruct and its constructors working (no API break).Engine.ConstraintEvalaware of the behaviour.Revised approach:
ExDatalog.Constraint.BuiltIn(or keep it as-is and add the behaviour in a new module).Actually, the prompt says:
This means:
ExDatalog.Constraintbecomes a behaviour module.ExDatalog.Constraint.ArithmeticandExDatalog.Constraint.Comparisonare the first implementations.Constraintstruct moves to an internal representation.Migration strategy:
The existing
ExDatalog.Constraintstruct and constructors (gt/2,lt/2,add/3, etc.) are public API. They appear in user-facingProgramconstruction. We cannot break them.Approach:
ExDatalog.Constraintas the public-facing struct module with all existing constructors.ExDatalog.Constraint.Behaviouras the behaviour module with the@callback evaluate/3.ExDatalog.Constraint.ArithmeticimplementingConstraint.Behaviourfor arithmetic ops.ExDatalog.Constraint.ComparisonimplementingConstraint.Behaviourfor comparison ops.Engine.ConstraintEvalto dispatch through the behaviour when a constraint module is specified, falling back to the existing inline logic for built-in IR constraints.Wait — there's a subtlety. The constraint struct carries
op,left,right,result— the IR format used by the compiler. The behaviour'sevaluate/3takes aterm()which could be this struct. So the relationship is:Constraint.gt/2,Constraint.add/3etc. to build constraints.%IR.Constraint{}structs in the IR.Engine.ConstraintEval.apply_one/2dispatches based onop. This is where the behaviour should plug in.Revised approach — minimal refactoring:
Rather than a full restructuring, the Constraint behaviour serves as an extension point for future constraint types (string predicates, type checks) that don't fit the
{op, left, right, result}struct format.ExDatalog.Constraint— keep as-is with all existing structs and constructors. Add the@callback evaluate/3behaviour.ExDatalog.Constraint.Context(new) — evaluation context passed toevaluate/3.ExDatalog.Constraints.Arithmetic(new:lib/ex_datalog/constraints/arithmetic.ex) — implements the behaviour for arithmetic ops.evaluate/3delegates to the arithmetic logic currently inConstraintEval.ExDatalog.Constraints.Comparison(new:lib/ex_datalog/constraints/comparison.ex) — implements the behaviour for comparison ops.evaluate/3delegates to the comparison logic currently inConstraintEval.Engine.ConstraintEval— add a dispatch layer: if a constraint has a:modulefield (or resolves to a module viaop), call the behaviour. If not, use the existing inline logic. Actually, since we're not changing the IR representation, and all built-in ops are already handled inline, the dispatch for built-in constraints stays as-is. New constraint types use the behaviour.Actually, let me reconsider the simplest path that satisfies the prompt requirements:
Simplest approach:
@callback evaluate/3toExDatalog.Constraintmodule.%Constraint{}) already carries all evaluation data. The behaviour'sevaluate/3callback receives this struct.Constraints.Arithmeticimplementsevaluate/3— it receives a%Constraint{}struct, dispatches onop, and performs the arithmetic.Constraints.Comparisonimplementsevaluate/3— similar.ConstraintEval.apply_one/2is refactored to callConstraint.evaluate(constraint, binding, context)whereevaluate/3dispatches to the appropriate module.Constraint.Contextcarries evaluation context (currently empty/unused, but reserved for future needs like capabilities, provenance tracking mode).This way:
Constraint evaluation flow (v0.2.0):
This adds one indirection layer but is entirely internal. No public API change.
Phase 4 — Built-in Pure Constraints
New constraint types
All implement
ExDatalog.Constraintbehaviour and are represented as%Constraint{}structs with new ops. This keeps the IR format uniform.New ops added to
ExDatalog.Constraint:Comparison extensions:
:is_integer— type check (unary,result: nil):is_binary— type check (unary):is_atom— type check (unary)String predicates:
:starts_with—starts_with(left, right)where right is a literal string:contains—contains(left, right)where right is a literal string:matches—matches(left, right)where right is a regex pattern (serialized as string)Wait — the current struct only has
leftandright(binary). Type checks are unary. We need to handle this.Option A: Add
unaryconstraints asleftonly,right: nil.Option B: Keep
right: {:const, nil}for unary constraints.Decision: Option A. For unary constraints,
rightisnilandresultisnil.New constructors in
ExDatalog.Constraint:Wait —
matches/3doesn't fit the{left, right, result}pattern well, and regex determinism is a concern (noted in the prompt). Let's keep it simple for v0.2.0:Phase 4 scope:
is_integer/1,is_binary/1,is_atom/1eq/2,neq/2gt/2,lt/2,gte/2,lte/2starts_with/2,contains/2(no regex for determinism)in/2— checks if a value is in a list of constantsConstructor additions:
Validation updates in
ExDatalog.Validator.Safety:memberconstraint:rightis a constant list;leftmust be bound.ConstraintEval updates:
String.starts_with?/2,String.contains?/2.IR representation:
For type predicates, the IR gains a new unary constraint type. The
%IR.Constraint{}struct still works —rightwill benilfor unary ops, and the compiler handles this.ExDatalog.Constraints.Type(new:lib/ex_datalog/constraints/type.ex)Implements
Constraintbehaviour for:is_integer,:is_binary,:is_atom.ExDatalog.Constraints.String(new:lib/ex_datalog/constraints/string.ex)Implements
Constraintbehaviour for:starts_with,:contains.ExDatalog.Constraints.Membership(new:lib/ex_datalog/constraints/membership.ex)Implements
Constraintbehaviour for:member.Phase 5 — Capability Model
ExDatalog.Capabilities(created in Phase 1, extended in Phase 5)The struct is already defined. Phase 5 adds:
Capabilities.merge/2— merges two capability structs (AND semantics: both must support).Capabilities.from_backend/1— shorthand for querying a backend's capabilities.Capabilities.satisfies?/2— checks if capabilities satisfy a required set.Integration with Engine.Naive:
storage_mod.capabilities(state)and store in the evaluation context.Integration with Constraint evaluation:
Constraint.Contextincludes the capability map. Constraint implementations can check it if they need specific backend features.Storage Design
Storage.Backend Behaviour
The existing
ExDatalog.Storagebehaviour is already well-defined. The main addition iscapabilities/1andteardown/1.Storage.ETS Design
Table strategy: One ETS table per relation, type
:ordered_set, named{:ex_datalog, relation_name}.Why
:ordered_set:State struct:
Thread safety:
:private(only owning process). Matches current single-process evaluation.:public+:read_concurrencyfor concurrent read workloads.concurrent_readscapability reflects the access mode.Memory considerations:
:ordered_setuses a balanced tree internally. Memory overhead is slightly higher than:set, but the deterministic ordering benefit outweighs this.:compressedoption by default (can be added as an option).Determinism Guarantees
stream/2:ets.tab2list→Enum.sort(deterministic)relations/1member?/3size/2Important:
Storage.Map.stream/2currently returnsMapSet.to_list(set)which is not deterministic across BEAM versions or runs. However, the engine consumesstream/2output intoMapSet.new()insnapshot_facts/3, which doesn't depend on order. The final result'sMapSetis also unordered but equality-comparable.For Phase 1,
Storage.Map.stream/2should also sort its output for consistency:This adds a small cost to Map backend but ensures deterministic iteration across both backends. The prompt says "deterministic iteration semantics" are required.
Actually — let me reconsider. The engine currently uses
streamin two places:snapshot_facts/3which wraps the result inMapSet.new()— order doesn't matter.MapSet.to_list(Map.get(delta, relation, MapSet.new()))directly — these go throughJoin.join/3which also doesn't depend on order.However, deterministic iteration is still required for reproducibility. If two runs produce facts in different orders, the provenance tracking could attribute to different rules. And for ETS, without explicit sorting,
:ets.tab2liston:setor:bagtables returns in insertion order which varies.Decision: Both backends will sort the output of
stream/2. For Map, this isEnum.sort(MapSet.to_list(set)). For ETS, this isEnum.sort(:ets.tab2list(table)).Constraint Design
Architecture
Evaluation dispatch
Engine.ConstraintEval.apply_one/2currently has inline logic for all ops. Refactored:But wait — this changes the return type. Currently
apply_onereturns{:ok, binding()} | :filter. TheConstraint.evaluate/3callback returns{:ok, boolean()} | {:ok, map()} | {:error, term()}.Let me reconcile:
Option A:
evaluate/3returns:{:ok, binding}— constraint passed, binding possibly extended (arithmetic){:ok, :pass}— constraint passed, binding unchanged (comparison, type, string):filter— constraint failedOption B:
evaluate/3returns:{:ok, true}— passed, no binding change{:ok, false}— failed → filter{:ok, extended_binding}— passed, binding extended{:error, reason}— error, treat as filterOption A is closer to the existing
ConstraintEvalpattern. But the prompt's original suggestion is{:ok, boolean()} | {:error, term()}. Let me keep it close to the prompt but practical:Final design:
Where
binding()is%{String.t() => term()}— the same asBinding.t().This matches the existing
ConstraintEval.apply/2andapply_one/2return types exactly. Each constraint module implements this callback. The dispatch:This keeps the existing struct, adds a dispatch layer, and makes it easy to add new constraint types by:
ExDatalog.Constraint.Constraint.Context
For v0.2.0,
Contextcarries capabilities and provenance flag. Constraints don't use it yet — it's reserved for future use (e.g., a Z3 constraint might need to know if the backend supports external execution).Determinism Strategy
Sources of nondeterminism
:ordered_settables +Enum.sort/1instream/2.stream/2output in both Map and ETS backends.snapshot_facts/3usesMap.new/1with a reduce — safe because we construct maps from sorted keys.Guarantees
stream/2output is deterministically sorted — both backends.relations/1output is deterministically sorted — both backends.Testing strategy
Migration Strategy
Backward compatibility
ExDatalogtop-level API (new/0,validate/1,compile/1,evaluate/2,query/2) is unchanged.ExDatalog.Constraint.gt/2,lt/2, etc. are unchanged. New constructors (is_integer/1, etc.) are additive.ExDatalog.Storage.Mapgainscapabilities/1andteardown/1but all existing callbacks are unchanged.Engine.ConstraintEvaldispatches through behaviour internally butapply/2andapply_one/2signatures are unchanged.Program.add_rule/2and IR compilation are unchanged — new constraint ops flow through the same compiler path.Versioning
mix.exsversion to0.2.0.@source_refin docs tov0.2.0.Deprecations
Storage.Mapis the default and remains the default.Telemetry Considerations
Existing events
[:ex_datalog, :query, :start]— unchanged.[:ex_datalog, :query, :stop]— addstorage_typeto metadata.[:ex_datalog, :query, :exception]— unchanged.New events
[:ex_datalog, :storage, :init]— measurements:%{count: n}(number of relations). Metadata:%{storage_type: :map | :ets}.[:ex_datalog, :storage, :insert]— measurements:%{count: n}(tuples inserted). Metadata:%{storage_type, relation}.For v0.2.0, backend-level telemetry can be deferred. The most important addition is
storage_typein the existing:stopevent metadata.Constraint telemetry
:telemetry.span.Provenance Considerations
Current architecture
Explain.explain/3reconstructs derivation trees fromResult.provenance.fact_originsandResult.provenance.rules.Naivetracksoriginsas%{relation => %{tuple => rule_id | :base}}— last-wins attribution.Changes needed
Constraint.Contextcarries aprovenance: boolean()field for future use. Not used in v0.2.0.Future considerations
Testing Strategy
Backend conformance tests
Shared test module
ExDatalog.Storage.BackendConformanceTestcovering:init/1— creates storage for given schemas.insert/3— inserts single tuple, idempotent.insert_many/3— bulk insert.member?/3— membership test.size/2— cardinality.stream/2— deterministic iteration (sorted).relations/1— sorted list.build_index/3,get_indexed/4,update_index/4— index operations.capabilities/1— returns capabilities struct.teardown/1— cleanup.ETS stress tests
Constraint tests
For each new constraint type:
constraint_test.exs).constraint_eval_test.exs).Portability tests
Result(after sorting).Deterministic tests
MapSetorder-dependent assertions.Performance Considerations
ETS vs Map
Key insight: The Map backend copies the entire
MapSeton everyinsert(immutable data). For >100K facts, this creates significant GC pressure. ETS avoids this for reads.The ETS backend wins on:
The Map backend wins on:
Benchmarks
Required benchmarks:
:erlang.process_info/2) for Map vs ETS backends at various sizes.Benchmark module:
bench/storage_bench.exsusing Benchee.Optimization avoids
Risks
ETS table ownership — ETS tables are owned by the process that creates them. If the evaluation process dies, tables are automatically deleted. But if a caller expects the storage to survive beyond the evaluation, this would be a problem. Mitigation: ETS storage is scoped to
Engine.Naive.evaluate/2— created on init, torn down on completion. No cross-process sharing in v0.2.0.ETS
:ordered_setperformance —:ordered_setuses a balanced tree with O(log n) operations. For very large fact sets,:set(hash table) would be faster for lookups but loses deterministic ordering. Mitigation: Sort onstream/2regardless; use:setand sort. Actually —:setiteration order is not guaranteed, but we're sorting anyway. Use:setfor better insert/lookup performance. Decision: Use:setfor performance, sort instream/2for determinism. This matches the prompt's requirement that ETS iteration must be normalized.Wait —
:setdoesn't prevent duplicates at the ETS level (same tuple can be inserted twice in:bag). But:setreplaces on:ets.insert. So:setgives us idempotent insert (same as MapSet) AND O(1) lookup. But iteration order is still not deterministic — we sort instream/2.Actually, for Datalog facts (tuples),
:setsemantics (one copy per key,:ets.insertreplaces) is what we want. Key = the entire tuple.Revised decision: Use
:setfor ETS tables. Sort instream/2. This gives better performance than:ordered_setwhile maintaining determinism.Wait — but
member?/3needs to check if a tuple exists. With:set, the key IS the tuple.:ets.member(table, tuple)works in O(1). With:ordered_set, it's O(log n). So:setis faster for bothmember?andinsert.Final decision:
:setwith sortedstream/2.Constraint dispatch overhead — Adding a dispatch layer in
ConstraintEvaladds one function call per constraint. This is negligible for typical Datalog programs with few constraints per rule. Mitigation: Measure in benchmarks. If significant, inline the hot path for built-in constraints.Unary constraints in IR — The current
IR.Constraintstruct hasleft,right,resultfields. Unary constraints (type predicates) don't useright. The struct still works —right: nil. But the compiler'sfrom_constraint/1needs to handle unary constraints. Mitigation: Extendfrom_constraint/1with a clause for unary ops.Regex determinism — The prompt warns about regex determinism.
matches/2with~r/foo/is not serializable across BEAM nodes. Mitigation: Don't implementmatchesin v0.2.0. Onlystarts_withandcontains, which are deterministic.Rollback Strategy
Phase 1 rollback
If Storage.Backend abstraction causes issues:
Storage.exto original (removecapabilities/1andteardown/1).Storage.Mapchanges.Capabilitiesmodule.Phase 2 rollback
If ETS backend causes issues:
Storage.ETSmodule.:etsoption from engine.Phase 3 rollback
If Constraint Behaviour causes issues:
Constraint.BehaviourandConstraint.Context.ConstraintEval.Constraints.ArithmeticandConstraints.Comparison.Constraintstruct and constructors unchanged.Phase 4 rollback
If new constraint types cause issues:
Constraintmodule.Constraints.Type,Constraints.String,Constraints.Membership.Phase 5 rollback
If Capability model causes issues:
Capabilitiesstruct and merge/satisfies functions.capabilities/1from Storage behaviour.Full rollback
If the entire release causes issues:
File Listing (New/Modified)
Phase 1 — Storage Backend Abstraction
New:
lib/ex_datalog/capabilities.extest/ex_datalog/capabilities_test.exstest/ex_datalog/storage/backend_conformance_test.exsModified:
lib/ex_datalog/storage.ex— addcapabilities/1,teardown/1callbacks, update docslib/ex_datalog/storage/map.ex— implementcapabilities/1,teardown/1, sortstream/2, update docsmix.exs— bump version to0.2.0Phase 2 — ETS Storage Backend
New:
lib/ex_datalog/storage/ets.extest/ex_datalog/storage/ets_test.exsbench/storage_bench.exsModified:
lib/ex_datalog/engine/naive.ex— addteardown/1call, addstorage_typeto telemetry metadataPhase 3 — Constraint Behaviour
New:
lib/ex_datalog/constraint/context.exlib/ex_datalog/constraints/arithmetic.exlib/ex_datalog/constraints/comparison.extest/ex_datalog/constraints/arithmetic_test.exstest/ex_datalog/constraints/comparison_test.exsModified:
lib/ex_datalog/constraint.ex— add@callback evaluate/3, add@behaviourdefinition, add dispatch functionlib/ex_datalog/engine/constraint_eval.ex— refactor to dispatch throughConstraint.evaluate/3Phase 4 — Built-in Pure Constraints
New:
lib/ex_datalog/constraints/type.exlib/ex_datalog/constraints/string.exlib/ex_datalog/constraints/membership.extest/ex_datalog/constraints/type_test.exstest/ex_datalog/constraints/string_test.exstest/ex_datalog/constraints/membership_test.exsModified:
lib/ex_datalog/constraint.ex— add new ops, constructors, validationlib/ex_datalog/engine/constraint_eval.ex— add dispatch for new opslib/ex_datalog/validator/safety.ex— handle unary constraints and membershiplib/ex_datalog/ir.ex— handle unary constraints infrom_constraint/1Phase 5 — Capability Model
Modified:
lib/ex_datalog/capabilities.ex— addmerge/2,from_backend/1,satisfies?/2test/ex_datalog/capabilities_test.exs— test new functionsDocumentation
New:
docs/constraints.mddocs/storage_backends.mddocs/capabilities.mdModified:
README.md— add ETS backend section, constraints sectionCHANGELOG.md— v0.2.0 entrySummary of Invariants
fnpredicates, no calling Elixir functions from rules.storage_typemetadata.