Status: implemented, end to end.
Modules:
| module | file | phase |
|---|---|---|
Rete.IR | lib/rete/ir.ex | the shared data |
Rete.DSL.Parser | lib/rete/dsl/parser.ex | W1, quoted AST → IR |
Rete.DSL.Normalize | lib/rete/dsl/normalize.ex | W2a, gate normalization |
Rete.DSL.Bindings | lib/rete/dsl/bindings.ex | W2b, binding classification and guard splitting |
Rete.DSL.Codegen | lib/rete/dsl/codegen.ex | W3, expression construction and code generation |
Rete.Ruleset | lib/rete/ruleset.ex | the macros that drive all of it |
This document is the contract between the compile phases, and between the front end and
the network builder, which is docs/design/network.md.
1. Pipeline
quoted DSL
|> Rete.DSL.Parser.parse_production/4 # W1 AST -> IR, gates are placeholders
|> Rete.DSL.Normalize.normalize_lhs/1 # W2a gates -> conditions, (compound) negations, {:or, ...}
|> Rete.DSL.Bindings.classify/2 # W2b join_bind / new_bind / join_filter
|> Rete.IR.lhs_bindings/1 # W2c recompute the production's :bind
|> Rete.DSL.Codegen.compile/1 # W3 emit functions, escape into the module
|> Rete.Compiler.build/2 # build time, see network.mdThe first three steps take a %Rete.IR.Production{}, and return a
%Rete.IR.Production{}. All of this happens inside the defrule/defquery macro
expansion, so a phase may freely read and rewrite quoted AST. Rete.IR.escape/1 is the
boundary — Codegen.compile/1 calls it. After that boundary, only plain runtime data and
captured functions survive.
Rete.Ruleset.build/4 runs the first four steps, and it is public. So a test can inspect
the classified IR of a declaration, without compiling a module for it.
Rete.Ruleset.defrule/2 is then a two-liner:
env |> Rete.Ruleset.build(decl, body, type) |> Rete.DSL.Codegen.compile()A production written without a do block matches defrule/1 instead, and this raises an
error naming the rule. There is nothing correct to generate here. The RHS would become a
bodiless function head, and the module would then fail to compile with "implementation not
provided for predefined def". That error points at the generated function, and not at the
rule.
Codegen.compile/1 expands to
quote do
unquote_splicing(expr_defs(production)) # def __<code>__(args)
@rule_data @rule_data ++ [unquote(IR.escape(production))]
unquote(rhs_def(production)) # def <name>(hash, bindings)
endThe order matters here. The escaped production captures the expression functions by name, so the compiler must define them first.
Ordering constraint. Bindings.classify/2 raises on a Rete.IR.Gate, on purpose. A
gate's arguments do not all bind, and silently mis-classifying one would produce wrong
join keys. Normalization must therefore run first.
Bindings.classify/2 also raises once :__ast__ has been dropped. So it must run before
Codegen.compile/1. Both checks are idempotent.
What a ruleset module ends up containing
__<code>__/1and__<code>__/2- one function per distinct expression,<query_name>/1,2- one per query, running it against a session,__rhs_<name>__/2- the RHS of each production,get_rule_data/0,get_expr_data/0,get_taxo_data/0,get_version/0.
get_expr_data/0 returns {code, fun} for every expression reachable from the LHS:
alpha expressions, join filters, and tests alike, deduplicated by code.
Rete.get_expr_data/1 merges this data across modules, again deduplicating by code.
2. Struct reference
Rete.IR.Production
| field | type | set by | meaning |
|---|---|---|---|
:name | atom | W1 | rule name; also the name of the generated RHS function |
:type | :rule | :query | W1 | |
:hash | integer | W1 | :erlang.phash2([decl_ast, body_ast]) after module-attribute qualification. decl_ast includes the head |
:opts | keyword | W1 | from the leading options map, e.g. [salience: 100]; [] if absent |
:bind | [atom], sorted | W2c | every variable the LHS can make visible to the RHS, including fact/collection bindings; see below |
:params | [atom], declaration order | W1, checked by W2c | the head of a query: the bindings that key its matches. [] on a rule; see below |
:lhs | Rete.IR.lhs/0 | W1, rewritten by W2 | ordered condition list |
:rhs | (hash, bindings_map -> facts) | nil | escape/1 | nil before escaping |
:module | module | W1 | defining module |
:__ast__ | %{bind: %{atom => quoted}, decl: quoted, body: quoted} | W1, narrowed by W2c | compile-time only, dropped by escape/1 |
:bind is a product of the pipeline
:bind is not a pre-pass over the declaration. The parser cannot compute it. At that
point, every variable of every element looks like a binding. A variable that only occurs
inside a negation, or inside one branch of a disjunction, or only in a rule-level guard,
would all get swept up. The generated RHS head would then demand keys no token carries —
so the production could never fire.
Rete.Ruleset.build/4 therefore recomputes it after classification, with
{guaranteed, optional} = Rete.IR.lhs_bindings(production.lhs)
bind = Enum.sort(guaranteed ++ optional)It then narrows __ast__.bind to the same key set. The two halves are:
- guaranteed — bound on every path through the LHS, so the key is in every token.
Rete.IR.bound_vars/1accumulates this for each element. For a disjunction, only the intersection of its branches counts. - optional — bound on some branch of a disjunction, but not on all of them. This is the union of the branches, minus the intersection.
Rete.DSL.Codegen.rhs_def/1 reads the two differently:
defrule either({:or, [{:user, id}, {:admin, level}]}) do {:seen, id, level} end
#=> def either(hash, %{} = bindings) do
# id = Map.get(bindings, :id)
# level = Map.get(bindings, :level)
# {:seen, id, level}
# endA guaranteed binding is destructured in the function head, %{cid: cid}. A token missing
it raises a FunctionClauseError, instead of firing the rule with a hole in it. An
optional binding is read with Map.get/2 instead. It is nil on the branches that do not
bind it. The compile-action of Clara gives the same answer, because it lets every
binding key out of the token map.
Either way, the compiler binds only the variables the body actually reads. So a rule that
ignores a join variable still compiles under --warnings-as-errors.
A variable in neither half — one that only exists inside a negation, or is only read by
a rule-level guard — is not in :bind at all. A body that mentions it fails to compile,
with undefined variable. That is the intended answer: a negation binds nothing
downstream, so there is nothing to hand the RHS.
:params is checked against the same two halves
The parser reads :params from the head as you wrote it. It checks only the shape: bare
variables, no repeated name, and not on a rule. For the reason above, the parser cannot
know yet whether each name is a binding. Rete.Ruleset.build/4 therefore checks this where
it recomputes :bind.
A parameter must be guaranteed. It is not sufficient for it to be in :bind. A
parameter keys every match that the query holds. If a token does not carry one parameter,
Token.join_key(token, params) gives a shorter map. No call could name that bucket,
because a call always supplies every parameter. The engine therefore rejects an optional
binding as a parameter, and the message names the disjunction.
:params keeps declaration order. It is not sorted, because this is the order in which
each message about the query names the parameters. The order does not change the key:
Token.join_key/2 returns a map, and two maps with the same pairs are the same key.
Rete.IR.Fact
| field | type | set by | meaning |
|---|---|---|---|
:type | term (never nil) | W1 | declared fact type, see §4 |
:fact_binding | atom | nil | W1 | the f in f = {:order, id}; not in :bind |
:bind | [atom], sorted | W1 | variables bound by the pattern itself |
:alpha | %Expr{arity: 1, kind: :alpha} | W1, rebuilt by W2b | (fact) -> bindings_map | nil |
:join_filter | %Expr{arity: 2, kind: :join_filter} | nil | W2b | (token_bindings, fact_bindings) -> boolean |
:join_bind | [atom] | nil | W2b | variables shared with upstream conditions (hash-join keys) |
:new_bind | [atom] | nil | W2b | variables first introduced here |
:__ast__ | see §3 | W1 | compile-time only |
join_bind ++ new_bind == bind always holds, and both are sorted, because :bind is
sorted too. The fact binding is in neither: it is not a hash-join key.
Note that the variables a join filter reads from the token side are not in
:join_bind. :join_bind holds only the equality keys the engine indexes on.
Rete.IR.Coll
Same as Fact, with :coll_binding instead of :fact_binding. This is the variable the
collected list binds to — nil for an anonymous [{:order, id}].
:alpha has the same per-fact shape as a Fact alpha. The engine applies it to each
candidate element, never to the whole list.
:inert — collection-local variables
Elixir fuses binding and constraining. Writing amt in a pattern binds it — where Clara
would have written (> amount ?lim) and bound nothing. Taken literally, this makes a
guarded collection impossible to write:
os = [{:order, cid, amt} when amt > lim]amt is a new variable, and a collection that introduces a new variable groups by it. So
this would gather one singleton group per distinct amount, instead of every order over the
limit.
The rule that resolves this: a collection's pattern variable participates only if another condition also matches on it — a real join. Otherwise it is inert.
Inert means local to the collection. The variable constrains which facts are gathered. It
groups nothing, and it binds nothing downstream. It stays in :bind, because the alpha
must still return it, for a join filter to test against. But it is excluded from
:new_bind, and from Rete.IR.bound_vars/1.
Only another condition's pattern counts:
| mentions the variable | counts as a join? |
|---|---|
| another condition's pattern | yes |
| another collection's pattern | yes |
| another condition's guard | no |
| the collection's own guard | no |
the rule level when | no |
| the right hand side | no |
| a negation's pattern | no — a negation binds nothing, so it is not a join |
Reading an inert variable outside its collection is a compile error, naming the variable and the collection. Every gathered fact has its own value, so there is no single value to bind.
Note the interaction with Rete.Compiler.Sort, which defers collections. A plain
condition that matches the variable therefore sorts before the collection. This makes
the variable an ordinary join key, not a grouping variable. So in practice, grouping
arises only between two collections: the sort defers both, and the first groups by
what the second joins on.
To recover per-group firing, you have two options. Add a second collection that matches on
the variable. Or collect everything, and use Enum.group_by/2 in the right hand side —
the same answer this engine already gives for min, max, and sum.
This deliberately differs from Clara, where an accumulator's source condition binds whatever it names. Making the two the same would mean reintroducing explicit binding syntax, and giving up what Elixir pattern matching buys.
Order
The order of the list a rule receives is unspecified. Sort it in the right hand side,
if order matters. The engine keeps collections in a deterministic order, so that order
independence holds, even for a rule that returns its collection. But this is an
implementation guarantee, not a contract. See docs/design/network.md.
Empty-collection semantics, decided by :new_bind
- no new variable: the condition propagates
[], and the rule fires with zero matches. - at least one new variable: the condition groups by those variables, so only non-empty groups exist.
Because an inert variable is not in :new_bind, a guarded collection over otherwise-local
variables propagates [] like any other ungrouped collection.
Rete.IR.Test
| field | type | set by | meaning |
|---|---|---|---|
:bind | [atom], sorted | W1 | variables the guard reads |
:expr | %Expr{arity: 1, kind: :test} | W1 | (bindings_map) -> boolean |
:__ast__ | %{guard: quoted, bind: %{atom => quoted}} | W1 | compile-time only |
A rule-level guard (defrule r(...) when <guard> do) produces this. W1 appends it as the
last LHS element. It stays last, but not necessarily at the top level. When the
branches of a disjunction bind different variables, W2b absorbs everything downstream into
them, so the test ends up as the last element of every branch.
A Test has no fact input, so it binds nothing downstream. Its :bind field is what its
guard reads, and Rete.IR.bound_vars/1 of a Test is always []. So a variable the
test reads never enters the production's :bind on account of the test.
Everything a test reads has to come out of the token. So W2b
(Rete.DSL.Bindings.check_test_vars!/2) rejects at compile time a guard that reads a
variable which no condition binds on its path. That variable is a spelling error, or one
that exists only inside a negation, or one that only some branches of a disjunction bind.
Left alone, such a guard would compile into a function whose argument pattern demands a
key no token carries. It would fall through to false, and the rule would silently never
fire. Because this check runs per path, it catches this case:
defrule r({:or, [{:gold, id, tier}, {:silver, id}]}) when tier > 1
#=> ** (ArgumentError) the rule level guard `tier > 1` reads `tier`, which no
# condition binds on this path through the left hand side.tier > 1 cannot be evaluated on the silver branch at all. Write it where it can run
instead: as a per-condition guard, inside the branch that binds it.
{:or, [{:gold, id, tier} when tier > 1, {:silver, id}]} works. A rule-level guard over a
variable every branch binds is fine, since that variable is then guaranteed.
Rete.IR.Gate (W1 placeholder)
| field | type | meaning |
|---|---|---|
:gate | :and | :or | :not | :nand | :nor | :xor | :xnor | |
:args | [condition] | parsed arguments, may nest further gates |
:code | nested list [gate | arg_codes] | structural id, e.g. [:or, :fact_user_bind_id_expr_1, [:not, :fact_order_bind_id_expr_2]] |
The parser recognizes gates, and parses their arguments. It performs no normalization.
Rete.DSL.Normalize replaces every Gate with plain conditions, Negation and
CompoundNegation nodes, and {:or, [[condition, ...], ...]} disjunctions. So a Gate
never survives into a compiled module.
Semantics: n-ary :xor means exactly one argument holds. :xnor is its negation.
:not with several arguments negates their conjunction. See the Rete.DSL.Normalize
moduledoc for the degenerate-arity table — a 0-argument and is true, a 0-argument or
is false, and so on.
Why "exactly one" rather than odd parity. For two arguments, the two readings agree,
and Clara has no xor to inherit a convention from. They diverge from three arguments up:
with all three true, exactly-one says false, and odd-parity says true.
"Exactly one" matches how the word is used about rule conditions — "exactly one of these
applies". Odd parity is a circuit-design notion instead, one no rule author is likely to
reach for. This choice is deliberate. A rule that needs parity should say so with nested
two-argument xors.
Normalization rewrites a negation by exactly three rules:
| written | becomes | why |
|---|---|---|
not(leaf) | Negation | the ordinary case |
not(or(a, b)) | and(not a, not b) | de Morgan is sound over a disjunction |
not(and(a, b)) | CompoundNegation | de Morgan is not sound over a conjunction |
plus not(not(x)) = x, which collapses through a CompoundNegation as well.
Rete.IR.Negation
%Negation{condition: Fact.t() | Coll.t()}. Normalization creates this. :condition is
always a single condition. A negation of a conjunction becomes a
Rete.IR.CompoundNegation instead. A negation of a disjunction turns into a conjunction
of negations, by De Morgan's law, and never survives as a Negation.
A negation never matches a fact, so the variables inside it are not bound for the conditions that follow. Its inner condition is still classified, though, because the engine needs the join keys to know which tokens the negation applies to.
Rete.IR.CompoundNegation
%CompoundNegation{conditions: [condition]} means "no match satisfies all of these at
once". Normalization creates this for {:not, [a, b]}, {:nand, [a, b]}, and everything
else that desugars to a negated conjunction. :conditions is a conjunction, in author
order, with at least two elements. Each element is a Fact, Coll, Test, Negation,
or a nested CompoundNegation — never a Gate, and never an {:or, ...}.
Why it exists. not(and(a, b)) = or(not a, not b) is valid as propositional logic. It
becomes invalid the moment the conjuncts share an existentially quantified variable —
which is the normal case in a rules engine:
defrule clean({:nand, [{:order, x}, {:refund, x}]}) do {:clean} endThis reads "no x has both an order and a refund". Applying De Morgan's law would make it
read "there are no orders at all, or there are no refunds at all" instead. Take one order for
x = 1 and one refund for x = 2. The intended reading is then true, and the De Morgan
reading is false. The rule would thus do the opposite of what it says. So normalization
never applies De Morgan's law across a conjunction.
Like a Negation, it binds nothing downstream — bound_vars/1 returns []. But its inner
conditions do bind each other. Bindings.classify/2 classifies them as a little LHS of
their own, starting from the outer bound set. So refund above gets join_bind: [:x].
What the compiler does with it. Rete.Compiler.Negation.extract/1 does exactly what
Clara's get-complex-negation does (compiler.clj:971, called from add-production at
line 1261 — that is, before to-dnf, which is why Clara's own de-Morgan-over-and
branch is unreachable).
It generates a helper production whose LHS is :conditions. That helper's RHS inserts a
marker fact, carrying the variables the negation joins on. The compiler then replaces the
CompoundNegation with a plain Negation of that marker. Nothing else in the pipeline can
evaluate a CompoundNegation, and nothing else tries.
Rete.IR.Expr
| field | type | meaning |
|---|---|---|
:code | atom | stable, human-readable unique id, see §5 |
:name | atom | the generated function, always :"__<code>__" |
:arity | 1 | 2 | |
:kind | :alpha | :test | :join_filter | fixes the calling convention |
:fun | captured function | nil | nil until escape/1 |
:__ast__ | %{args: quoted, body: quoted} | compile-time only |
Calling conventions:
| kind | arity | signature | falsy result |
|---|---|---|---|
:alpha | 1 | (fact) -> bindings_map | nil | nil on a pattern mismatch or a failed guard |
:test | 1 | (bindings_map) -> boolean | false |
:join_filter | 2 | (token_bindings, fact_bindings) -> boolean | false |
Codegen.expr_def/1 dispatches on :kind to pick that falsy result. An alpha must answer
nil, because %{} is a legitimate success value, for a pattern that binds nothing
({:tick}).
For arity 2, :__ast__.args is a two-element list: [token_pattern, fact_pattern].
Codegen.expr_def/1 wraps them in case {left, right} do, and falls back to false.
:lhs shape
@type element :: condition() | {:or, [[element()]]}
@type lhs :: [element()]The LHS is never flattened to DNF, since that explodes combinatorially. Normalization
runs per condition instead, as in Clara's add-production. An element is either one
condition, or a disjunction of conjunctions that fans out from the current parents and
re-converges before the next element.
The element type is recursive. A branch is itself a list of elements, and it may hold
a further {:or, ...}. Normalization never produces that nesting, but binding
classification does. It occurs when classification absorbs the elements after a
disjunction into branches that classify them differently (see §2, Rete.IR.Production,
and the Rete.DSL.Bindings moduledoc). Rete.IR.exprs/1, Rete.IR.escape/1, and
Rete.IR.lhs_bindings/1 all recurse through this structure.
Two edge values the network builder has to handle, both produced by degenerate gates:
{:or, [[]]}is true: one branch that adds no condition.normalize_lhs/1splices it away, so it never reaches the builder through the normal path.{:or, []}is false: no branch at all, so the production can never fire. The compiler keeps it, because dropping it would change the production's meaning. Do not assume a disjunction has at least one branch.
An empty branch never appears next to a non-empty one. {:or, [[], [a]]} would mean
"match unconditionally, or match a" — which is just "match unconditionally", since the
empty branch is true, and true or x is true. Nothing is lost by collapsing it,
because only the variables bound by every branch survive a disjunction, and an empty
branch binds none.
Normalize.simplify/1 therefore absorbs such a disjunction into {:or, [[]]}.
normalize_lhs/1 then splices that away, so the element disappears from the LHS entirely.
The builder only ever sees {:or, []}, or a disjunction whose every branch has at least
one condition.
Across a disjunction, only the variables bound by every branch are bound afterwards — the intersection, not the union. A union would hand a downstream condition a join key that one branch never produces.
The branch limit
Distribution is the one step that can explode. A conjunction of k disjunctions of m
branches each yields m^k branches, and every branch becomes a separate join path in the
beta network. Normalize.to_dnf/1 refuses to build more than Normalize.max_branches/0
(256) branches for a single gate. It raises an ArgumentError instead, naming the gate,
its arity, and the branch count.
Negation is not a source of growth. not of a DNF of n branches is exactly one branch of
n literals — each a CompoundNegation, or a plain Negation when the branch is one
literal wide. So not, nand, nor, and xnor are all linear. xor is linear too:
every non-chosen argument of each "exactly one" branch gets negated, and each negation
contributes only a single branch.
3. :__ast__ - what the later phases get to work with
Fact and Coll:
%{
pattern: quoted, # the pattern as written, WITHOUT the binding and WITHOUT the `when`
guard: quoted | nil,
bind: %{atom => quoted_var}, # the variable AST for every entry of :bind
source: quoted # the whole element as written, for error messages
}Test: %{guard: quoted, bind: %{atom => quoted_var}}.
Production: %{bind: %{atom => quoted_var}, decl: quoted, body: quoted}.
Expr: %{args: quoted, body: quoted}.
Two ordering rules that must not be confused:
- the
:bindlist on a struct is sorted. It is a set, so you may compare it freely. - the
:bindmap keepsMap.keys/1order instead. This order is spliced into generated code, and it is part of the expression hash. Do not re-order it, or rebuild it from a sorted list — doing so changes expression codes, and node sharing silently degrades.
escape/1 drops :__ast__, so nothing downstream of the macro can see quoted AST. After
classification, __ast__.guard on a condition holds only the alpha part of the guard.
The lifted part is recoverable at join_filter.__ast__.body.
Rebuilding expressions
Rete.DSL.Bindings rebuilds an alpha through the public helpers rather than editing the
struct:
{type, args} = Parser.compile_pattern(env, fact.__ast__.pattern)
alpha = Parser.build_alpha_expr(type, fact.__ast__.pattern, args, alpha_guard, fact.__ast__.bind)build_alpha_expr/6 (a delegate to Codegen.alpha_expr/6) hashes {pattern, body}. So a
condition whose guard was fully lifted out produces exactly the same code as if it had
been written without a guard at all. This is the point: it lets the condition share the
alpha node. A test checks this.
4. Fact types and the "alpha matches any type" rule
:type is the declared type. The alpha expression never checks it. The alpha index
applies type filtering instead — including taxonomy (derive/underive) — when it
decides whether to propagate a fact to a node. This is asserted on purpose, in
test/rete_test.exs ("bind7 tests that the fact matches any fact type"), in
test/rete/dsl/parser_test.exs, and in test/rete/dsl/codegen_test.exs.
| written | :type | compiled alpha pattern |
|---|---|---|
{:tick} | :tick | {_} |
{:order, id} | :order | {_, id} |
{:order, id, amt} | :order | {_, id, amt} |
%Order{id: id} | Order (expanded module) | %{id: id} - the __struct__ check is dropped |
%{__type__: :order, id: id} | :order | %{id: id} - the __type__ key is dropped |
%Order{__type__: :vip, id: id} | :vip - the declared type wins | %Order{id: id} - the __struct__ check is kept |
{"order", id} | "order" | {_, id} |
A fact at run time is therefore a tagged tuple of any arity, a struct, or a tagged map. A
__type__ key overrides the module wherever it appears. It is a declaration, not data, so
no pattern matches it as a field.
The two struct rows above follow one rule, not two: a pattern keeps the __struct__ check
only when the index does not already guarantee the module.
%Order{f: v}drops the check.Orderis the type, so the index applied it. A second check would compare every fact and learn nothing. It would also break derivation, because the index routes a descendant type here on purpose.%Order{__type__: :vip, f: v}keeps the check.:vipis the type, so the index guarantees nothing about the shape. The module is then the only constraint left for the alpha to apply, and the condition reads "anOrder, and a:vip".
Both rows drop the __type__ value. An alpha must never check a type a second time, or
a derived type would stop matching.
A type is any term except nil. Taxo and the alpha index both store a type as a map
key, so an atom is not required. nil is reserved to mean no type. That is what lets an
unset struct field fall back to the module.
A pattern must write the type as a literal, because the alpha index routes on it at
compile time. Rete.DSL.Parser tests the AST with Macro.quoted_literal?/1, then reads
the value with Code.eval_quoted/1. Evaluation is safe because that test has passed.
5. Expression naming and hashing
Rete.DSL.Codegen owns this. expr_code/3, expr_name/1, type_label/1, and
expr_hash/2 each have exactly one implementation, and both Parser and Bindings call
them.
fact_<type>_bind_<v1>_<v2>_..._expr_<hash> alpha without guard
test_fact_<type>_bind_<v1>_..._expr_<hash> alpha with guard
test_bind_<v1>_..._expr_<hash> test over bindings only
join_<type>_bind_<v1>_..._expr_<hash> join filterThe <type> segment is a label, not an identifier. type_label/1 turns a type that
is not an atom into a short, readable name, and two types may share one: "a-b" and
"a_b" both give a_b. Only <hash> makes a code unique. That hash covers the raw
pattern, and the pattern holds the type, so two different types always give two different
codes. The hash carries the whole identity of an expression. The other segments only make
the generated function name readable in a stacktrace.
<type>is the type label. An atom type is rendered as written, and a module loses itsElixir.prefix, with.becoming_(MyApp.Order→MyApp_Order). Any other type is named frominspect/1, and a type with no letter and no digit getsEMPTY.<v...>are the variables the expression reads, sorted, joined with_. An empty bind set givesfact_tick_bind_expr_<hash>. For a join filter, these are the guard's variables from both sides — for example,join_order_bind_amt_t_expr_<hash>for{:order, amt} when amt > t.<hash>is:erlang.phash2/1of the meta-stripped, escaped,term_to_binary'd pair{args_ast, body_ast}. For an alpha, that pair is{pattern_ast, body_ast}. For a test, it is{bindings_map_ast, guard_ast}.- the generated function is named
:"__<code>__".
Three rewrites make the hash see through things that are lexical in Elixir, but not in the
AST. Parser.expand_aliases/2 and Parser.resolve_constants/2 apply them, over the whole
declaration and body, before anything else runs:
- every alias and
__MODULE__is resolved to the module it names. SoH.ok?(amt)underalias A, as: H, and underalias B, as: H, do not hash the same. - every
@xbecomes{:@, _, [{:x, _, DefiningModule}]}. So the same pattern in two modules does not share one expression. - every
^vis unwrapped tov. A condition compiles to a standalone function, which has no enclosing scope for a pin to refer to, so all three spellings used to fail to compile outright.^@limitand^7become the literal value.^amtbecomes plainamt, which is already how this DSL spells a join, so ordinary classification turns it into a join key.
Only alias nodes are expanded, never macros — the body has to reach the generated function
exactly as the user wrote it. Pinned values and @x are never bindings.
Determinism
Codegen.ast_hash/1 normalizes the AST before hashing it. Each normalization exists so
that the hash is a function of what the code means:
- metadata is stripped, so a production keeps its hash when it moves to a different line.
- discarded variables are canonicalized to
_. So{:order, _x}and{:order, _y}— byte-identical once compiled, since a_-prefixed name is never a binding — share one expression. - the bindings map is sorted, wherever it is spliced into a hashed AST.
Map.to_list/1on an atom-keyed map iterates in atom-table interning order. The hash thus used to depend on which atom the VM interned first. The same source then produced different codes on a full build and on an incremental one, and it duplicated every alpha node on a rebuild, with no message. Never reintroduce an unsorted map into a hashed AST.
Module attribute values
An attribute's value is not part of its code, and it cannot be. @limit expands to a
hidden Module.__get_attribute__ call, and that call only runs once the module body is
evaluated — after every macro in that body has already expanded. Module.get_attribute/3
still reports the default value at expansion time.
Hashing the name alone is what keeps two conditions that read the same attribute sharing
one node — the ordinary case. Codegen.check_attr_values!/3 catches the dangerous case
instead: the same pattern on either side of a reassignment. The compiler emits this check
into the module body, where the values are readable. It records what each code saw. It
then raises an error when that code is reached again with a different value, and does not
let the second rule silently reuse the first rule's compiled function.
Sharing
Codes are the node-sharing key for W2. Two conditions with the same code have
byte-identical behavior, and they must map to the same alpha node. Within a module,
Codegen.expr_defs/1 guards each definition with Module.defines?/2. So the compiler
compiles a shared condition once, and both Expr structs capture the same function.
Never derive new codes from anything unstable: line numbers, make_ref, or the map
iteration order of a rebuilt map.
Across modules
A code is equal exactly when two expressions behave the same. An expression could depend
on the module that wrote it in four ways. The text above closes three of them: an alias
resolves to the module it names, @x carries its defining module, and a pin is unwrapped.
All three happen before the hash is taken.
The fourth is the unqualified call. ok?(amt) hashes as the bare name, whether it resolves
to an import or to a function of the calling module. So two modules produce one code for
two functions.
Codegen answers this while it still holds the AST and the caller's Macro.Env, and
records the answer in Expr.share. A call is portable when
Macro.Env.lookup_import/2 resolves it to Kernel, or when Macro.special_form?/2
recognizes it. One call that is not portable clears the flag for the whole expression.
Rete.Compiler.disambiguate_codes/1 reads the flag at build time, after escape/1 has
dropped the AST. It qualifies a code as <code>@<module> when more than one module
contributed it and at least one of them did not mark it shared. One refusal splits the
code for every contributor: qualifying only the refusing side would leave the rest on a
node holding another module's function.
Everything else is shared. Two modules that write {:customer, cid} match it once per
fact, and every beta node below it is shared too, because a beta node's sharing key holds
the alpha code.
Expr.share defaults to false. An expression built where nothing set it is kept
apart, rather than merged on an assumption nobody checked.
Rete.get_expr_data/1 is a different thing, and it still deduplicates by code alone. It
feeds the generated function table, not the network.
6. Accepted LHS element forms
| form | result |
|---|---|
{:type, a, b, ...} (any arity, incl. {:type}) | Fact |
%Mod{f: v} | Fact, type: Mod |
%{__type__: :type, f: v} | Fact, type: :type |
%Mod{__type__: :type, f: v} | Fact, type: :type — the declaration outranks the module |
f = <pattern> | Fact with fact_binding: :f |
<pattern> when <guard> | Fact, guard split between :alpha and :join_filter |
f = <pattern> when <guard> | both |
[<pattern>] | Coll with coll_binding: nil |
[<pattern> when <guard>] | Coll with a guard |
c = [<pattern>], c = [<pattern> when <guard>] | Coll with coll_binding: :c |
{gate, [element, ...]} | normalized into conditions, Negations, CompoundNegations and {:or, ...} |
trailing ... ) when <guard> do | Test appended last |
Parsing rules and disambiguations:
- the leading
%{...}literal of a declaration is the options map. The exception: a__type__key makes it a tagged-map condition instead. {gate, [...]}is a gate whenevergateis one of the seven gate atoms, and the single argument is a list. So a two-element fact tuple named:and, with a list payload, cannot be expressed.whenbinds looser than=. Sof = {:t, x} when garrives as{:when, _, [{:=, _, [f, pattern]}, g]}, and the parser peels off both decorations before compiling the pattern.- an inner and an outer guard on a collection combine with
and([{:t, x} when a] when b→a and b). - variables named
_or_fooare never bindings, in any position. Because they are discarded, a guard that reads one is a compile-time error, naming the variable to rename (see §7). - the fact/collection binding is not in
:bind, and the alpha does not return it — the engine adds it to the token instead.Rete.IR.bound_vars/1returns:bind ++ [binding], which is what makes a condition's binding visible downstream.
Compile-time errors (all ArgumentError):
- a map fact pattern with no
__type__("a map fact pattern must declare its type ..."). - a type written as
nil, in any shape:{nil, id},%{__type__: nil}and%Mod{__type__: nil}all give one message, "nil is not a fact type ...". A pattern is stricter than a fact here. At run time an unset struct field falls back to the module, but writingnilin a pattern is never what anyone means, so it is refused. - a
__type__that is not a literal, in a map or a struct pattern. It cannot be bound. - a tuple pattern whose first element is not a literal ("unsupported condition ...").
- a struct pattern whose alias does not expand to a module.
- any other pattern shape ("unsupported condition ...").
- binding an element inside a collection (
[f = {:t, x}]). - binding or guarding a gate.
- binding a condition twice.
- a guard — per condition or rule level — reading a variable that is not available where it runs (see §7).
- a production with no
doblock. - a
Gate, or an already-escaped condition, reaching binding classification.
7. Guard splitting
The compiler splits a per-condition guard conjunct by conjunct, over its top-level
and/&& chain:
- a conjunct whose variables are all bound by the condition's own pattern — plus pinned values and module attributes, which are compile-time constants — stays in the arity-1 alpha. So the alpha rejects unmatched facts before any join work happens.
- any other conjunct becomes part of the arity-2 join filter.
defrule r({:threshold, t}, {:order, amt} when amt > 0 and amt > t)
# ^ alpha ^ join filterA guard that the compiler cannot decompose — an or mixing local and upstream variables,
or a single expression touching both — goes to the join filter whole. Correctness
beats early filtering here. && counts as a splittable conjunction, alongside and.
In the join filter, each guard variable is destructured from the fact side, when the
condition's own pattern binds it, and from the token side otherwise. So a join
variable is never bound twice in the same pattern. The compiler wraps the body in
if ..., do: true, else: false, so the boolean contract holds, whatever the user wrote.
A guard variable that is neither local nor bound upstream produces a compile-time
ArgumentError (Bindings.check_guard_vars!/3), naming the variable and the condition.
Left uncaught, it would compile into a join filter that reads the token side for a key
that is never there. The production could then never fire.
A forward reference does not trigger this error. Rete.Compiler.Sort runs before
classification, and it reorders binders to the front. So
defrule r({:order, amt} when amt > t, {:threshold, t}) is sorted into
{:threshold, t}, {:order, amt} when amt > t, and it becomes indistinguishable from the
same rule written that way round. What remains is:
- a
_-prefixed variable, as in{:order, _amt} when _amt > 0. The pattern discards it, so it is in no bindings map, and in no token. The error tells you to rename it toamt. The argument pattern in the alpha does bind it. But to inline it there would only replace this error with the Elixir warning "the underscored variable is used after being set". That warning is fatal under--warnings-as-errors.
That is why Codegen.join_filter_expr/4 may keep deriving the guard's variables with
Parser.parse_bind/1, which drops _-prefixed names. A join filter can never contain
one.
Bindings.check_test_vars!/2 checks the rule-level guard the same way, against the
variables bound on its path. It has no fact of its own, so everything it reads has to
already be in the token. See Rete.IR.Test in §2 for the disjunction case — the one place
the two checks differ in consequence, not in rule. A per-condition guard inside a branch
sees that branch's own bindings. A rule-level guard runs once per branch instead, so it
cannot read a variable that only some branches bind.
8. Known gaps
A local guard conjunct after a cross-condition one filters late. Guard splitting takes the maximal leading run of local conjuncts, not every local conjunct. The alpha runs before the beta node, so hoisting a conjunct over one written earlier loses short-circuit protection:
amt > t and div(100, amt) > 1raisedArithmeticErroronamt = 0, when split by value alone. Recovering the lost early filtering needs a purity analysis of the conjunct being hoisted.An unqualified local or imported call in a guard still hashes the same across modules.
valid?(amt)is byte-identical in two modules that definevalid?/1differently, so both produce one code. The parser resolves aliases and__MODULE__, but not bare local calls.Building a network stays safe.
CodegenclearsExpr.sharefor an expression holding such a call, andRete.Compiler.disambiguate_codes/1gives each contributing module its own code. So the collision costs sharing for that one expression, not correctness. Since 0.4.0 it costs nothing else: every other cross-module code is shared.The check is on the call, not on what it resolves to, so two modules importing the same
valid?/1are also kept apart. Resolving unqualified calls to their source at hash time —Macro.Env.lookup_import/2already answers this — would close both halves.Rete.get_expr_data/1is unchanged and still deduplicates by code alone, so it reports only one of the two functions.Per-group firing is hard to reach. A collection variable participates only when another condition's pattern matches on it, and
Rete.Compiler.Sortdefers collections. So a plain condition that matches the variable sorts before the collection, and it becomes an ordinary join key instead. In practice, grouping needs two collections. The alternative: collect everything, and useEnum.group_by/2in the right hand side, which yields one fact holding a map, instead of one activation per group. An explicit grouping form was considered, and deferred — seeRete.DSL.Bindings.mark_inert/1.No subsumption in normalization.
a ∨ (a∧b)is not reduced toa, on purpose. Branches carry bindings, and dropping the longer branch would drop the bindingsbintroduces. Repeated literals and contradictory conjunctions are pruned, though, and a disjunction with an empty branch is absorbed into true.Guard splitting and inertness use different notions of "own". A guard may read the collection's inert variables — that is exactly what makes them inert. So a guard's scope is every variable the pattern binds, while
Rete.IR.bound_vars/1reports only what escapes. Conflating the two makes a guarded collection fail to compile. Seeown_scope/1inRete.DSL.Bindings.