Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ ex_datalog-*.tar

# Dialyzer PLT files.
/priv/plts/

/livebook/*.exs
/livebook/*.md
/chest/
.tool-versions
32 changes: 30 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2025-06-19

### Added

- **Tuple shorthand for rules**: `Program.add_rule/3` and `Program.add_rule/4` accept
`{relation, [terms]}` tuples for heads, `{:polarity, {relation, [terms]}}` for body
literals, and `{:op, args...}` for constraints. Uppercase atoms (`:X`) become variables,
`:_` becomes a wildcard, lowercase atoms and other values become constants.
- `Term.from/1` — converts shorthand values to `Term.t()` following Prolog convention.
- `ExDatalog.Atom.from_tuple/1` — constructs an atom from `{"relation", [terms]}` shorthand.
- `Constraint.from_tuple/1` — constructs a constraint from operator tuples
like `{:neq, :A, :B}`, `{:add, :X, :Y, :Z}`, `{:is_integer, :V}`.

### Changed

- **`ExDatalog.Result` renamed to `ExDatalog.Knowledge`** — the struct returned by
`materialize/2` now reflects that it represents a materialized knowledge base,
not a query result. All references updated across source, tests, docs, and livebooks.
- `ExDatalog.query` (2-arity) renamed to `ExDatalog.materialize/2` — the top-level API function
now reflects that it runs the full fixpoint pipeline, not a single query.
- **Telemetry events renamed**: `[:ex_datalog, :query, :start|:stop|:exception]` →
`[:ex_datalog, :materialize, :start|:stop|:exception]`.
- `ExDatalog.validate/1` and `ExDatalog.compile/1` now pass through `{:error, _}` tuples
from the builder pipeline instead of raising `FunctionClauseError`. `materialize/2`
also passes through `{:error, _}` from a failed pipeline step.
- Livebook examples (`quickstart.livemd`, `examples.livemd`, `examples.exs`) converted
to tuple shorthand notation. README quickstart updated accordingly.

## [0.2.0] - 2025-05-15

### Added
Expand Down Expand Up @@ -97,8 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ExDatalog.Engine.Join` — sequential-scan join (`join/3`), tuple matching (`match_tuple/3`), projection (`project/2`), indexed join (`join_indexed/4`, not yet wired into evaluator)
- `ExDatalog.Engine.ConstraintEval` — constraint evaluation (comparison filters, arithmetic extensions)
- `ExDatalog.Storage.Map` — default Map/MapSet-based storage backend
- `ExDatalog.Result` — result struct with relations, stats, and provenance fields
- Full pipeline: `ExDatalog.query/1` and `ExDatalog.query/2` public API
- `ExDatalog.Knowledge` — knowledge base struct with relations, stats, and provenance fields
- Full pipeline: `ExDatalog.materialize/2` public API
- Phase 5: Negation and stratification
- Negative body atoms (`{:negative, %IR.Atom{}}`) evaluated as filters against fully-materialised lower-stratum relations
- Stratification validation rejects unstratifiable programs before evaluation
Expand Down
133 changes: 72 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Add `ex_datalog` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:ex_datalog, "~> 0.2.0"}
{:ex_datalog, "~> 0.3.0"}
]
end
```
Expand All @@ -63,59 +63,76 @@ The classic Datalog example: compute all ancestors from parent facts.

```elixir
alias ExDatalog
alias ExDatalog.{Program, Rule, Atom, Term}
alias ExDatalog.{Program, Knowledge}

{:ok, result} =
{:ok, knowledge} =
Program.new()
|> Program.add_relation("parent", [:atom, :atom])
|> Program.add_relation("ancestor", [:atom, :atom])
|> Program.add_fact("parent", [:alice, :bob])
|> Program.add_fact("parent", [:bob, :carol])
|> Program.add_fact("parent", [:carol, :dave])
|> Program.add_rule(
Rule.new(
Atom.new("ancestor", [Term.var("X"), Term.var("Y")]),
[{:positive, Atom.new("parent", [Term.var("X"), Term.var("Y")])}]
)
)
{"ancestor", [:X, :Y]},
[{:positive, {"parent", [:X, :Y]}}]
)
|> Program.add_rule(
Rule.new(
Atom.new("ancestor", [Term.var("X"), Term.var("Z")]),
[
{:positive, Atom.new("parent", [Term.var("X"), Term.var("Y")])},
{:positive, Atom.new("ancestor", [Term.var("Y"), Term.var("Z")])}
]
)
)
|> ExDatalog.query()

result.relations["ancestor"]
{"ancestor", [:X, :Z]},
[
{:positive, {"parent", [:X, :Y]}},
{:positive, {"ancestor", [:Y, :Z]}}
]
)
|> ExDatalog.materialize()

Knowledge.get(knowledge, "ancestor")
#=> MapSet.new([{:alice, :bob}, {:bob, :carol}, {:carol, :dave},
#=> {:alice, :carol}, {:bob, :dave}, {:alice, :dave}])
```

### Shorthand rule notation

`add_rule/3` and `add_rule/4` use a tuple-based shorthand that follows
Prolog convention: uppercase atoms become variables, `:_` becomes a wildcard,
and lowercase atoms/other values become constants.

```elixir
# Base rule: ancestor(X,Y) :- parent(X,Y).
Program.add_rule(program, {"ancestor", [:X, :Y]}, [{:positive, {"parent", [:X, :Y]}}])

# With constraints — find high earners:
Program.add_rule(program, {"high_earner", [:X]}, [{:positive, {"income", [:X, :S]}}], [{:gt, :S, 100_000}])

# Negation — bachelors are males who are not married:
Program.add_rule(program, {"bachelor", [:X]}, [
{:positive, {"male", [:X]}},
{:negative, {"married", [:X, :_]}}
])
```

The struct-based `add_rule/2` with `Rule.new/3` remains available for
full control over term types.

### Arithmetic constraints

Compute derived values in rules. Find numbers and their doubles:

```elixir
{:ok, result} =
{:ok, knowledge} =
Program.new()
|> Program.add_relation("number", [:integer])
|> Program.add_relation("doubled", [:integer, :integer])
|> Program.add_fact("number", [1])
|> Program.add_fact("number", [2])
|> Program.add_fact("number", [3])
|> Program.add_rule(
Rule.new(
Atom.new("doubled", [Term.var("X"), Term.var("Y")]),
[{:positive, Atom.new("number", [Term.var("X")])}],
[Constraint.add(Term.var("X"), {:const, 2}, Term.var("Y"))]
)
)
|> ExDatalog.query()

result.relations["doubled"]
{"doubled", [:X, :Y]},
[{:positive, {"number", [:X]}}],
[{:add, :X, 2, :Y}]
)
|> ExDatalog.materialize()

Knowledge.get(knowledge, "doubled")
#=> MapSet.new([{1, 3}, {2, 4}, {3, 5}])
```

Expand All @@ -125,24 +142,21 @@ Filter bindings by Elixir type or list membership:

```elixir
# Keep only integer values from a mixed-type relation
Rule.new(
Atom.new("int_value", [Term.var("X")]),
[{:positive, Atom.new("value", [Term.var("X")])}],
[Constraint.type_integer(Term.var("X"))]
Program.add_rule(program, {"int_value", [:N, :V]},
[{:positive, {"value", [:N, :V]}}],
[{:is_integer, :V}]
)

# Keep only "primary" colors
Rule.new(
Atom.new("primary_color", [Term.var("X")]),
[{:positive, Atom.new("color", [Term.var("X")])}],
[Constraint.member(Term.var("X"), {:const, [:red, :blue, :green]})]
Program.add_rule(program, {"primary_color", [:X]},
[{:positive, {"color", [:X]}}],
[{:member, :X, [:red, :blue, :green]}]
)

# Keep only strings that start with "hel"
Rule.new(
Atom.new("hello_word", [Term.var("X")]),
[{:positive, Atom.new("word", [Term.var("X")])}],
[Constraint.starts_with(Term.var("X"), {:const, "hel"})]
Program.add_rule(program, {"hello_word", [:X]},
[{:positive, {"word", [:X]}}],
[{:starts_with, :X, "hel"}]
)
```

Expand All @@ -151,15 +165,10 @@ Rule.new(
Use negative body atoms with stratified evaluation. Find people who are not parents:

```elixir
Program.add_rule(
Rule.new(
Atom.new("childless", [Term.var("X")]),
[
{:positive, Atom.new("person", [Term.var("X")])},
{:negative, Atom.new("parent", [Term.var("X"), Term.wildcard()])}
]
)
)
Program.add_rule(program, {"childless", [:X]}, [
{:positive, {"person", [:X]}},
{:negative, {"parent", [:X, :_]}}
])
```

### ETS backend
Expand All @@ -168,11 +177,11 @@ For workloads exceeding ~100K facts, use the ETS backend for off-heap storage
and reduced GC pressure:

```elixir
{:ok, result} = ExDatalog.query(program, storage: ExDatalog.Storage.ETS)
{:ok, knowledge} = ExDatalog.materialize(program, storage: ExDatalog.Storage.ETS)

# Or with options:
{:ok, result} =
ExDatalog.query(program,
{:ok, knowledge} =
ExDatalog.materialize(program,
storage: ExDatalog.Storage.ETS,
storage_opts: [access: :public, write_concurrency: true]
)
Expand All @@ -183,18 +192,18 @@ and reduced GC pressure:
Track which rule derived each fact:

```elixir
{:ok, result} = ExDatalog.query(program, explain: true)
result.provenance.fact_origins
{:ok, knowledge} = ExDatalog.materialize(program, explain: true)
knowledge.provenance.fact_origins
#=> %{"ancestor" => %{{:alice, :bob} => "rule_0", ...}, ...}
```

### Telemetry

ExDatalog emits `:telemetry` events at the start, end, and on exceptions
during query evaluation:
during materialization:

```elixir
:telemetry.attach("my-handler", [:ex_datalog, :query, :stop], &handle_stop/4, nil)
:telemetry.attach("my-handler", [:ex_datalog, :materialize, :stop], &handle_stop/4, nil)

def handle_stop(_event, measurements, metadata, _config) do
IO.puts("Query completed in #{measurements.duration} µs (#{measurements.iterations} iterations)")
Expand Down Expand Up @@ -223,7 +232,7 @@ ExDatalog.Engine.Naive (semi-naive fixpoint)
ExDatalog.Storage.Map | ExDatalog.Storage.ETS
|
v
ExDatalog.Result
ExDatalog.Knowledge
```

The `Storage` behaviour defines the contract for pluggable backends.
Expand Down Expand Up @@ -271,6 +280,8 @@ reference.
- [What is Datalog?](docs/what-is-datalog.md) — introduction, history, Prolog comparison, industry use cases, LLM integration
- [Constraints](docs/constraints.md) — constraint types, evaluation, and the dispatch model
- [Storage Backends](docs/storage_backends.md) — Map vs ETS, options, capabilities, determinism guarantee
- [Quickstart Tutorial](livebook/quickstart.livemd) — interactive Livebook walkthrough
- [Examples](livebook/examples.livemd) — 10 realistic use cases (RBAC, supply chain, fraud detection, and more)
- [API reference](https://hexdocs.pm/ex_datalog) — full module and function documentation

Generate docs locally:
Expand Down Expand Up @@ -337,9 +348,9 @@ The following references are highly recommended for understanding both the theor

| Version | Description |
|---|---|
| v0.2.0 | ETS backend, constraint behaviour, type/string/membership predicates, capabilities, provenance, telemetry |
| v0.3.0 | Aggregation (`count`, `sum`, `min`, `max`), general predicates as deterministic BEAM callbacks |
| v0.4.0 | Magic sets / demand-driven evaluation, external solver adapter (experimental Z3/Soufflé) |
| v0.3.0 | Tuple shorthand for rules (`add_rule/3`, `add_rule/4`), `Term.from/1`, `ExDatalog.Atom.from_tuple/1`, `Constraint.from_tuple/1`; renamed `Result` → `Knowledge`, `query` → `materialize` |
| v0.4.0 | Sigil DSL (`~d`), aggregation (`count`, `sum`, `min`, `max`), general predicates as deterministic BEAM callbacks |
| v0.5.0 | Magic sets / demand-driven evaluation, external solver adapter (experimental Z3/Soufflé) |
| v1.0.0 | Stable public API, hardened production semantics |

## License
Expand Down
Loading
Loading