Rete.DSL.Codegen (Rete v0.2.0)

Copy Markdown View Source

Expression construction and code generation: the last phase of the DSL front end.

Internal. This is the last phase before Rete.IR.escape/1. It constructs the Rete.IR.Expr descriptor of every executable the IR needs. It emits the quoted definitions spliced into the ruleset module. Both live here, so the naming and hashing scheme has one implementation.

kindaritysignature

| :alpha | 1 | (fact) -> bindings_map | nil | | :join_filter | 2 | (token_bindings, fact_bindings) -> boolean | | :test | 1 | (bindings_map) -> boolean | | RHS | 2 | (hash, bindings_map) -> facts |

An alpha matches a fact of any type, on purpose, because the alpha index applies type filtering. That is why it signals no match with nil, while a test and a join filter return false.

A code is <kind>_<type>_bind_<v1>_<v2>_..._expr_<hash>, where the variables are sorted, and the hash is :erlang.phash2/1 of the meta-stripped {args, body} pair. Two expressions with the same code behave identically. expr_defs/1 guards every definition with Module.defines?/2, so two rules of one module that share a condition share one function.

The RHS destructures a guaranteed binding in the head, and reads an optional one with Map.get/2. It binds only the variables the body reads, so an ignored one becomes %{name: _name}, and the rule compiles under --warnings-as-errors. The map keys stay untouched. See docs/design/ir.md §5.

Summary

Types

The variable ASTs of the bindings an expression destructures.

Functions

Builds the alpha Rete.IR.Expr of a condition, (fact) -> bindings_map | nil.

The stable hash of an AST fragment.

Asserts that an expression code always sees the same module attribute values.

The complete quoted body a defrule/defquery expands to.

Joins a code prefix, the sorted variable names and the hash into an expression code.

The quoted definition of a single expression function.

The quoted definitions of every expression function of a production.

The stable hash of an expression, from its argument pattern and its body.

The name of the function generated for an expression code, :"__<code>__".

Builds a join filter, (token_bindings, fact_bindings) -> boolean.

The quoted definition of a query's own function, or nil for a rule.

The quoted definition of the RHS function, (hash, bindings_map) -> facts.

Builds the Rete.IR.Expr of a test over bindings, (bindings_map) -> boolean.

Renders a fact type for use inside an expression code.

Types

bind()

@type bind() :: %{required(atom()) => Macro.t()}

The variable ASTs of the bindings an expression destructures.

Functions

alpha_expr(type, pattern, args_ast, guard, bind)

@spec alpha_expr(atom() | module(), Macro.t(), Macro.t(), Macro.t() | nil, bind()) ::
  Rete.IR.Expr.t()

Builds the alpha Rete.IR.Expr of a condition, (fact) -> bindings_map | nil.

pattern is the pattern as written, only used to compute the stable hash. args_ast is the compiled argument pattern from Rete.DSL.Parser.compile_pattern/2. guard is the alpha part of the per-condition guard, or nil. bind maps every bound variable to its AST.

The hash is taken over {pattern, body}. So a condition whose guard was wholly lifted into a join filter produces exactly the same code as the same condition written without a guard, and it shares that condition's alpha node.

ast_hash(ast)

@spec ast_hash(Macro.t()) :: non_neg_integer()

The stable hash of an AST fragment.

Two normalisations run first. Both exist so the hash is a function of what the code means, not of how it was typed:

  • metadata is stripped, so a rule keeps its hash when it moves down a file.
  • discarded variables are canonicalised to _. So {:order, _x} and {:order, _y} — byte-identical once compiled, since a _-prefixed name is never a binding — share one expression, and therefore one alpha node.

A module attribute hashes as its name, because its value cannot be known here. @limit expands to a hidden Module.__get_attribute__ call, and that call only runs once the module body is evaluated — after every macro in the body has already expanded. So two conditions over the same pattern share a code, whatever the attribute is currently worth. This is what keeps them sharing an alpha node, in the ordinary case where the value has not changed. check_attr_values!/3 catches the case where the value has changed, when the body runs.

check_attr_values!(module, code, values)

@spec check_attr_values!(module(), atom(), keyword()) :: :ok

Asserts that an expression code always sees the same module attribute values.

The ruleset module's body calls this, where attribute values are readable, once per generated expression that mentions an attribute. It raises when a code that was already generated is reached again with a different value. Otherwise, the second rule would silently reuse the first rule's compiled function.

compile(production)

@spec compile(Rete.IR.Production.t()) :: Macro.t()

The complete quoted body a defrule/defquery expands to.

In order: the query function (queries only), the expression functions, the escaped production appended to @rule_data, and the RHS function. The escaped production captures the expression functions by name, so it must come after their definitions.

The query function comes first, so that a @doc written above the defquery attaches to it. That is the one definition of the four a caller ever names.

expr_code(prefix, bind_keys, hash)

@spec expr_code([atom() | String.t()], [atom()], integer()) :: atom()

Joins a code prefix, the sorted variable names and the hash into an expression code.

expr_def(expr)

@spec expr_def(Rete.IR.Expr.t()) :: Macro.t()

The quoted definition of a single expression function.

An arity-1 expression matches the fact (or the bindings map) against its argument pattern. An arity-2 join filter matches both sides at once. The fallback for a non-matching argument is nil for an alpha, and false for a test or a join filter — matching each kind's documented return type.

expr_defs(production)

@spec expr_defs(Rete.IR.Production.t()) :: [Macro.t()]

The quoted definitions of every expression function of a production.

Deduplicated by name within the production and guarded with Module.defines?/2 across productions, so a condition shared by two rules of the same module is compiled once.

expr_hash(args, body)

@spec expr_hash(Macro.t(), Macro.t()) :: non_neg_integer()

The stable hash of an expression, from its argument pattern and its body.

Metadata (and therefore line numbers) is stripped first, so the same source text always hashes the same wherever it is written.

expr_name(code)

@spec expr_name(atom()) :: atom()

The name of the function generated for an expression code, :"__<code>__".

join_filter_expr(type, local, guard)

@spec join_filter_expr(atom() | module(), MapSet.t(atom()) | [atom()], Macro.t()) ::
  Rete.IR.Expr.t()

Builds a join filter, (token_bindings, fact_bindings) -> boolean.

This is what makes a cross-condition guard work. local is the set of variables the condition's own pattern binds. The compiler destructures every variable the guard reads from the fact side, when it is local, and from the token side otherwise. So a join variable is never bound twice in the same pattern.

The body is wrapped in if ..., do: true, else: false, so the documented boolean contract holds, whatever the user wrote.

query_def(production)

@spec query_def(Rete.IR.Production.t()) :: Macro.t() | nil

The quoted definition of a query's own function, or nil for a rule.

defquery summary(...) defines summary/1 and summary/2, so you run a query by calling it:

MyRuleset.summary(session)
MyRuleset.summary(session, cid: 1)

It delegates to Rete.Session.query/3, with {__MODULE__, name}. This is what lets two rulesets use the same query name freely — the pair is the identity, and the caller writes the module, instead of hoping the bare name is unique.

rhs_def(production)

@spec rhs_def(Rete.IR.Production.t()) :: Macro.t()

The quoted definition of the RHS function, (hash, bindings_map) -> facts.

Named after Rete.IR.rhs_name/1 of the production. So defrule loyalty(...) defines __rhs_loyalty__/2, and leaves loyalty itself alone.

Rete.IR.lhs_bindings/1 decides how the RHS reads the bindings, in two ways:

  • a guaranteed binding is destructured in the head, %{cid: cid}. A token missing it raises a FunctionClauseError, instead of firing the rule with a hole in it.
  • an optional binding — one only some branches of a disjunction bind — is read with Map.get/2 in the body instead. The tokens of the other branches genuinely do not carry the key, so it is nil there.

Either way, only the variables the body actually reads get bound. So a rule that ignores a join variable still compiles under --warnings-as-errors.

test_expr(guard, bind)

@spec test_expr(Macro.t(), bind()) :: Rete.IR.Expr.t()

Builds the Rete.IR.Expr of a test over bindings, (bindings_map) -> boolean.

Produced by a rule level guard, defrule r(...) when <guard> do.

type_code(type)

@spec type_code(atom() | module()) :: String.t()

Renders a fact type for use inside an expression code.

Module types lose their Elixir. prefix and their dots, so MyApp.Order becomes MyApp_Order and codes stay readable.