ExDatalog.Program (ExDatalog v0.4.1)

Copy Markdown View Source

Builder for constructing Datalog programs.

A program holds:

  • Relations — named schemas with arity and type information.
  • Facts — ground tuples asserted as true for a given relation.
  • Rules — inference rules that derive new facts from existing ones.

Relation names are string keys stored in a map. They do not collide with internal atoms like :positive, :negative, :wildcard, or :constraint because those atoms appear in tuple positions ({:positive, atom}), not as map keys.

Programs are built using a pipeline of builder functions. Structural validation (arity checking, relation existence) is done at build time by add_relation/3, add_fact/3, and add_rule/2. On failure these return {:error, String.t()} with a human-readable message.

Error propagation in pipelines

Because builder functions return t() | {:error, String.t()}, a failed step will short-circuit the rest of the pipeline: the {:error, _} tuple passes through unchanged. This means you can pipe freely and check for errors at the end:

{:ok, knowledge} =
  Program.new()
  |> Program.add_relation("edge", [:atom, :atom])
  |> Program.add_relation("path", [:atom, :atom])
  |> Program.add_fact("edge", [:a, :b])
  |> Program.add_rule({"path", [:X, :Y]}, [{:positive, {"edge", [:X, :Y]}}])
  |> ExDatalog.materialize()

If add_relation/3 fails, the {:error, msg} tuple flows through add_fact/3 and add_rule/3 without raising, and ExDatalog.materialize/1 will detect the error struct and return {:error, [msg]}.

Semantic validation (variable safety, stratification, constraint binding) is done separately by ExDatalog.Validator.validate/1, which returns {:error, [ExDatalog.Validator.Error.t()]} with structured error structs.

Note: builder methods perform a subset of the same checks as the validator (relation existence, arity). This is intentional: the builder provides early feedback for interactive construction, while the validator is the canonical source of truth and catches issues the builder cannot (e.g., programs assembled by directly modifying the struct, which bypasses builder validation).

Shorthand rule notation

add_rule/3 and add_rule/4 accept a more ergonomic tuple-based notation that avoids the need for explicit Rule.new/3, ExDatalog.Atom.new/2, and Term.var/1 calls:

  • Head{"relation", [terms...]} where each term follows the Prolog convention: uppercase atoms become variables (:X{:var, "X"}), :_ becomes a wildcard, lowercase atoms and other values become constants.

  • Body{:positive, {"rel", [terms...]}} or {:negative, {"rel", [terms...]}} for each literal.

  • Constraints — operator tuples like {:neq, :A, :B} for comparisons, {:add, :X, :Y, :Z} for arithmetic, {:is_integer, :V} for type predicates, {:starts_with, :E, "prefix"} for string predicates, and {:member, :X, [:a, :b]} for membership.

    Program.add_rule(program,

    {"ancestor", [:X, :Z]},
    [
      {:positive, {"parent", [:X, :Y]}},
      {:positive, {"ancestor", [:Y, :Z]}}
    ]

    )

    Program.add_rule(program,

    {"high_earner", [:X]},
    [{:positive, {"income", [:X, :S]}}],
    [{:gt, :S, 100_000}]

    )

The struct-based add_rule/2 remains available for cases where you need full control over term types.

Example

iex> alias ExDatalog.{Program, Atom, Rule, Term}
iex> program =
...>   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_rule(
...>        {"ancestor", [:X, :Y]},
...>        [{:positive, {"parent", [:X, :Y]}}]
...>      )
iex> length(program.facts) == 2
true
iex> length(program.rules) == 1
true

Summary

Functions

Adds a ground fact to the program.

Adds a relation schema to the program.

Adds a rule to the program.

Adds a rule using shorthand notation for the head atom, body literals, and constraints.

Returns true if the relation is defined in the program.

Creates a new, empty Datalog program.

Returns the schema for a relation, or nil if not defined.

Types

body_literal_shorthand()

@type body_literal_shorthand() ::
  {:positive | :negative, {String.t(), [ExDatalog.Term.shorthand()]}}
  | {:positive | :negative, ExDatalog.Atom.t()}
  | ExDatalog.Rule.literal()

constraint_shorthand()

@type constraint_shorthand() :: tuple() | ExDatalog.Constraint.t()

fact_values()

@type fact_values() :: [ExDatalog.Term.value()]

head_shorthand()

@type head_shorthand() ::
  {String.t(), [ExDatalog.Term.shorthand()]} | ExDatalog.Atom.t()

ir_type()

@type ir_type() :: :integer | :string | :atom | :any

relation_name()

@type relation_name() :: String.t()

relation_schema()

@type relation_schema() :: %{arity: non_neg_integer(), types: [ir_type()]}

t()

@type t() :: %ExDatalog.Program{
  facts: [{relation_name(), fact_values()}],
  relations: %{required(relation_name()) => relation_schema()},
  rules: [ExDatalog.Rule.t()]
}

Functions

add_fact(program, relation, values)

@spec add_fact(t(), relation_name(), fact_values()) :: t() | {:error, String.t()}

Adds a ground fact to the program.

The relation must be declared via add_relation/3 and the number of values must match the relation's arity.

Returns {:error, reason} if:

  • The relation is not defined.
  • The arity of values does not match the relation schema.
  • A value in values is not an integer, string, or atom (floats are not supported).

Examples

iex> alias ExDatalog.Program
iex> program = Program.new() |> Program.add_relation("parent", [:atom, :atom])
iex> Program.add_fact(program, "parent", [:alice, :bob])
%ExDatalog.Program{
  relations: %{"parent" => %{arity: 2, types: [:atom, :atom]}},
  facts: [{"parent", [:alice, :bob]}],
  rules: []
}

iex> alias ExDatalog.Program
iex> program = Program.new() |> Program.add_relation("parent", [:atom, :atom])
iex> {:error, _} = Program.add_fact(program, "unknown", [:alice])
{:error, "relation \"unknown\" is not defined"}

add_relation(program, name, types)

@spec add_relation(t(), relation_name(), [ir_type()]) :: t() | {:error, String.t()}

Adds a relation schema to the program.

types is a list of type atoms (:integer, :string, :atom, :any) with length equal to the arity of the relation.

Returns {:error, reason} if:

  • name is empty.
  • types is empty.
  • The relation already exists.

Examples

iex> ExDatalog.Program.add_relation(ExDatalog.Program.new(), "parent", [:atom, :atom])
%ExDatalog.Program{
  relations: %{"parent" => %{arity: 2, types: [:atom, :atom]}},
  facts: [],
  rules: []
}

iex> {:error, _} = ExDatalog.Program.add_relation(ExDatalog.Program.new(), "", [:atom])
{:error, "relation name must be a non-empty string"}

add_rule(program, rule)

@spec add_rule(t(), ExDatalog.Rule.t()) :: t() | {:error, String.t()}

Adds a rule to the program.

Performs structural validation:

  • The head relation must be declared.
  • The head arity must match the relation schema.
  • All body atoms must reference declared relations with matching arities.

Semantic validation (variable safety, stratification) is deferred to ExDatalog.Validator.

Returns {:error, reason} if any structural check fails.

Examples

iex> alias ExDatalog.{Program, Rule, Atom, Term}
iex> program =
...>   Program.new()
...>   |> Program.add_relation("parent", [:atom, :atom])
...>   |> Program.add_relation("ancestor", [:atom, :atom])
iex> rule = Rule.new(
...>   Atom.new("ancestor", [Term.var("X"), Term.var("Y")]),
...>   [{:positive, Atom.new("parent", [Term.var("X"), Term.var("Y")])}]
...> )
iex> result = Program.add_rule(program, rule)
iex> length(result.rules) == 1
true

add_rule(program, head, body, constraints \\ [])

@spec add_rule(t(), head_shorthand(), [body_literal_shorthand()], [
  constraint_shorthand()
]) ::
  t() | {:error, String.t()}

Adds a rule using shorthand notation for the head atom, body literals, and constraints.

This is a more ergonomic alternative to add_rule/2 that avoids the need for explicit Rule.new/3, ExDatalog.Atom.new/2, and ExDatalog.Term.var/1 calls.

The head is a tuple {"relation", [terms...]} where each term follows the Prolog-inspired convention:

  • Uppercase atoms become logic variables (:X{:var, "X"})
  • :_ becomes a wildcard
  • Lowercase atoms and other values become constants

Each body literal is {:positive, {"rel", [terms...]}} or {:negative, {"rel", [terms...]}}. You may also mix in structs like {:positive, ExDatalog.Atom.new(...)}.

Each constraint is an operator tuple like {:neq, :A, :B} or {:add, :X, :Y, :Z}. You may also use existing %Constraint{} structs.

Returns {:error, reason} if any structural check fails (same validation as add_rule/2).

Examples

iex> alias ExDatalog.Program
iex> program =
...>   Program.new()
...>   |> Program.add_relation("parent", [:atom, :atom])
...>   |> Program.add_relation("ancestor", [:atom, :atom])
iex> result = Program.add_rule(program,
...>   {"ancestor", [:X, :Y]},
...>   [{:positive, {"parent", [:X, :Y]}}]
...> )
iex> length(result.rules) == 1
true

iex> alias ExDatalog.Program
iex> program =
...>   Program.new()
...>   |> Program.add_relation("income", [:atom, :integer])
...>   |> Program.add_relation("high_earner", [:atom])
iex> result = Program.add_rule(program,
...>   {"high_earner", [:X]},
...>   [{:positive, {"income", [:X, :S]}}],
...>   [{:gt, :S, 100_000}]
...> )
iex> length(result.rules) == 1
true

has_relation?(program, name)

@spec has_relation?(t(), relation_name()) :: boolean()

Returns true if the relation is defined in the program.

Examples

iex> alias ExDatalog.Program
iex> program = Program.new() |> Program.add_relation("parent", [:atom, :atom])
iex> Program.has_relation?(program, "parent")
true

iex> alias ExDatalog.Program
iex> Program.has_relation?(Program.new(), "unknown")
false

new()

@spec new() :: t()

Creates a new, empty Datalog program.

Examples

iex> ExDatalog.Program.new()
%ExDatalog.Program{relations: %{}, facts: [], rules: []}

relation(program, name)

@spec relation(t(), relation_name()) :: relation_schema() | nil

Returns the schema for a relation, or nil if not defined.

Examples

iex> alias ExDatalog.Program
iex> program = Program.new() |> Program.add_relation("parent", [:atom, :atom])
iex> Program.relation(program, "parent")
%{arity: 2, types: [:atom, :atom]}

iex> alias ExDatalog.Program
iex> Program.relation(Program.new(), "unknown")
nil