Rete

CI

This project is a forward-chaining rules engine for Elixir, based on the Rete algorithm. A rule reads as a function: its arguments are the conditions, and its body is what follows.

Use it where the logic is a pile of interacting conditions: pricing, eligibility, alerting, policy, validation, or diagnosis. This is the code that becomes a nest of cond clauses nobody wants to touch. The question that matters here is not "what happens next" but "what is true now".

defrule dormant({:customer, cid, name}, {:not, [{:order, cid, _}]}) do
{:dormant, name}
end

Pattern matching in the argument list gives you destructuring, variable binding, and join-variable identification for free. cid appearing in two conditions is the join.

Everything a rule concludes is truth-maintained. Retract the order, and the customer becomes dormant again. You do no bookkeeping yourself.

Installation

def deps do
[
{:rete, "~> 0.2.0"}
]
end

Documentation: https://hexdocs.pm/rete. The rule-writing reference is docs/dsl.md.

How a Rete engine thinks

Most Elixir code is a pipeline: you call a function, it returns a value, and control moves on. A rules engine inverts that model.

You put facts into a session. The engine works out which rules match them and what those rules conclude. It keeps the conclusions consistent as the facts change. You never call a rule yourself.

Four ideas carry the whole model.

Facts are plain data. A fact is a tagged tuple, a struct, or a map with a __type__ key. There is no fact API. {:order, 1, 250} is a fact.

A rule is a pattern over several facts at once. The engine matches its conditions independently, then joins them on the variables they share.

A cond clause must name the order it checks things in. A rule instead states the shape of the world it cares about. The engine finds every combination of facts with that shape. One combination is one match. One match fires the rule once.

Nothing runs until you say so.insert/2 propagates facts through the network and queues the matches it finds. fire_rules/2 runs the queued matches.

This lets you reason about a batch of facts together. Otherwise each fact would trigger a cascade of its own.

Conclusions are held up by their support, not by having happened. When a rule fires, the engine inserts the facts it returns logically. It remembers which match produced each fact.

If you take away any fact behind that match, the conclusion is withdrawn. Anything concluded from that conclusion is withdrawn too, until the session settles. A pile of functions cannot give you this. It is also why a rule's right hand side can only insert facts: keeping a conclusion true as the world changes is the engine's job, not yours.

The name comes from the algorithm underneath. Rete compiles the rules into a network that shares work between them.

A condition written in four rules is matched once per fact. The network remembers partial matches. Because of this, inserting one fact costs work proportional to what that fact actually affects, not to the size of the rulebase.

A worked example

defmodule Retail do
use Rete.Ruleset
# An online order is a kind of order, so rules about orders see both.
derive :online_order, :order
# An order over the current threshold is large.
defrule large_order({:threshold, limit}, {:order, cid, amt} when amt > limit) do
{:large_order, cid, amt}
end
# Add up everything a customer ordered.
defrule spend({:customer, cid, name}, orders = [{:order, cid, _amt}]) do
{:spend, name, Enum.sum(for {_, _, amt} <- orders, do: amt)}
end
# A customer with no orders at all.
defrule dormant({:customer, cid, name}, {:not, [{:order, cid, _}]}) do
{:dormant, name}
end
defquery large_orders({:large_order, cid, amt}) do
{cid, amt}
end
end

This example uses four left hand side forms:

cid is the join variable throughout.

Insert

session =
Rete.Session.new([Retail])
|> Rete.Session.insert([
{:threshold, 100},
{:customer, 1, "Ada"},
{:customer, 2, "Bo"},
{:order, 1, 250},
{:order, 1, 40},
{:online_order, 2, 30}
])
Rete.Session.facts(session)
#=> the six facts above, and nothing else
length(Rete.Session.pending(session))
#=> 3

Nothing has been concluded, because nothing has fired. What has happened is the matching. Three activations are queued: large_order for Ada's 250, and spend for each customer. Each activation carries the bindings it matched:

%Rete.Activation{
node_id: 3,
token: %Rete.Token{
matches: [{{:threshold, 100}, 1}, {{:order, 1, 250}, 2}],
bindings: %{limit: 100, cid: 1, amt: 250}
},
salience: 0,
...
}

salience is firing priority. A rule declared defrule urgent(%{salience: 10}, ...) fires before one at the default value of 0. Every activation at one salience level fires before any activation at a lower one. See docs/dsl.md#options-salience for more.

Fire

fire_rules/2 also takes :max_cycles, :concurrency, and :timeout. See its own doc and docs/design/engine.md §11 for more.

session = Rete.Session.fire_rules(session)
Rete.Session.facts(session) |> Enum.sort()
#=> [
# {:customer, 1, "Ada"},
# {:customer, 2, "Bo"},
# {:large_order, 1, 250},
# {:online_order, 2, 30},
# {:order, 1, 40},
# {:order, 1, 250},
# {:spend, "Ada", 290},
# {:spend, "Bo", 30},
# {:threshold, 100}
# ]

Three things to notice here:

  1. {:order, 1, 40} did not produce a :large_order. The guard runs against the threshold fact, so changing the threshold changes the answer.
  2. {:spend, "Ada", 290} is one activation over a list of two orders, not two activations.
  3. Bo's spend counts an {:online_order, ...} fact too. derive :online_order, :order puts it under :order in the taxonomy, so the rule matches it even though the rule never mentions online orders.

Nobody is dormant, because both customers have orders.

Query

A query has the same left hand side as a rule, but it never fires. It holds the matches that reached it. It is a function in its own module, so you read it back by calling it.

Retail.large_orders(session)
#=> [{1, 250}]
Retail.large_orders(session, cid: 1)
#=> [{1, 250}]

A query returns what its body computes, one result per match. It answers in whatever shape suits the caller, instead of handing back raw bindings.

There is nothing to declare. You can constrain any variable the left hand side binds, at call time. Naming a variable the left hand side does not bind raises an error, instead of quietly answering [].

A query is identified by {module, name}, never by a bare name. Because of this, two rulesets that each define a :summary compose into one session without collision.

When you choose the query at runtime, name the pair: Rete.Session.query(session, {Retail, :large_orders}, cid: 1).

Retract

session =
session
|> Rete.Session.retract({:order, 1, 250})
|> Rete.Session.fire_rules()
Rete.Session.facts(session) |> Enum.sort()
#=> [
# {:customer, 1, "Ada"},
# {:customer, 2, "Bo"},
# {:online_order, 2, 30},
# {:order, 1, 40},
# {:spend, "Ada", 40},
# {:spend, "Bo", 30},
# {:threshold, 100}
# ]

{:large_order, 1, 250} is gone. Nothing retracted it directly. Its support went away.

{:spend, "Ada", 290} is gone too, replaced by {:spend, "Ada", 40}. The collection is part of the match, so a different list is a different match.

Retract the rest of the facts. The dormancy rules take over:

session =
session
|> Rete.Session.retract([{:order, 1, 40}, {:online_order, 2, 30}])
|> Rete.Session.fire_rules()
Rete.Session.facts(session) |> Enum.sort()
#=> [
# {:customer, 1, "Ada"},
# {:customer, 2, "Bo"},
# {:dormant, "Ada"},
# {:dormant, "Bo"},
# {:spend, "Ada", 0},
# {:spend, "Bo", 0},
# {:threshold, 100}
# ]

The :spend rule still fires, with an empty list. Its collection introduces no variable of its own. See the empty-collection rule in docs/dsl.md for why.

:dormant fires now that the negation holds.

Ask why

Rete.Inspect.explain(session, {:dormant, "Ada"})
#=> [
# %{
# fact: {:dormant, "Ada"},
# rule: :dormant,
# origin: :derived,
# bindings: %{cid: 1, name: "Ada"},
# supports: [%{fact: {:customer, 1, "Ada"}, rule: nil}]
# }
# ]

Each entry is one independent support. A fact concluded twice needs both supports to go before the fact itself goes.

Rete.Inspect also has:

For history instead of a snapshot, attach Rete.Listener.Collect or Rete.Listener.Trace.

Sessions are values

Every operation returns a new session and changes nothing:

quiet = Rete.Session.new([Retail]) |> Rete.Session.insert({:customer, 1, "Ada"})
busy = quiet |> Rete.Session.insert({:order, 1, 250}) |> Rete.Session.fire_rules()
# `quiet` is untouched. Reuse it as a checkpoint.

The compiled network is shared, not copied. Because of this, a session is cheap to hold, cheap to fork, and safe to pass between processes.

Compiling is the expensive part. Do it once, with Rete.Compiler.build/2. Start sessions from the result with Rete.Session.from_network/1.

What is public

Seven modules are public. They are the ones the examples above use:

modulefor
Reteaggregating rule, expression and taxonomy data across ruleset modules
Rete.Rulesetdefrule, defquery, derive, underive
Rete.Sessionbuilding a session, inserting, retracting, firing, querying
Rete.Inspectexplain/2, fired/2, why_not/2, collection/3
Rete.Listener (+ .Collect, .Trace)watching what a session does

Everything else is internal: the DSL front end, the IR, the compiler, the network, the engine, working memory, the agenda, and the value structs.

It is documented, because durability, checkpointing, and tooling will eventually need to reach in. docs/design/ carries the reasoning behind it. Semantic versioning does not cover this internal part. It may change in a patch release. The generated docs group it under Internals: headings for this reason.

Two internal structs do reach you through the public API. Rete.Session.pending/1 returns Rete.Activation structs. Every listener event carries a Rete.Token.

Read their fields freely. Do not depend on their functions. Expect the field sets to change.

Limitations

Correctness and DSL clarity came first. Several things were left out on purpose. The design docs under docs/design/ record why.

Development

mix deps.get
mix compile --warnings-as-errors
mix test
mix format --check-formatted
mix credo --strict
mix dialyzer

CI runs exactly those, on the declared floor (Elixir 1.18) and on the current release.

mix bench

Scaling benchmarks report the empirical exponent: the k in O(n^k), read from the growth between one size and the next. This is more useful than a wall-clock figure, since nobody has a baseline for that.

This engine's real failure mode is not a slow function. It is an operation that proves quadratic in something a session accumulates. A single-size measurement cannot show this.

Around ~n^1 is fine. ~n^2 is a bug, unless docs/design/ already lists it as a known gap.

Wall-clock thresholds are not in CI. On shared runners, they fail for reasons that mean nothing.

Acknowledgements

Clara is the semantic reference for this engine. When a question about behaviour had no obvious answer, Clara's answer became the specification. Examples of such questions:

Two of Clara's issues are implemented and regression-tested here: issue 433 on node sharing, and issue 304 on scoped negation markers.

This engine ports none of Clara's code. Clara is Clojure on the JVM, and its architecture reflects that: transient-versus-persistent memory, a transport abstraction, four activation protocols, and listener calls scattered through every node.

This engine does the BEAM-native thing instead: a flat propagation loop over an explicit work queue, one immutable memory threaded through a fold, and events emitted in exactly one place.

taxo provides the type hierarchies behind derive and underive.

Licence

Apache-2.0. See LICENSE.