Packages
A forward-chaining Rete rules engine for Elixir, with a pattern-matching DSL in which a rule reads as a function: its arguments are the conditions and its body is what follows.
Current section
Files
Jump to
Current section
Files
docs/design/network.md
# The compiled network
What the build phase produces and the engine runs. The companion to `ir.md`,
which covers everything up to a classified `Rete.IR.Production`, and to
`engine.md`, which covers what happens to a network once it exists.
Status: implemented, end to end.
---
## 1. Pipeline
The DSL front end runs at **compile** time, inside each `defrule`. What is left
for **build** time is the part that depends on the whole rule set rather than on
one rule:
```
Rete.Compiler.build([MyRuleset])
|> Rete.Compiler.Negation.extract/1 rewrite compound negations into helpers
|> Rete.Compiler.BetaGraph.build/1 beta nodes and edges, shared where equal
|> Rete.Network.new/3 alpha nodes grouped by code, taxonomy indexed
```
Node sharing is why this cannot happen per rule: whether two conditions collapse
onto one node depends on what every other rule already put in the graph.
---
## 2. Structure
```elixir
%Rete.Network{
alphas: %{code => %Node.Alpha{}}, # one per distinct expression code
alpha_beta: %{code => [beta_id]}, # the beta nodes each alpha feeds
taxonomy: %Rete.Taxonomy{}, # indexed: fact type => alpha codes
graph: %Rete.Compiler.BetaGraph{}, # beta nodes and their edges
queries: %{name => beta_id},
productions: [%Rete.IR.Production{}] # including generated helpers
}
```
The network is built once and never mutated, so any number of sessions can share
one. Working memory, the agenda and pending propagations are the engine's, not
the network's.
### How a fact travels
1. `Rete.Taxonomy.alpha_ids/2` maps the fact's type to alpha codes. **This is the
only place the taxonomy is consulted.**
2. Each alpha's arity 1 function turns the fact into a bindings map, or `nil`.
It matches a fact of *any* type on purpose — the type decision was step 1.
3. Matching elements go to the beta nodes in `alpha_beta`, and the engine
propagates along the graph's forward edges.
---
## 3. Node kinds
Nodes are **data**; activation lives in `Rete.Engine.Nodes`. No node carries its
children: they are the graph's forward edges, because a node is shared precisely
when it is *equal*, and equality must not depend on how many rules happen to have
hung children off it.
| Node | Produced by | What the engine does with it |
|---|---|---|
| `Alpha` | any condition with a pattern | run `:fun` on a fact, `(fact) -> bindings \| nil` |
| `RootJoin` | first condition of a rule | turn each element straight into a token |
| `HashJoin` | later condition, no cross-condition guard | join tokens to elements on equality of `:join_bind` |
| `ExprJoin` | later condition with a cross-condition guard | hash join, then `:filter.(token_bindings, fact_bindings)` |
| `Negation` | `{:not, [one_condition]}` | propagate the token while **no** element matches on `:join_bind` |
| `NegationJoin` | negation with a cross-condition guard | as above, with the filter deciding what counts as a match |
| `Accumulate` | a collection binding | gather matching elements into a list under `:coll_binding` |
| `AccumulateJoin` | collection with a cross-condition guard | as above; candidates cannot be reduced until a token exists |
| `Test` | a rule level `when` | propagate the token when `:fun.(bindings)` is truthy |
| `Production` | a rule terminal | call `:rhs.(hash, bindings)`, logically insert the result |
| `Query` | a query terminal | hold the tokens that reached it, keyed for lookup |
### `:propagates_empty?` on the accumulate nodes
The locked empty-collection rule, precomputed. Note that `:new_bind` for a
collection counts only *participating* variables: one that no other condition's
pattern matches on is local to the collection and excluded, so a guarded
collection over otherwise-local variables is ungrouped and does propagate `[]`.
See the `Rete.IR.Coll` section of `ir.md`.
* `true` when the pattern introduces **no** new variables. Every variable it uses
is already fixed by the token, so there is exactly one group and the node
propagates `[]` when nothing matches — the rule fires with an empty list.
* `false` when it introduces one. It groups by that variable, and a group only
exists where a fact created it, so there is no empty group to invent.
### Collection element order is unspecified
**A rule may not depend on the order of the list it receives.** Sort in the right
hand side if order matters.
The engine does in fact keep collections in a deterministic order — elements are
inserted by term order rather than appended on arrival, so the same fact set
always produces the same list whatever order the facts arrived in, and a
retract-and-reinsert round trip restores it exactly. That is deliberate: without
it a rule that returns its collection would produce a different fact depending on
insertion order, and the engine's order-independence property would hold for
every rule except that one.
But it is an implementation guarantee, not a contract. Term order is arbitrary
from the author's point of view, and nothing about it is a useful thing to build
on.
---
## 4. Sharing
Two conditions collapse onto one node when they are **equal** and have the
**same parent set**. Equality alone is not enough, and the difference is a
correctness bug, not a missed optimisation:
```elixir
defrule a({:customer, cid}, {:order, cid, amt})
defrule b({:vendor, cid}, {:order, cid, amt})
```
The two `{:order, cid, amt}` conditions are equal but sit under different
parents. Sharing them would let a token from `{:vendor, ...}` join elements that
only ever belonged to `{:customer, ...}`, so `a` would fire on `b`'s facts.
Clara records the same requirement as issue 433.
Equality is `Rete.Network.Node.sharing_key/1`, built from **expression codes** —
never from captured functions, which are never equal, and never from a struct
holding `:__ast__`, which would compare quoted AST that is not part of identity.
W1 guarantees a code is deterministic across compilations and equal exactly when
behaviour is equal, which is what makes sharing reproducible between a full build
and an incremental one.
A terminal keys on its production's identity, so two rules with an identical left
hand side still get one terminal each and fire independently.
**Alpha sharing** is the same idea one level up: conditions are grouped by
expression code, so a condition written in four rules is matched once per fact.
`{:order, cid, _amt}` and `{:order, cid, _}` share, because W1 canonicalises
discarded variables.
---
## 5. Disjunctions
`{:or, [b1, b2]}` adds each branch as its own chain under the current parents and
hands the **union of the branch terminals** to the next condition. That is why a
condition is added under a *list* of parent ids: the condition after a
disjunction has one parent per branch, and the branches re-converge on it.
This is also why the left hand side is never flattened to DNF. Whole-LHS DNF is
exponential in the number of disjunctions; fanning out and re-converging per
condition is linear.
---
## 6. Compound negation extraction
A negation node watches one condition. It cannot watch a conjunction, and de
Morgan does not rescue it: `not(and(a, b)) = or(not a, not b)` is sound
propositionally, but rule conditions share existentially quantified variables, so
with orders `{1}` and refunds `{2}` the original is true and the rewrite is false.
`Rete.DSL.Normalize` therefore leaves a `CompoundNegation` for `Rete.Compiler.Negation`.
```elixir
defrule clean({:customer, cid}, {:nand, [{:order, cid}, {:refund, cid}]})
# becomes, in effect
defrule clean__neg_1({:customer, cid}, {:order, cid}, {:refund, cid}) do
{:"...clean__neg_1", %{cid: cid}} # the marker
end
defrule clean({:customer, cid}, {:not, [{:"...clean__neg_1", cid}]})
```
Three properties make this correct:
* **The marker is scoped.** It carries the ancestor bindings the conjunction
joins on, and the negation matches on them. Otherwise one customer with both an
order and a refund would suppress the rule for *every* customer — the negation
would ask "does any match exist" instead of "does one exist for this `cid`".
Clara's issue 304.
* **The helper repeats the prefix**, which is what binds those variables, and
means the marker is only produced for groups that reached the negation.
* **The helper fires first**, via `:internal_salience`. Otherwise the negating
rule observes an absence that had merely not been computed yet, fires, and is
retracted by truth maintenance — a visible spurious activation.
The marker's *type* is the generated name, so the alpha index routes it to
exactly one place. Its alpha is the one exception to "an alpha matches a fact of
any type": it checks the tag, because a marker is engine machinery rather than a
user fact and a stray tuple of the same shape must not be mistaken for one.
Extraction runs after macro expansion, so a helper's expressions are plain
closures wrapped in `Rete.IR.Expr` rather than functions generated into a module.
---
## 7. What the engine must honour
* **Activation order** is `{salience, internal_salience}` descending, then node
id. `Rete.Network.production_nodes/1` already returns them in that order.
Ignoring `:internal_salience` breaks extracted negations.
* **Only logical inserts.** A production's `:rhs` returns facts to insert, truth
maintained. There is no unconditional insert and no RHS retract. Session level
retract exists, and TMS cascades from it.
* **Taxonomy is the index's job.** Never re-check a fact's type inside an alpha.
* **`:propagates_empty?`** decides whether an accumulate node emits `[]`.
* A `Test` node has no fact input, so it neither joins nor binds.
---
## 8. Known gaps
* **No subsumption between rules.** Two rules whose left hand sides differ only
in a redundant condition build separate chains.
* **Sharing is prefix-only.** Two rules that share a *suffix* but not a prefix do
not share anything, which is inherent to how a beta network is built.