Rete.Session (Rete v0.9.0)

Copy Markdown View Source

A session is an immutable value: every operation returns a new one.

A session can be held, compared, kept as a checkpoint and sent between processes. The compiled network inside it is shared rather than copied, so two sessions built from the same rules differ only in their working memory.

Facts you insert stay until you retract them. Facts a rule concludes are logical: the engine holds them only while the match behind them holds. That is why a rule's right hand side only inserts. It is also why retracting a fact removes anything concluded from it too, transitively.

fire_rules/2 is the only call that matches anything. insert/2 and retract/2 record facts and queue the work. So a session you have not fired holds facts, and no matches.

The examples here run against this ruleset:

defmodule Rete.Doc.Orders do
  use Rete.Ruleset

  derive :premium, :customer

  defrule large_order({:customer, cid}, {:order, cid, amt} when amt > 100) do
    {:flagged, cid, amt}
  end

  defquery flagged_for(cid)({:flagged, cid, amt}), do: {cid, amt}
end

iex> alias Rete.Session
iex> session =
...>   Session.new([Rete.Doc.Orders])
...>   |> Session.insert([{:customer, 1}, {:order, 1, 250}])
...>   |> Session.fire_rules()
iex> Session.facts(session) |> Enum.sort()
[{:customer, 1}, {:flagged, 1, 250}, {:order, 1, 250}]
iex> Rete.Doc.Orders.flagged_for(session, 1)
[{1, 250}]

See docs/dsl.md for writing rules and docs/design/engine.md §8 for truth maintenance.

Summary

Functions

Every fact the session holds, inserted or concluded, one entry for each occurrence.

Fires rules until the agenda is empty, returning a new session.

Builds an empty session over an already compiled network.

Inserts one fact or a list of them, returning a new session.

What a listener has accumulated, or nil if it is not attached.

The compiled network behind a session.

Builds a session from ruleset modules.

Runs a query by {module, name}: one result per match, computed by its body.

Retracts one fact or a list of them, returning a new session.

Whether the session has no work waiting for fire_rules/2.

Attaches a listener, returning a new session.

Types

t()

@type t() :: %Rete.Session{state: Rete.Engine.State.t()}

Functions

facts(session)

@spec facts(t()) :: [term()]

Every fact the session holds, inserted or concluded, one entry for each occurrence.

Unordered. A session is a multiset of facts, not a sequence. A fact held twice appears twice here, whether two inserts put it there or two matches concluded it.

The two halves answer on different clocks. insert/2 and retract/2 update working memory at once, so an inserted fact appears here before anything matches it. A concluded fact is as of the most recent fire, because concluding one is what fire_rules/2 does. So a session with a queued retract shows the conclusions that rested on the fact it took out. settled?/1 reports whether a fire is owed.

fire_rules(session, opts \\ [])

@spec fire_rules(
  t(),
  keyword()
) :: t()

Fires rules until the agenda is empty, returning a new session.

Options:

  • :max_cycles — how many cycles one call may fire. A cycle is one pass of the fire loop: one activation at the default concurrency, one whole activation group above it. 100_000 by default, which no settling ruleset has reached. A ruleset that exceeds it raises with the rules that fired most. One that fires the whole allowance and then settles is fine. Give it a smaller integer to catch a loop sooner, or :infinity to remove the cap. See docs/design/observability.md §3 for how to pick a number.
  • :concurrency — how many rule bodies of one activation group run at once. 1 by default, which fires them one at a time. Raise it when a body does I/O or real computation. The bodies of a group then run on tasks, and their conclusions are applied in group order. A body that only builds a tuple is about 1.5% of firing, and it costs more than that to hand to a task, so the default suits ordinary rules. Above 1, a body runs on a task. It does not inherit Logger.metadata, and its bindings are copied, which is expensive for a collection binding.
  • :timeout — milliseconds one body may take, or :infinity, the default. Applies only when :concurrency is above 1.

This is the only call that propagates. insert/2 and retract/2 queue work. Firing drains that queue, matches everything waiting, runs the rules that match, and returns at quiescence. So a session you have not fired holds facts but no matches.

iex> alias Rete.Session
iex> queued =
...>   Session.new([Rete.Doc.Orders])
...>   |> Session.insert([{:customer, 1}, {:order, 1, 250}])
iex> Rete.Doc.Orders.flagged_for(queued, 1)
[]
iex> Rete.Doc.Orders.flagged_for(Session.fire_rules(queued), 1)
[{1, 250}]

from_network(network)

@spec from_network(Rete.Network.t()) :: t()

Builds an empty session over an already compiled network.

The network is immutable, so one can back any number of independent sessions.

insert(session, facts)

@spec insert(t(), term() | [term()]) :: t()

Inserts one fact or a list of them, returning a new session.

Facts are a multiset, and every occurrence matches. Inserting a fact equal to one already present gives the rules a second match of it, and it takes a second retraction to remove.

Inserting does not match anything. It records the fact and queues the work. fire_rules/2 is what matches, and until you call it no rule has seen the fact.

iex> alias Rete.Session
iex> session = Session.new([Rete.Doc.Orders]) |> Session.insert({:customer, 1})
iex> Session.facts(session)
[{:customer, 1}]

listener_state(session, module)

@spec listener_state(t(), module()) :: term()

What a listener has accumulated, or nil if it is not attached.

network(session)

@spec network(t()) :: Rete.Network.t()

The compiled network behind a session.

new(modules, opts \\ [])

@spec new(
  [module()],
  keyword()
) :: t()

Builds a session from ruleset modules.

Options go to Rete.Compiler.build/2. Compiling the network is the expensive part, so a long-lived application should do it once and use from_network/1.

iex> Rete.Session.new([Rete.Doc.Orders]) |> Rete.Session.facts()
[]

query(session, ref, params \\ [])

@spec query(t(), {module(), atom()}, keyword() | %{required(atom()) => term()}) :: [
  term()
]

Runs a query by {module, name}: one result per match, computed by its body.

Usually you would not write this. defquery flagged_for(...) defines flagged_for/2 in its own module, so the same call reads Rete.Doc.Orders.flagged_for(session, 1), which the compiler checks. Use this form when the query is decided at runtime.

params gives a value for every binding that the head of the query makes, and for no other name. Write it as a keyword list or a map. A query with no head takes no parameters. A partial key, an extra key or an unknown key raises an error. It does not answer [].

This takes the bindings, and not the head. The head of a query is a pattern, and the generated function matches it. This call is dispatched by {module, name} at run time, so it cannot know that pattern. It takes the names the pattern binds instead. For a head of ({cid, tid}), the generated function takes {1, 2} and this one takes %{cid: 1, tid: 2}. A head guard holds here too, so a value it rejects answers []. See Rete.Ruleset.defquery/2.

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, which is stale rather than empty. A query reads propagated state either way, and it does not raise, where Rete.Inspect.why_not/2 does. Call settled?/1 to tell the two cases apart.

Row order is unspecified. Rows come back in the order the facts arrived, so the same facts fed in a different order answer in a different order. The set of rows never varies. Sort the result yourself if order matters to you.

A parameter matches a binding by term equality, in the same way as a map key. 1 and 1.0 are therefore different parameter values, but == reports that they are equal.

iex> alias Rete.Session
iex> session =
...>   Session.new([Rete.Doc.Orders])
...>   |> Session.insert([{:customer, 1}, {:order, 1, 250}])
...>   |> Session.fire_rules()
iex> Session.query(session, {Rete.Doc.Orders, :flagged_for}, cid: 1)
[{1, 250}]

A second order, queued and not yet fired. The answer is the one from before it, not []:

iex> alias Rete.Session
iex> stale =
...>   Session.new([Rete.Doc.Orders])
...>   |> Session.insert([{:customer, 1}, {:order, 1, 250}])
...>   |> Session.fire_rules()
...>   |> Session.insert({:order, 1, 900})
iex> Session.settled?(stale)
false
iex> Session.query(stale, {Rete.Doc.Orders, :flagged_for}, cid: 1)
[{1, 250}]
iex> fired = Session.fire_rules(stale)
iex> fired |> Session.query({Rete.Doc.Orders, :flagged_for}, cid: 1) |> Enum.sort()
[{1, 250}, {1, 900}]

retract(session, facts)

@spec retract(t(), term() | [term()]) :: t()

Retracts one fact or a list of them, returning a new session.

Anything concluded from them is retracted too, transitively. Retracting a fact that is not present does nothing.

Like insert/2, this queues the work rather than doing it. The fact leaves at once, and the conclusions that rested on it leave when fire_rules/2 drains the queue.

iex> alias Rete.Session
iex> session =
...>   Session.new([Rete.Doc.Orders])
...>   |> Session.insert([{:customer, 1}, {:order, 1, 250}])
...>   |> Session.fire_rules()
...>   |> Session.retract({:customer, 1})
...>   |> Session.fire_rules()
iex> Session.facts(session)
[{:order, 1, 250}]

settled?(session)

@spec settled?(t()) :: boolean()

Whether the session has no work waiting for fire_rules/2.

false means insert/2 or retract/2 recorded a fact that no rule has seen yet. A query on such a session answers as though the fact never arrived, so this is the check to make when you did not write the insert/2 yourself.

A session fresh from new/1 is not settled. new/1 queues the root token, which the first fire plants. So a rule with no conditions has not fired yet either.

iex> alias Rete.Session
iex> queued = Session.new([Rete.Doc.Orders]) |> Session.insert({:customer, 1})
iex> Session.settled?(queued)
false
iex> Session.settled?(Session.fire_rules(queued))
true

with_listener(session, module, init \\ nil)

@spec with_listener(t(), module(), term()) :: t()

Attaches a listener, returning a new session.

The listener sees every event from now on. Read what it accumulated with listener_state/2. Attaching several is fine, and they see events in attachment order.

iex> alias Rete.Session
iex> Session.new([Rete.Doc.Orders])
...> |> Session.with_listener(Rete.Listener.Collect, [])
...> |> Session.insert({:customer, 1})
...> |> Rete.Listener.Collect.by_tag(:fact_inserted)
[{:fact_inserted, {:customer, 1}, :asserted}]