All notable changes to rete are recorded here. The format follows
Keep a Changelog, and the project follows
Semantic Versioning.
0.7.0
This release changes how you read a query. A query now declares its parameters in a
head. Those parameters are the only way to read the query. This release has breaking
changes. One of them is silent: a parameter matches by term equality, where a filter
used ==.
This release removes index/2. Before, a query scanned every match that it held, and kept
the matches that the filter accepted. An index stored those matches a second time, in
buckets, to prevent that scan. The engine now keys the query store on the declared
parameters. This does the same work with one store, not two. It also removes a failure
mode of an index: an index that no call matched gave no increase in speed, and reported
nothing.
defquery orders_for(cid)({:large_order, cid, amt}) do
{cid, amt}
end
MyRuleset.orders_for(session, cid: 1) #=> [{1, 250}, {1, 900}]To read one row out of 4,000 matches takes 0.0001 ms with a parameter. To build every row
and then filter in Elixir takes 0.089 ms. mix bench runs both, and
docs/design/engine.md §13 "Queries" reads the result.
Added
:metain the options map.%{meta: <term>}attaches your own data to a rule or a query, for example an owner or a ticket link. The engine never reads it, and never validates its shape.Rete.get_rule_data/1returns it unchanged, inproduction.opts[:meta].
Changed
A query declares its parameters in its head.
defquery rows(cid, tid)(<conditions>)tells the engine to key the matches of the query oncidandtid. A read is then a map lookup, not a scan.A call must name every parameter, and no other name. A partial set is not a more narrow lookup. It is a different key, and the engine stores no match under it. A partial key, an extra key and an unknown key each raise an error.
A query without a head takes no parameters. It answers with every match that it holds, in arrival order. This behavior does not change, and the cost does not change. The engine keys all of these tokens on
%{}, which is the one bucket that the query always had.A parameter must be a variable that the left hand side binds. Each match must also carry that variable. Thus you cannot use a variable that only some branches of a disjunction bind. A rule cannot take parameters.
Migration. A query that you always read without filters does not change. A query that you filtered needs a head. The head must name exactly the values that you supply. A query that you read in two ways is now two queries. These two queries share every node above the terminal, so the engine matches the conditions one time. To select on a value that you do not want to key on, filter the rows that the query returns.
One filter has no head to replace it. This is a filter on a variable that cannot become a parameter: a variable that only some branches of a disjunction bind. The old filter read an absent binding as
nil. A parameter cannot do this, because those tokens have no such key. Filter the result, or write one query for each branch.For a query that selects one row out of 4,000, a read by parameter is approximately 860 times faster than building every row and filtering in Elixir. With a body that builds a map and a string, it is approximately 1,600 times faster, because a head runs the body only for the row that it returns.
mix benchruns both comparisons, anddocs/design/engine.md§13 "Queries" reads them.These compare the two ways to select a row in this release. The filter that this release removed sat between them: it scanned every match like the first, but ran the body only for the rows it kept, like the second. That code is deleted, so no benchmark measures it.
A parameter matches by term equality, not by
==. The old filter compared withMap.get(bindings, key) == value. Thuscid: 1.0matched a binding of1. A map key lookup uses=:=, so this no longer occurs. This change is silent: nothing raises an error, and the row stops coming back.The third argument of
Rete.Session.query/3is parameters, not filters. The shape is the same: a keyword list or a map. The call is also the same, if the head of the query names the values that you supply.
Removed
index/2. A query now has one keying, which is its head. There is no second index to declare. A call toindex/2raises anArgumentErrorthat names the head to write. It raises an error because an "undefined function" error does not tell you what to write.# before defquery flagged_for({:flagged, cid, tid, amt}), do: {cid, tid, amt} index :flagged_for, [:cid] # after defquery flagged_for(cid)({:flagged, cid, tid, amt}), do: {cid, tid, amt}index :flagged_for, [:cid]andindex :flagged_for, [:cid, :tid]were two indexes on one query. They are now two queries.Rete.Inspect.query_plan/3. It reported which index a filter would use, or:scan. Every read is now a lookup on the head. Thus there is nothing to report, and a declared key cannot stay unused.index: 2from the exportedlocals_without_parens. A project that inherits the formatter configuration of this project withimport_deps: [:rete]gets this change automatically.
Internal
Rete.IR.Productionhas a new:paramsfield. The:indexfield ofRete.Network.Node.Querybecomes:params. It holds one key list, not a list of key sets.- The query terminal writes one token store. Before, it wrote an unbucketed store, and one
store for each declared index. This release also removes
Rete.Memory.index_id/2, andusable_index/2andcandidates/2inRete.Engine. - The engine checks a head where it computes
:bind. Thus an incorrect parameter raises an error at the line of the query, and not at@before_compile. This removedrecord_index!/5,resolve_indexes!/1,record_query_bind!/3,with_indexes/2, and the@rete_index_dataand@rete_query_bindsattributes. Together they were approximately 150 lines ofRete.Ruleset. - A head is part of the declaration of a query, so it is part of the production hash. A
changed head thus changes
get_version/0.get_rule_data/0no longer adds the indexes after the fact.
0.6.0
This release changes how facts are typed. Two rules replace one. It has breaking
changes, and both are silent: a struct that sets a __type__ field changes type, and a
tuple whose tag is not an atom no longer raises on insert. Each is described below, under
"Migration".
Changed
A
__type__now takes precedence over the struct module.%MyApp.Order{__type__: :vip, id: 1}is a:vip, not aMyApp.Order. A__type__is an explicit declaration, and a declaration outranks the module.__type__is also never ordinary data.Rete.DSL.Parser.compile_pattern/2now drops it from a struct pattern, as it always did from a tagged map. A struct pattern can therefore no longer bind or match the key.%MyApp.Order{__type__: t}is a compile error.In a condition, writing both means both.
%MyApp.Order{__type__: :vip, id: id}matches a fact that is aMyApp.Orderand is typed:vip. The index routes on the declared type, so it no longer guarantees the module. The struct pattern keeps its__struct__check to apply the module itself. A plain%MyApp.Order{id: id}still drops that check, because there the module is the type and the index has applied it. That is also what lets a derived type reach it. Neither form checks the__type__value again. An alpha that applied the taxonomy would breakderive/2.You can only write
%Mod{__type__: ...}whenModdeclares a__type__field. Elixir rejects an unknown struct key at compile time.Migration. A struct that declares a
__type__field and sets it changes type. If you used the field as data, rename it. A struct with no such field, or with the field unset, does not change.A fact type may be any term except
nil."express",42and{:tenant, 7}are all types, andderive/2relates them like any other type. Nothing depended on the atom constraint:Taxodocuments a tag as "any term, because Taxo stores it as a map key", and theRete.Taxonomyindex is a plain map lookup. The change covers tuple tags as well, so{"order", 1}is a fact of type"order".Rete.Taxonomy.fact_type/0is thereforeterm(), and so are:typeonRete.IR.FactandRete.IR.Coll. Dialyzer can no longer check those positions, because no Erlang type says "any term exceptnil". The one constraint that remains is checked where it can be:default_fact_type/1at run time, andRete.DSL.Parserat compile time. This is the cost of the change, and it is the reason both of those raise a message that namesnilrather than a generic one.A pattern must write the type as a literal, because the alpha index routes on it at compile time.
Rete.DSL.Codegen.type_code/1is renamedtype_label/1. It builds a short name frominspect/1for a type that is not an atom, only so the generated function name reads well in a stacktrace. It is not required to be unique. The hash at the end of each code supplies uniqueness, because that hash covers the raw pattern, and the pattern holds the type. A type with no letter and no digit, such as"-"or%{}, gets the nameEMPTY, so a code readsfact_EMPTY_bind_id_expr_1234rather thanfact__bind_id_expr_1234. Atom types render exactly as before, so no existing expression code changes.Rete.DSL.Codegenis internal, and semantic versioning does not cover it.Migration. A tuple whose first element is neither
nilnor an atom used to raise on insert. It is now a fact. If you relied on that error to catch a malformed value, it no longer raises.nilis the one term that is not a type. It means that a fact declares no type. This is what lets an unset struct field fall back to the module, so a new%MyApp.Order{}that declares__type__is still aMyApp.Order. A plain%{__type__: nil}has no module to fall back to, so it raises.A map with
__type__: nilwas previously accepted and typednil. No condition could be written againstnil, becausecompile_pattern/2refused to compile one. Such a fact therefore reached no alpha node and matched nothing, and it did so silently. That is the outcome the raising clause exists to prevent.
Fixed
A
niltype in a pattern now reports the same way in every shape.{nil, id}fell through to the generic "unsupported condition" message, which names the three fact shapes but never says thatnilis the problem.%{__type__: nil}reported "a map fact pattern must declare its type", which confuses writingnilwith omitting the key. All of{nil, id},%{__type__: nil}and%Mod{__type__: nil}now raise "nil is not a fact type ...", and the message says what to write instead. Omitting__type__from a map is still its own, different error.A first-position map fact pattern that omits
__type__now says so. A leading%{...}literal is the options map unless it carries__type__. Sodefrule r(%{cid: cid})was refused as an options map, with ":cidis not an option". The same condition one slot later produced the parser's "declare its type with__type__" message instead. The options error now names both readings, so the error no longer depends on where the condition appears.
Tests
- Map and struct facts are now tested end to end.
test/rete/fact_shapes_test.exsinserts both shapes into a real session and fires the rules. It covers joins between all three shapes, guards, pinned join keys, whole-fact bindings, collections, negations, compound negations, disjunctions, truth maintenance,Rete.Inspect.explain/2, queries with and without an index,deriveacross shapes, alpha routing, node sharing between modules, maps with string keys, and nested patterns. Before this, no test inserted a struct fact into a session, and one test covered tagged maps.
0.5.0
fire_rules/2 is now the only call that propagates. This release has breaking changes.
Added
Rete.Session.settled?/1reports whether a session has work waiting forfire_rules/2. A query cannot raise on an unfired session, because[]is a true answer about one. So this is the way to tell "no match" apart from "not matched yet". A session fresh fromnew/1is not settled, becausenew/1queues the root token.
Changed
insert/2andretract/2no longer propagate. They record the fact and queue the work.fire_rules/2drains that queue, matches everything waiting, runs the rules that match, and returns at quiescence.A session you have not fired holds facts and nothing else.
Rete.Session.facts/1still answers, becauseinsert/2updates working memory at once. Nothing else does: the engine activates no rule, and a query answers as of the most recent fire. On a session you never fired that is[]. On one you fired and then inserted into, it is the answer from before that insert. Watch for the second case in review: a stale result looks right, and an empty one at least looks wrong.Two problems drove this. Building a session queued activations before the caller did anything, so
pending/1was non-empty on a session nobody touched.A listener could never observe those activations either.
Rete.Engine.Statestarts with no listeners, andRete.Session.with_listener/3can only attach afterward. So:activation_addedwent to nobody, and a listener later saw:activation_firedfor a rule it never saw added.A listener attached to a fresh session now misses nothing.
insert/2andretract/2emit:fact_inserted,:fact_retractedand:fact_duplicated, because they update working memory at once. Every other event happens insidefire_rules/2: all of:propagated, and all of:activation_added,:activation_removedand:activation_fired. Seedocs/design/observability.md§1.Batching is the other gain. Any number of inserts and retractions now cost one settle. A fire coalesces the queue before it drains. So facts fed one call at a time reach a node as one batch. Feeding 1,000 orders one call at a time now costs what one call carrying all 1,000 costs.
mix benchmeasures the two side by side, under "1,000 facts, in one insert call and in 1,000".A node's batch closes when the opposite direction reaches that node, because merging past a retraction would drop it. So a run of inserts merges, a run of retractions merges, and a caller that alternates the two on one node gets one batch per run. Where you put your call boundaries never changes where the session lands.
One ordering moved, and it was documented as unspecified. A rule reachable by two routes — two disjunction branches over one fact type, say — used to fire fact by fact when its facts arrived in separate calls, and route by route when they arrived in one. It is now route by route either way.
# a rule whose two branches both match every {:n, _} session |> Session.insert({:n, 5}) |> Session.insert({:n, 6}) |> Session.fire_rules() # 0.4.0 fires 5, 5, 6, 6. 0.5.0 fires 5, 6, 5, 6, which is what one # insert call carrying both facts already fired in 0.4.0.The cause is the coalescing above: the merge used to run over one call, because each call drained, and it now runs over the whole queue. So the call boundary stops deciding the sequence. A retraction between the two inserts brings the old order back, because it closes that node's merge window.
Both are arrival orders, and both settle to the same facts. What this changes is the order of
:activation_firedevents, which is what a trace reads.docs/design/engine.md§7 states what arrival order does and does not promise. A rule's own matches still arrive in fact order, which is the part rules rest on, and it did not move.Rete.Inspect.why_not/2andcollection/3now raise on a session with propagation queued. Both read what propagation built. On a session that never fired that is zero of everything. It reads as "nothing matched", but the truth is "nothing has been matched yet". The error names the pending count and says to callfire_rules/2.explain/2andfired/2are unchanged. They read memories thatinsert/2andretract/2update at once, so both answer at any point — about the session as it stands, not as it will stand. A queued retract shows a conclusion whose support already left, reported asorigin: :unknown. Fire first for a settled provenance graph.Rete.Session.pending/1is gone.fire_rules/2returns at quiescence, and nothing propagates before it. So the function can only ever answer[].Rete.Activationno longer reaches the public API. UseRete.Listenerto observe activations, andsettled?/1to ask whether a session has work waiting.Nothing replaces it, and nothing will. Reporting what would activate means matching, and matching before a fire is the work this release moved into the fire.
pending/1also had no counterpart in clara-rules, so no ported ruleset depended on the contract. Seedocs/design/engine.md§12.A production with no conditions is documented.
defrule startup do ... endis legal, and always was. No code changed for it. Its timing moved with everything else here: it is true of the empty session, so it fires once, on the firstfire_rules/2, with nothing inserted.Its support is the root token rather than a fact, so it is the one conclusion
retract/2cannot reach. A query written the same way answers one row in every fired session, and binds nothing, so any filter raises.docs/dsl.mdstates this for rule authors,docs/design/engine.md§6 gives the reason, andRete.EngineTestpins both.One deliberate divergence from Clara. Clara's
test_negation/test-simple-negationqueries a session that nobody inserted into and nobody fired. It expects one row. Clara plants the root token when it builds the session. This engine answers[]there. That test fires every other session before it queries it, and those cases agree exactly. Seedocs/design/engine.md§12.
Migration
Add a fire_rules/2 before any query that ran against an unfired session:
# before
session |> Session.insert(facts) |> MyRules.some_query()
# after
session |> Session.insert(facts) |> Session.fire_rules() |> MyRules.some_query()Grep for the same shape on a session that fired earlier. It is the case to look hardest for, because the query returns real rows and none of them account for the new facts:
settled = session |> Session.insert(first_batch) |> Session.fire_rules()
# before: the insert propagated, so this saw both batches
settled |> Session.insert(second_batch) |> MyRules.some_query()
# after: this answers as of the fire above, and second_batch is not in it
# fix
settled |> Session.insert(second_batch) |> Session.fire_rules() |> MyRules.some_query()Session.settled?/1 is the assertion to reach for where a function receives a session it
did not build.
Replace Session.pending/1 with a listener:
session
|> Session.with_listener(Rete.Listener.Collect, [])
|> Session.fire_rules()
|> Rete.Listener.Collect.by_tag(:activation_fired)Where pending/1 only answered "is there work waiting", use settled?/1:
# before
if Session.pending(session) != [], do: Session.fire_rules(session), else: session
# after
if Session.settled?(session), do: session, else: Session.fire_rules(session)0.4.0
Node sharing now reaches across module boundaries. Composing rulesets costs what writing them in one module costs.
Performance
Rulesets in separate modules now share nodes. A condition written in two modules used to compile to one alpha node per module, so a fact was matched against it once per module. It now compiles to one node, matched once, feeding every rule below it. Every beta join under that alpha is shared too.
The split was there because an unqualified call hashes as its bare name. Two modules that import a different
ok?/1and both write{:bar, amt} when ok?(amt)produce one code for two functions, so the compiler kept every cross-module code apart rather than tell a safe case from an unsafe one.Rete.DSL.Codegennow answers that question while it still holds the AST and the caller's environment, and records the answer inRete.IR.Expr's new:sharefield.Rete.Compiler.disambiguate_codes/1splits only the codes that field refuses. An imported call, a local call, and a compound negation marker are still kept apart. A plain pattern, a literal guard, and a qualified call are shared.This changes the shape of a network built from more than one module. It changes no result: the same facts fire the same rules and conclude the same facts either way.
Measured over k modules writing the same two conditions:
k modules alpha nodes join nodes matches per fact 1 2 2 1 8 2 2 1 32 2 2 1 Each of those was
k + 1,2kandkbefore. A condition that still refuses to share keeps the old shape, which is what the newmix benchscenario contrasts against.mix benchgrew from seventeen scenarios to eighteen. The added one matches 200 facts against the same condition in k modules, with nothing firing, so it measures matching alone. It is flat from k=4 to k=32. All eighteen are linear.
Changed
Rete.IR.Exprgains:share, and it defaults tofalse. Internals are not covered by semantic versioning — see "What is public" in the README.Rete.DSL.Codegen.alpha_expr,test_exprandjoin_filter_expreach take the caller'sMacro.Envas a new first argument. They are now/6,/3and/4.
Fixed
- The README claimed every
mix benchscenario was linear "except one", and pointed atdocs/design/engine.md§12 for it. No scenario has been superlinear since 0.3.0, whose own entry records that all seventeen were linear, and §12 lists design gaps rather than a measurement. The README also listed "many rules over one fact type" as unmeasured, which a 0.3.0 scenario already covers. mix.exsgrouped the docs underRete.Memory.Bucket, which does not exist. The module isRete.Bucket, and it was the one module rendering with no group on hexdocs.- The README omitted
Rete.Inspect.query_plan/3from both its public API table and its list of whatRete.Inspectoffers. - Two section cross-references pointed at the wrong section:
Rete.IRcitedir.md§2 for the "alpha matches any type" rule, which is §4, andengine.mdcitednetwork.md§3 for the no-DNF claim, which is §5. ir.md§8 still described the pre-0.4.0 rule, that every code two modules contributed was qualified.
None of these is a behavior change.
0.3.0
A performance pass. Six quadratics removed, and three orderings changed.
Changed
Three orderings moved. Every one of them was documented as unspecified before this release, so none was a promise. Each was costing real time to hold steady.
- A collection gathers in reverse arrival order, and does not sort. The same facts fed
in a different order now produce a different list, and a member retracted and reinserted
comes back at the front. What a collection holds is still a function of the fact set.
Sort in the right hand side if the order matters. See
docs/design/network.md§3. - Query rows follow the order the facts arrived.
Rete.Session.query/3no longer sorts its result. The set of rows never varies, and one feed always answers the same way. - A rule reached by two routes within one
insertcall now sees all of one route's matches before any of the other's, instead of interleaving them fact by fact. A rule's own matches still arrive in fact order.{:propagated, ...}events coarsen with it: fewer events, larger counts, same shape.
Performance
mix bench grew from nine scenarios to seventeen. The eight added cover the shapes the
original suite could not see, and each has a control beside it. All seventeen are linear.
| scenario | was | now |
|---|---|---|
| two rules concluding the same fact | ~n^1.93, 193 ms | ~n^0.9, 6 ms |
| filling one collection behind a live token | ~n^2.37, 48 ms | ~n^0.9, 0.6 ms |
| filling one collection, no token yet | ~n^1.99, 14 ms | ~n^1.0, 0.7 ms |
| filling one collection one member at a time | ~n^1.78, 31 ms | ~n^1.1, 4 ms |
| an unkeyed negation taking n blockers | ~n^1.72, 30 ms | ~n^1.1, 5 ms |
| cancel n pending activations of one rule | ~n^1.51, 17 ms | ~n^1.1, 8 ms |
Compiling 1,024 rules over one fact type went from an extrapolated ~225 ms to 7.7 ms. Beta node sharing is an index now, rather than a scan of every child of every parent.
Sessions that only insert got about 30% faster. The two indexes that make retraction cheap are built on first use, so a session that never retracts never pays for them.
Gathering into a collection is linear. What a collection costs after that depends on how
members arrive, because everything inserted in one call is one change. Over 4,000 members
arriving one per call, a rule that reduces its collection costs 27 ms, and one that concludes
a fact holding the collection costs 408 ms. Arriving a hundred per call, the same two cost
3.0 ms and 6.8 ms. Batch inserts where you can. See docs/dsl.md.
docs/design/engine.md §13 carries the measurements, the trades, and the three wrong
attributions made along the way.
Added
index/2, which declares how a query's matches are bucketed. A filter covering a declared key set reads one bucket instead of every match. Measured at 4,000 matches with one returned: 200 calls take 97 ms unindexed and 0.07 ms indexed.defquery flagged_for({:flagged, cid, tid, amt}), do: {cid, tid, amt} index :flagged_for, [:cid] index :flagged_for, [:cid, :tid][:cid, :tid]is one index over both bindings. Two indexes are two lines. A declaration may come before or after its query.An index changes speed, not results. Every filter works, indexed or not, and returns the same rows in the same order. It declares no parameters and permits nothing — the caller may still filter on any bound variable. Declaring none is the default, and a query without one behaves exactly as before.
Rete.Inspect.query_plan/3, which reports the index a filter would use, or:scan. A declared index nothing matches is otherwise silently no faster.Rete.Bucket, the tombstoned ordered multiset behind both working memory and the agenda. Internal.Rete.DisjunctionTestandRete.CanonTest. The first pins the compiler's claim that it never flattens a left hand side to disjunctive normal form, and the 256-branch cap, which had no test in either direction.
Fixed
- The options map on
defruleanddefqueryrefused nothing it did not understand, so%{saliance: 10}was silently ignored. Unknown keys now raise. This can break a ruleset that passes a stray key. - The README claimed no profiling pass had been done and no benchmark suite existed. Both were false.
docs/design/engine.mdattributed the collection quadratic to a walk that a control had seemed to confirm and had not.
0.2.0
Documentation and a dependency bump. No change to the DSL, the compiler or the engine.
Documentation
- The whole prose surface rewritten in an ASD-STE100-influenced style: the README,
docs/dsl.md,docs/design/*.md, and every@moduledoc,@doc,@typedocand comment inlib/. Short sentences, active voice, no semicolons, no phrasal verbs. Every technical fact, hedge and caveat carries over unchanged — only the sentence structure does. - Two stale claims in the README's Limitations section corrected. Neither is a
behavior change; both describe what 0.1.0 already did.
- "No parallel or async rule evaluation" was wrong:
fire_rules/2's:concurrencyoption already runs one activation group's rule bodies on tasks, and has since 0.1.0. - "No durability" overstated the gap. A session holds no PID, ETS table or other
process-local handle, so
:erlang.term_to_binary/1and:erlang.binary_to_term/1round-trip a whole session, including its compiled network, as long as the receiving process has the same compiled ruleset and listener modules loaded. There is still no checkpoint API, no versioned migration and no distributed sync.
- "No parallel or async rule evaluation" was wrong:
Dependencies
taxobumped to~> 0.2.0. A cyclic derivation passed toderive/2now raisesTaxo.CyclicDerivationError, carrying:childand:parent, in place of a bareRuntimeError.Rete.Taxonomyalready always builds a proper%Taxo{}before calling into it, so taxo's stricter argument typing changes nothing observable here.
0.1.0
First release. A complete forward-chaining Rete engine: the DSL front end, the network compiler, the propagation loop, truth maintenance and the observability tools.
0.1.0 rather than 1.0.0 deliberately. Everything documented works and is covered by
725 tests, but one part of the surface is known to be unsettled and is likely to change
without a major version: how a collection reaches per-group firing. See the known gaps
in docs/design/.
The DSL
defrule/2— a rule reads as a function: its arguments are the left hand side and its body is the right hand side.- Fact patterns of any arity (
{:order, cid, amt},{:tick}), struct patterns (%Order{id: id}) and tagged maps (%{__type__: :order, id: id}). - Fact bindings (
o = {:order, cid}), per-condition guards ({:order, amt} when amt > limit) and rule-level guards. - A variable shared by two conditions is a join; no join syntax.
- Collection bindings (
orders = [{:order, cid, amt}]), collect-all, with the empty collection and collection-local variable rules ofdocs/dsl.md. - Gates:
:and,:or,:not,:nand,:nor,:xor,:xnor, nestable, with n-aryxorreading as "exactly one". - Negation of a single condition, and of a conjunction — the latter extracted into a generated helper whose marker fact carries the bindings the negation is scoped by.
derive/2andunderive/2for fact-type hierarchies, applied by the alpha index.defquery/2. A query returns what its body computes, one result per match, and defines<name>/1,2in its own module so it is run by calling it —MyRuleset.find_user(session, id: 1). Any binding can be filtered on; there is no parameter declaration.%{salience: n}for firing priority.- Pinned values, module attributes and aliases resolved into a condition's identity, so that two conditions share a compiled node exactly when they behave the same.
- Compile-time errors, naming the rule and the variable, for: a guard reading a variable
nothing binds, a left hand side that cannot be ordered, a binding that shadows an
upstream variable, reading a collection-local variable outside its collection, a
discarded (
_-prefixed) variable read by a guard, a bound collection element, a production with no body, a production name declared twice in one module, the obsoleteparams:option, and two conditions reading the same module attribute at different values.
The compiler
- Stable topological condition sort, so a rule may be written in the order it reads and a forward reference still compiles.
- Per-condition gate normalization — the left hand side is never flattened to whole-LHS DNF — with a 256-branch limit per gate.
- Alpha node sharing by expression code, and beta node sharing by equal sharing key and identical parent set (Clara issue 433).
- Cross-module expression code disambiguation, so two modules that write the same unqualified call cannot collapse onto one node.
The engine
Flat propagation loop over an explicit work queue; one immutable working memory, so a session is a value that can be held, compared, forked and sent between processes.
Hash joins, expression joins, negation, negation joins, collections, collection joins, tests, productions and queries.
Salience-ordered agenda with removal by value, so a match retracted before it fires never fires.
Truth maintenance with well-founded support: a conclusion its own match rests on is dropped rather than supporting itself for ever.
An opt-in loop guard.
:max_cyclesdefaults to:infinity, sofire_rules/2runs to quiescence and an oscillating ruleset spins rather than raising; pass an integer to bound a call and it raises with the rules that fired most. A numeric default was tried and rejected: 10,000 was reached by 4,000 facts through a three-rule chain with no loop in sight, and a count cannot separate a runaway from a large settling pass, so any default eventually fails correct code. It counts cycles — one pass of the fire loop, which is one activation at the default concurrency and one whole activation group above it. An unrecognized value raises rather than quietly meaning no cap, which is whatmax_cycles: nilwould do by accident of Erlang term order.docs/design/observability.md§3 has the numbers for picking one.:concurrencyand:timeoutonfire_rules/2.concurrency: 1by default, which fires one rule body at a time. Above1the bodies of one activation group run on tasks, and their conclusions are applied in group order. Worth raising only when a body does I/O or real computation: a body that builds a tuple is 1.5% of firing and costs more than that to hand to a task, so break-even is about 5 µs. Sixty-four bodies sleeping 5 ms go from 385 ms to 7 ms.It preserves the resulting session, asserted by a property over a ruleset spanning every node kind. It does not preserve firing order, because taking a group freezes it while firing one at a time re-sorts the agenda after every activation. A body may also run for a match another activation in the same group then invalidates: that activation does not fire and nothing it computed is inserted, but a side effect it performed is not undone.
docs/dsl.mdstates the at-least-once contract this implies, anddocs/design/engine.md§11 has the measurements.
Observability
Rete.Listener— one callback, every event emitted in one place, costing nothing when nobody is listening.Rete.Listener.CollectandRete.Listener.Traceship.Rete.Inspect—explain/2,fired/2,why_not/2andcollection/3, all derived from working memory, so they need no setup and cannot drift. A rule is named the same way a query is, by{module, name}.
Naming
- A production is identified by
{module, name}, not by name alone, so two rulesets that each define a:summarycompose into one session. A repeat within one module is rejected where it is written, naming both declarations. - Queries are run by calling them;
Rete.Session.query/3takes{module, name}for the runtime-chosen case. A bare name raises, naming the module that defines it. - Listener events name the rule too. The three activation events and the
:derivedorigin carry%{node: node_id, rule: {module, name}}in place of a bare node id, which a listener had no way to resolve.{:propagated, ...}still carries the id alone — a join node has no name to give.
The public API
Only Rete, Rete.Ruleset, Rete.Session, Rete.Inspect and Rete.Listener (with
Collect and Trace) are covered by semantic versioning. Everything else is documented
but internal and may change in a patch release. See "What is public" in the README.
Performance
Three quadratics in the size of one join key's bucket, all measured, all linear or flat.
Inserting 4,000 facts under one key went from 250 ms to under 10 ms, and retracting 200 of
them from a bucket of 8,000 from 108 ms to under 1 ms. mix bench is what says so, and
keeps saying so.
Rete.Agendais bucketed by sort key rather than one sorted list. Every activation of a production shares a key, so inserting walked past every match already queued for that rule; there are at most as many buckets as there are production nodes.Rete.Memory.Bucketreplaces the list behind each join key with an ordered multiset: adding and retracting are O(1), reading is unchanged.Rete.Engine.insert/3andretract/3collect their propagation ops without appending to the accumulator once per fact.
Ordering
- Two matches of one rule fire in arrival order, at any scale. A batch arriving at a node is split into join groups in the order each key first appeared rather than in map order; Elixir iterates a map of up to 32 keys in term order and a larger one in an internal hash order, so the previous behavior changed a rule's firing sequence the moment a node saw its 33rd join key.
- The runaway error says how much it left out. Both of its lists are cut to five, and a
cut that says nothing reads as the whole story: it reports
Still pending (5 of 412 activations)when there is more, and stays quiet when there is not.
Development
Neither of these ships in the package.
- CI (
.github/workflows/ci.yml) runs the project's six verification commands on the declared floor, Elixir 1.18, and on the current release. The floor was a promisemix.exsmade and nothing checked. mix bench(bench/run.exs) — nine scaling scenarios reporting the empirical exponent rather than a wall-clock number, so a reintroduced quadratic shows up as~n^2instead of as a figure with no baseline. Eight are linear. Filling one collection measures~n^1.94and is left in, because a suite that reported only good news would be worth less. A tenth scenario compares:concurrencysettings on a blocking body, which is a ratio rather than a shape. Not run in CI — timing thresholds on shared runners fail for reasons that mean nothing.