Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

rust-runes is a rule engine for embedding business logic in Rust applications. It is compatible with Grule’s rule language (GRL) and execution model: Grule’s .grl files load unchanged and fire the same rules.

Rules state what to do when a condition holds:

rule LongRide "Long rides get a flat rate" salience 10 {
    when
        Ride.Distance > 5000 && !Ride.Priced
    then
        Ride.NetAmount = 143.32;
        Ride.Priced = true;
}

Your application keeps the rules outside its code, hands the engine some facts (usually its own serde types), and the engine runs the rules until none applies.

This guide covers:

The API documentation describes every type and method.

rust-runes is an independent implementation; it is not affiliated with the Grule project.

Getting started

Installation

[dependencies]
rust-runes = "0.1.0"
serde = { version = "1", features = ["derive"] }

rust-runes requires Rust 1.83 or later. serde is only needed to convert your own types into facts, which is the usual way to use the engine.

Rules

Rules are usually kept in .grl files, outside your code:

// rules/pricing.grl
rule LongRide "Long rides get a flat rate" salience 10 {
    when
        Ride.Distance > 5000 && !Ride.Priced
    then
        Ride.NetAmount = 143.32;
        Ride.Priced = true;
        Log("Priced a long ride of " + Ride.Distance + "m");
}

rule ShortRide "Short rides are priced per meter" {
    when
        Ride.Distance <= 5000 && !Ride.Priced
    then
        Ride.NetAmount = Ride.Distance * 0.025;
        Ride.Priced = true;
}

Each rule has a name, an optional description, an optional salience (priority, default 0), a when condition and then actions. Ride is a fact your program provides.

Running them

Facts can be any serializable type. Field names are used as written, so structs that rules read often keep Grule-style capitalized names:

use rust_runes::{Fact, RuleEngine};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Debug)]
#[allow(non_snake_case)]
struct Ride {
    Distance: i64,
    NetAmount: f64,
    Priced: bool,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut engine = RuleEngine::new();
    engine.add_rules_from_dir("rules")?;

    let ride = Ride { Distance: 6000, NetAmount: 0.0, Priced: false };
    let mut facts = HashMap::from([("Ride".to_string(), Fact::from_serialize("Ride", &ride)?)]);

    let result = engine.execute(&mut facts)?;

    let ride: Ride = facts["Ride"].deserialize()?;
    println!("fired {:?}, net amount {}", result.rules_fired, ride.NetAmount);
    Ok(())
}

Facts are passed as a map from the name rules use to the fact. execute runs rules until none applies and changes the facts in place; deserialize turns a fact back into your type. The result lists the rules that fired, in order.

The same program with the rules inline, as a test would write it:

use rust_runes::{Fact, RuleEngine};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize)]
#[allow(non_snake_case)]
struct Ride {
    Distance: i64,
    NetAmount: f64,
    Priced: bool,
}

let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule LongRide salience 10 {
        when Ride.Distance > 5000 && !Ride.Priced
        then Ride.NetAmount = 143.32; Ride.Priced = true;
    }
    rule ShortRide {
        when Ride.Distance <= 5000 && !Ride.Priced
        then Ride.NetAmount = Ride.Distance * 0.025; Ride.Priced = true;
    }
"#)?;

for (distance, expected) in [(6000, 143.32), (1200, 30.0)] {
    let ride = Ride { Distance: distance, NetAmount: 0.0, Priced: false };
    let mut facts = HashMap::from([("Ride".to_string(), Fact::from_serialize("Ride", &ride)?)]);
    engine.execute(&mut facts)?;
    let ride: Ride = facts["Ride"].deserialize()?;
    assert_eq!(ride.NetAmount, expected);
}
fn shareable<T: Send + Sync>(_: &T) {}
shareable(&engine);
Ok::<(), Box<dyn std::error::Error>>(())

execute takes &self, so one engine can serve many executions, including from several threads at once.

Loading rules

  • add_rules_from_str(grl) loads rules from text.
  • add_rules_from_file(path) loads one file.
  • add_rules_from_dir(dir) loads every .grl file under a directory, recursively, in path order.
  • add_rule(rule) adds a rule built in code with Rule::new and the ast::Expression constructors.

Each returns the number of rules added. A syntax error reports the file, line and column, and nothing from a file that fails to load is added (files loaded before it in the same directory stay loaded). Rule names must be unique.

Examples

The repository’s examples/ directory has complete programs:

  • quickstart: rules from a .grl file, run against a serde struct.
  • methods: methods on your own types called from rules, and an Out fact that rules report rejection reasons into.
  • speed_up: Grule’s classic TestCar rule firing cycle after cycle, with a listener.

Run one with cargo run --example quickstart.

Facts

Facts are the data rules read and change. Each fact has a name, which rules use to refer to it (Ride in Ride.Distance), and a FactValue:

FactValueGRLFrom Rust
String"text"String, &str, char
Integer(i64)42signed and unsigned integers
Float(f64)1.5f32, f64
Booleantruebool
TimeNow(), MakeTime(...)DateTime fields marked with serde_time
ObjectFact.Fieldstructs and maps
ArrayFact.Items[0]Vec, slices, tuples
NullnilNone, ()

Integers and floats compare equal by value (1 == 1.0).

From your types

Fact::from_serialize converts any Serialize value, and deserialize converts a fact back into any Deserialize type, typically the one it came from:

use rust_runes::{Fact, RuleEngine};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize)]
#[allow(non_snake_case)]
struct Order {
    Total: f64,
    Items: Vec<String>,
    Codes: HashMap<i64, String>,
    Coupon: Option<String>,
}

let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule BigOrder {
        when Order.Total > 100 && Order.Items.Len() > 1 && Order.Coupon == nil
        then Order.Coupon = Order.Codes[404]; Order.Items.Append("gift");
    }
"#)?;

let order = Order {
    Total: 150.0,
    Items: vec!["book".into(), "pen".into()],
    Codes: HashMap::from([(404, "WELCOME".to_string())]),
    Coupon: None,
};
let mut facts = HashMap::from([("Order".to_string(), Fact::from_serialize("Order", &order)?)]);
engine.execute(&mut facts)?;

let order: Order = facts["Order"].deserialize()?;
assert_eq!(order.Coupon.as_deref(), Some("WELCOME"));
assert_eq!(order.Items, ["book", "pen", "gift"]);
Ok::<(), Box<dyn std::error::Error>>(())

Field names are used exactly as serde writes them, so #[serde(rename = "...")] and #[serde(rename_all = "...")] work if you’d rather keep snake case in Rust. Maps with integer keys work as in Go (Order.Codes[404]). Unsigned integers above i64::MAX become floats.

Times

chrono serializes DateTime as a string, which rules would see as a string. Annotate time fields with serde_time to make them time values that rules can compare and pass to the time functions:

use chrono::{DateTime, TimeZone, Utc};
use rust_runes::{Fact, RuleEngine};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize)]
#[allow(non_snake_case)]
struct Order {
    #[serde(with = "rust_runes::serde_time")]
    Created: DateTime<Utc>,
    #[serde(with = "rust_runes::serde_time::option")]
    Shipped: Option<DateTime<Utc>>,
    Late: bool,
}

let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Late {
        when Order.Shipped == nil && IsTimeBefore(Order.Created, MakeTime(2026, 1, 1, 0, 0, 0)) && !Order.Late
        then Order.Late = true;
    }
"#)?;

let order = Order {
    Created: Utc.with_ymd_and_hms(2025, 12, 1, 9, 30, 0).unwrap(),
    Shipped: None,
    Late: false,
};
let mut facts = HashMap::from([("Order".to_string(), Fact::from_serialize("Order", &order)?)]);
engine.execute(&mut facts)?;
assert!(facts["Order"].deserialize::<Order>()?.Late);
Ok::<(), Box<dyn std::error::Error>>(())

Outside fact conversion, annotated fields serialize as ordinary RFC 3339 strings, so the annotation doesn’t change the rest of your serialization (JSON APIs, databases).

Field types are kept

A field that already holds a value keeps its type when rules assign to it, as a Go struct field would:

  • Numbers convert: assigning a float to an integer field truncates it.
  • Other mismatches, such as a number assigned to a string field, are errors.
  • nil can be assigned to any field.
  • Assigning to a field that doesn’t exist yet creates it.
use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Split {
        when Bill.Shares == 0
        then Bill.Shares = Bill.Total / 3; Bill.Note = "split three ways";
    }
"#)?;

let bill = HashMap::from([("Total", 100), ("Shares", 0)]);
let mut facts = HashMap::from([("Bill".to_string(), Fact::from_serialize("Bill", &bill)?)]);
engine.execute(&mut facts)?;

// 100 / 3 is 33.33..., truncated to fit the integer field
assert_eq!(facts["Bill"].get_field("Shares"), Some(&FactValue::Integer(33)));
assert_eq!(facts["Bill"].get_field("Note"), Some(&FactValue::from("split three ways")));
Ok::<(), Box<dyn std::error::Error>>(())

Building facts by hand

Facts don’t have to come from a Rust type. FactValue converts from strings, numbers, booleans and times, and Fact::from_object builds an object fact from a map:

use rust_runes::{Fact, FactValue};
use std::collections::HashMap;

let customer = Fact::from_object(
    "Customer".to_string(),
    HashMap::from([
        ("Name".to_string(), FactValue::from("Ada")),
        ("Age".to_string(), FactValue::from(36)),
        ("Tags".to_string(), FactValue::Array(vec!["vip".into()])),
    ]),
);
assert_eq!(customer.get_field("Age").and_then(FactValue::as_integer), Some(36));

Fact::integer_fact, number_fact, string_fact and boolean_fact create facts that are a single value, which rules read by name (when Limit > 10).

The rule language

rust-runes reads GRL, the rule language of Grule, following Grule’s grammar (grulev3.g4).

Rules

rule Name "optional description" salience 10 {
    when <condition>
    then <statement>; <statement>; ...
}
  • Name: unique across the engine; Retract and errors refer to rules by name.
  • Description: optional, in quotes.
  • Salience: optional priority, an integer (negative too), default 0. When several rules match, the highest salience fires first; see the execution model.
  • when: an expression that must evaluate to a boolean.
  • then: statements, each ending with ;. The block may be empty.

Keywords (rule, salience, when, then, true, false, nil) are case-insensitive, and identifiers may use Unicode letters. Comments use // and /* */.

/* Discounts for loyal customers */
rule LoyaltyDiscount "Loyal customers get 10% off" salience 20 {
    when
        Customer.Years >= 5 &&      // five years or more
        !Order.Discounted
    then
        Order.Total *= 0.9;
        Order.Discounted = true;
}

Values

KindLiterals
Strings"..." or '...', with Go’s escapes: \n, \t, \", \\, \x41, \101, é, \U0001F600
Integers42, -7, 0x2A (hex), 052 (octal)
Floats1.5, .5, 1e3, 2.5E-3, 0x1p-2
Booleanstrue, false
Nilnil

Times have no literal; they come from Now(), MakeTime(...) or facts. Facts may also hold objects and arrays.

Reading facts

  • Fact reads a fact; Fact.Field.Inner reads nested fields.
  • Fact.Items[0] indexes arrays, and Fact.Codes["key"] or Fact.Codes[404] maps.
  • Fact.Method(args) calls a method, and Function(args) a function. Calls can be chained: Fact.Name.ToUpper().HasPrefix("A").

Reading a field or element that doesn’t exist is an error. In a condition, errors make the condition false by default (see failed conditions).

Operators

From lowest to highest precedence:

OperatorsNotes
||Logical or, short-circuits
&&Logical and, short-circuits
== != < <= > >=See comparisons below
+ - & |+ also joins strings; & and | are bitwise, on integers
* / %% takes integers
! -Unary not and negation
.Field [index] .Method()

Parentheses group as usual. Unlike in many languages, all comparisons share one level and & and | bind tighter than comparisons, as in Grule.

Arithmetic. Two integers give an integer and anything with a float gives a float, except /, which always gives a float: 7 / 2 == 3.5. Use Floor or assign to an integer field to truncate. Division by zero is an error.

Strings. + joins two strings, or a string with a number, boolean or time ("Total: " + 9.5 is "Total: 9.500000", formatting floats as Go’s %f does).

Comparisons. Numbers compare with numbers (integers and floats by value), strings with strings and times with times; all three can be ordered. A string or boolean is never equal to a value of another type, nil equals only nil, and comparing a number with a non-number is an error.

Booleans are strict: &&, || and ! take booleans only, and a condition must be a boolean. There is no truthiness.

These rules are all checked by tests:

use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Check {
        when !R.Done
        then
            R.Div = 7 / 2;
            R.Mod = 7 % 2;
            R.Mixed = 2 * 1.5;
            R.Joined = "n=" + 3 + ", ok=" + true;
            R.Precedence = 1 + 2 * 3 == 7 && 1 | 2 == 3;
            R.Done = true;
    }
"#)?;
let mut facts = HashMap::from([(
    "R".to_string(),
    Fact::from_object("R".to_string(), HashMap::from([("Done".to_string(), false.into())])),
)]);
engine.execute(&mut facts)?;

let r = &facts["R"];
assert_eq!(r.get_field("Div"), Some(&FactValue::Float(3.5)));
assert_eq!(r.get_field("Mod"), Some(&FactValue::Integer(1)));
assert_eq!(r.get_field("Mixed"), Some(&FactValue::Float(3.0)));
assert_eq!(r.get_field("Joined"), Some(&FactValue::from("n=3, ok=true")));
assert_eq!(r.get_field("Precedence"), Some(&FactValue::Boolean(true)));
Ok::<(), Box<dyn std::error::Error>>(())

Statements

The then block holds statements:

  • Assignments to facts, fields and elements: =, +=, -=, *=, /=. Order.Lines[0].Price *= 2; changes the nested element in place.
  • Function calls: Log("checked");, Retract("ThisRule");, Complete();.
  • Method calls: Order.Items.Append("gift");. A method called as a statement on a fact path changes the stored fact.

Assignments keep the type of a field that already has a value; see Facts.

rule Restock "Reorder low stock" salience 5 {
    when
        Item.Stock < Item.ReorderLevel && !Item.Ordered
    then
        Item.Order.Quantity = Item.ReorderLevel * 2 - Item.Stock;
        Item.History.Append("reordered " + Item.Order.Quantity);
        Item.Ordered = true;
        Log("Reordering " + Item.Name);
}

Built-in functions

All of Grule’s built-in functions and methods are available, with the same names and behavior.

Functions

KindFunctions
EngineRetract(name), Complete() (statements only); Changed(path), Forget(path)
LoggingLog(value), LogFormat(format, value)
ChecksIsNil(x), IsZero(x), StringContains(s, sub)
TimeNow(), MakeTime(year, month, day, hour, minute, second), GetTimeYear(t), GetTimeMonth(t), GetTimeDay(t), GetTimeHour(t), GetTimeMinute(t), GetTimeSecond(t), IsTimeBefore(t, u), IsTimeAfter(t, u), TimeFormat(t, layout)
MathMax, Min, Abs, Ceil, Floor, Round, RoundToEven, Trunc, Sqrt, Cbrt, Pow, Pow10, Exp, Exp2, Expm1, MathLog, Log10, Log1p, Log2, Logb, Ilogb, Mod, Remainder, Dim, Copysign, Hypot, Ldexp, Sin, Cos, Tan, Asin, Acos, Atan, Atan2, Sinh, Cosh, Tanh, Asinh, Acosh, Atanh, Erf, Erfc, Erfinv, Erfcinv, Gamma, J0, J1, Jn, IsInf, IsNaN, NaN, Signbit, Float64bits, Float64frombits

Engine. Retract("Rule") removes a rule for the rest of the execution, and Complete() ends the execution after the current rule’s actions; see the execution model. Changed("Fact.Field") and Forget("Fact.Field") tell the engine a path changed in a way it couldn’t see, so conditions reading it are evaluated again.

Logging goes through the log crate, at info level with target grule; install a logger (env_logger, tracing-log, …) to see it. LogFormat takes a Go format string ("%s is %d", %v, %.2f, …).

Time. MakeTime normalizes out-of-range values as Go’s time.Date does (month 13 is January of the next year). TimeFormat uses Go layouts, written as the reference time Mon Jan 2 15:04:05 MST 2006: "2006-01-02", "02/01/2006 15:04", "Jan 2, 2006".

Math functions are Go’s math package, by the same names, except MathLog for the natural logarithm (Log is logging).

Methods

TypeMethods
StringLen(), Compare(s), Contains(s), In(s, ...), Count(s), HasPrefix(s), HasSuffix(s), Index(s), LastIndex(s), Repeat(n), Replace(old, new), Split(sep), ToLower(), ToUpper(), Trim(), MatchString(regex)
ArrayLen(), Append(x, ...), Contains(x)
Object (map)Len()

As in Go, string lengths and positions count bytes, not characters. In is true when the string equals any of its arguments. Append called as a statement on a fact path (Order.Items.Append("x");) changes the stored array.

Your own functions and methods are registered from Rust; see Your own functions and methods.

Examples

use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Examples {
        when !R.Done
        then
            R.Date = TimeFormat(MakeTime(2025, 13, 1, 0, 0, 0), "2006-01-02");
            R.Month = GetTimeMonth(MakeTime(2025, 13, 1, 0, 0, 0));
            R.Rounded = Round(2.5);
            R.Biggest = Max(3, 7.5, 1);
            R.Words = "a,b,c".Split(",").Len();
            R.Shouting = "hello".ToUpper() + "!";
            R.Valid = "AB-123".MatchString("^[A-Z]{2}-[0-9]+$");
            R.Size = "café".Len();
            R.Listed = "red".In("green", "red");
            R.Done = true;
    }
"#)?;
let mut facts = HashMap::from([(
    "R".to_string(),
    Fact::from_object("R".to_string(), HashMap::from([("Done".to_string(), false.into())])),
)]);
engine.execute(&mut facts)?;

let r = &facts["R"];
assert_eq!(r.get_field("Date"), Some(&FactValue::from("2026-01-01")));
assert_eq!(r.get_field("Month"), Some(&FactValue::Integer(1)));
assert_eq!(r.get_field("Rounded"), Some(&FactValue::Float(3.0)));
assert_eq!(r.get_field("Biggest"), Some(&FactValue::Float(7.5)));
assert_eq!(r.get_field("Words"), Some(&FactValue::Integer(3)));
assert_eq!(r.get_field("Shouting"), Some(&FactValue::from("HELLO!")));
assert_eq!(r.get_field("Valid"), Some(&FactValue::Boolean(true)));
assert_eq!(r.get_field("Size"), Some(&FactValue::Integer(5))); // bytes
assert_eq!(r.get_field("Listed"), Some(&FactValue::Boolean(true)));
Ok::<(), Box<dyn std::error::Error>>(())

Execution model

As in Grule, execute runs rules until nothing more applies:

  1. Find the rules whose conditions hold.
  2. Fire the one with the highest salience; among equal saliences, the one added first. Firing runs its then statements, which usually change facts.
  3. Start the next cycle with the updated facts. When no condition holds, execution ends.

Only one rule fires per cycle, and its changes are visible to every rule in the next cycle. A rule fires again whenever its condition still holds, so a rule’s actions usually make its own condition false (!Order.Discounted … Order.Discounted = true), or the rule retracts itself.

use rust_runes::{Fact, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Base salience 10 {
        when Order.Total == 0
        then Order.Total = Order.Price * Order.Quantity;
    }
    rule Discount salience 5 {
        when Order.Total > 100 && !Order.Discounted
        then Order.Total = Order.Total * 0.9; Order.Discounted = true;
    }
    rule Shipping {
        when Order.Total > 0 && Order.Shipping == 0
        then Order.Shipping = 5;
    }
"#)?;

let order = HashMap::from([
    ("Price", serde_json::json!(60)), ("Quantity", serde_json::json!(2)),
    ("Total", serde_json::json!(0)), ("Shipping", serde_json::json!(0)),
    ("Discounted", serde_json::json!(false)),
]);
let mut facts = HashMap::from([("Order".to_string(), Fact::from_serialize("Order", &order)?)]);
let result = engine.execute(&mut facts)?;

// Shipping matched from cycle 2 on, but higher-salience rules went first
assert_eq!(result.rules_fired, ["Base", "Discount", "Shipping"]);
Ok::<(), Box<dyn std::error::Error>>(())

Controlling execution

  • Retract("Rule") removes a rule for the rest of this execution. A rule that retracts itself fires at most once.
  • Complete() ends the execution once the current rule’s statements have run.
  • Maximum cycles. A rule that never makes its own condition false would fire forever, so execution fails with EngineError::MaxCyclesExceeded after 5000 cycles. set_max_cycles(n) changes the limit.
  • Cancellation. execute_with_cancel(&mut facts, &flag) stops with EngineError::Cancelled once the AtomicBool is set (from another thread), and execute_with_timeout(&mut facts, duration) with EngineError::TimedOut. Both are checked before each rule evaluation, like a Go context.

Facts keep the changes made before an error.

Failed conditions

A condition that fails to evaluate, because of a missing field, a type error or a non-boolean result, is logged and counts as false, as in Grule. To make it an error instead:

use rust_runes::{EngineError, Fact, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.add_rules_from_str(r#"
    rule Typo { when Order.Totl > 100 then Order.Big = true; }
"#)?;
let mut facts = HashMap::from([(
    "Order".to_string(),
    Fact::from_serialize("Order", &HashMap::from([("Total", 150)]))?,
)]);

// By default the condition is just false
assert!(engine.execute(&mut facts)?.rules_fired.is_empty());

engine.set_return_err_on_failed_rule_evaluation(true);
let err = engine.execute(&mut facts).unwrap_err();
assert!(matches!(err, EngineError::InRule { ref rule, .. } if rule == "Typo"));
println!("{err}"); // names the rule and whether it failed in `when` or `then`
Ok::<(), Box<dyn std::error::Error>>(())

Errors in then statements always stop execution. Errors from rules are EngineError::InRule, naming the rule and the scope; err.root() gives the underlying error. && and || short-circuit, so Order.Items != nil && Order.Items.Len() > 0 doesn’t fail.

Inspecting execution

  • fetch_matching_rules(&facts) returns the rules whose conditions hold, highest salience first, without firing anything (Grule’s FetchMatchingRules).
  • add_listener(listener) attaches an EngineListener, notified when each cycle begins, as each rule is evaluated, and before each rule fires:
use rust_runes::{EngineListener, Rule, RuleEngine};

struct Tracer;

impl EngineListener for Tracer {
    fn execute_rule(&self, cycle: u64, rule: &Rule) {
        println!("cycle {cycle}: firing {}", rule.name);
    }
}

let mut engine = RuleEngine::new();
engine.add_listener(Tracer);

The ExecutionResult lists the rules that fired, in order, and the execution time.

Avoiding repeated work

Evaluated naively, every cycle re-evaluates every rule. The engine avoids most of that without changing which rules fire:

  • Shared sub-expressions. Identical boolean sub-expressions across all rules (Order.Total > 100, A && B, …) are recognized when rules load and evaluated once, with the result reused until a fact path they read changes. This is the role of Grule’s working memory.
  • An incremental agenda. Each rule’s condition result is kept until a rule assigns to, or calls a method on, a fact path the condition reads. A cycle only evaluates rules whose inputs changed, and finding the next rule to fire doesn’t scan the rule set.
  • Stopping at the first match. Rules below the cycle’s highest-salience match aren’t evaluated, unless that would be observable: a listener is attached, evaluation errors are returned, or the condition logs or calls your code. Then every rule is evaluated every cycle, as in Grule.

The engine only sees changes made by rules. If a function or method you registered changes facts the engine can’t attribute to a path, call Changed("Fact.Field") after it so conditions reading that path are evaluated again. Conditions that use Now(), logging, or your own functions or methods are re-evaluated every cycle regardless. set_condition_caching(false) turns result reuse off entirely.

Random rule sets are run with these optimizations on and off in the test suite, to check they never change the outcome.

Your own functions and methods

Rules can call code you register on the engine: functions (Percent(Order.Total, 10)) and methods (Loan.Payment(0.06)). Register them before executing; registering a name twice, or a function with a built-in’s name, is an error.

Functions

A function takes the evaluated arguments as FactValues and returns a FactValue, or an error message:

use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;

let mut engine = RuleEngine::new();
engine.register_function("Percent", |args| match args {
    [total, percent] => match (total.as_number(), percent.as_number()) {
        (Some(t), Some(p)) => Ok(FactValue::Float(t * p / 100.0)),
        _ => Err("Percent takes two numbers".to_string()),
    },
    _ => Err(format!("Percent takes 2 arguments, got {}", args.len())),
})?;

engine.add_rules_from_str(r#"
    rule Discount {
        when Order.Discount == 0
        then Order.Discount = Percent(Order.Total, 10);
    }
"#)?;
let order = HashMap::from([("Total", 250.0), ("Discount", 0.0)]);
let mut facts = HashMap::from([("Order".to_string(), Fact::from_serialize("Order", &order)?)]);
engine.execute(&mut facts)?;
assert_eq!(facts["Order"].get_field("Discount"), Some(&FactValue::Float(25.0)));
Ok::<(), Box<dyn std::error::Error>>(())

The engine can’t see what your code depends on, so conditions that call your functions are evaluated every cycle rather than cached.

Methods on your own types

In Grule, facts are Go structs and rules call their methods. The Rust equivalent is register_typed_method: the fact is converted into your type for the call and back afterwards, the arguments are decoded into a tuple, and the return value becomes a fact value.

use rust_runes::{Fact, RuleEngine};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize)]
#[allow(non_snake_case)]
struct Loan {
    Amount: f64,
    Escrow: f64,
    Monthly: f64,
}

impl Loan {
    fn monthly_payment(&self, annual_rate: f64, years: f64) -> f64 {
        let r = annual_rate / 12.0;
        self.Amount * r / (1.0 - (1.0 + r).powf(-years * 12.0))
    }
}

let mut engine = RuleEngine::new();
// No arguments: `()`. No result: return `Ok(())`, which becomes nil.
engine.register_typed_method("CalculateEscrow", |loan: &mut Loan, (): ()| {
    loan.Escrow = loan.Amount * 0.01;
    Ok(())
})?;
// Fixed arguments decode into a tuple
engine.register_typed_method("Payment", |loan: &mut Loan, (rate, years): (f64, f64)| {
    Ok(loan.monthly_payment(rate, years))
})?;

engine.add_rules_from_str(r#"
    rule Price {
        when Loan.Monthly == 0
        then Loan.CalculateEscrow(); Loan.Monthly = Loan.Payment(0.06, 30);
    }
"#)?;
let loan = Loan { Amount: 300_000.0, Escrow: 0.0, Monthly: 0.0 };
let mut facts = HashMap::from([("Loan".to_string(), Fact::from_serialize("Loan", &loan)?)]);
engine.execute(&mut facts)?;

let loan: Loan = facts["Loan"].deserialize()?;
assert_eq!(loan.Escrow, 3000.0);
assert_eq!(loan.Monthly.round(), 1799.0);
Ok::<(), Box<dyn std::error::Error>>(())
  • Arguments are a tuple for a fixed number ((f64,) for one, (String, i64) for two), () for none, or a Vec (Vec<f64>, Vec<FactValue>) for any number. A call with the wrong number or types of arguments is an error.
  • Changes persist when the method is called as a statement in a then block on a fact path (Loan.CalculateEscrow();), like a pointer receiver in Go. Called anywhere else, such as in a condition or inside an expression, it works on a copy.
  • Several types can register the same method name; a call goes to the first type the fact converts into. Fields the fact has that your type doesn’t are kept.
  • Cost. Each call converts the fact into your type and back with serde. That’s fine for occasional calls, but not for a method evaluated in thousands of conditions per execution; there, prefer a field the rules can read, or an untyped method.

The methods example shows a common Grule pattern: an Out fact with a Reject(reason) method that rules call to collect rejection reasons.

Untyped methods

register_method works on FactValues directly, without conversions, and applies to any value that has no built-in method of that name:

use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.register_method("Charge", |account, args| {
    let amount = args.first().and_then(FactValue::as_number).ok_or("Charge takes an amount")?;
    let FactValue::Object(fields) = account else {
        return Err("Charge is called on an account".to_string());
    };
    let balance = fields.get("Balance").and_then(FactValue::as_number).unwrap_or(0.0);
    fields.insert("Balance".to_string(), FactValue::Float(balance - amount));
    Ok(FactValue::Null)
})?;

engine.add_rules_from_str(r#"
    rule Fee { when !Account.Charged then Account.Charge(10); Account.Charged = true; }
"#)?;
let account = HashMap::from([("Balance", serde_json::json!(100.0)), ("Charged", serde_json::json!(false))]);
let mut facts = HashMap::from([("Account".to_string(), Fact::from_serialize("Account", &account)?)]);
engine.execute(&mut facts)?;
assert_eq!(facts["Account"].get_field("Balance"), Some(&FactValue::Float(90.0)));
Ok::<(), Box<dyn std::error::Error>>(())

The same rule about persistence applies: the method receives the stored value when called as a statement on a fact path, and a copy otherwise.

Migrating from Grule

Rules written for Grule load unchanged; the Go code around them maps to Rust like this.

The API side by side

Grule (Go)rust-runes
lib := ast.NewKnowledgeLibrary()
rb := builder.NewRuleBuilder(lib)
let mut engine = RuleEngine::new();
rb.BuildRuleFromResource("Name", "0.1", pkg.NewFileResource("rules.grl"))engine.add_rules_from_file("rules.grl")?;
pkg.NewBytesResource([]byte(grl))engine.add_rules_from_str(grl)?;
kb, _ := lib.NewKnowledgeBaseInstance("Name", "0.1")(the engine holds the rules)
dataCtx := ast.NewDataContext()
dataCtx.Add("Fact", &fact)
let mut facts = HashMap::new();
facts.insert("Fact".into(), Fact::from_serialize("Fact", &fact)?);
eng := engine.NewGruleEngine()
eng.Execute(dataCtx, kb)
engine.execute(&mut facts)?;
eng.ExecuteWithContext(ctx, dataCtx, kb)engine.execute_with_timeout(&mut facts, d)? or execute_with_cancel(&mut facts, &flag)?
eng.FetchMatchingRules(dataCtx, kb)engine.fetch_matching_rules(&facts)?
eng.MaxCycle = 100engine.set_max_cycles(100);
eng.ReturnErrOnFailedRuleEvaluation = trueengine.set_return_err_on_failed_rule_evaluation(true);
eng.Listeners = append(eng.Listeners, l)engine.add_listener(l);
Methods on fact structsengine.register_typed_method(...) (see Your own functions and methods)

Facts

In Grule, rules change your Go structs directly through pointers. Here the facts are converted into FactValues, rules change those, and you convert the result back with facts["Fact"].deserialize(). Field names are the same as long as your serde field names match the Go field names, which is why the examples keep capitalized names.

Field types behave as in Go: assigning a float to an integer field truncates it, and assigning a string to a number field is an error.

Function facts

A common Grule pattern is a fact whose only purpose is its methods, used as a library of functions (dataCtx.Add("DF", &Functions{}), then DF.Percent(Order.Total, 10) in rules). Either register the functions with register_function and call them without the prefix, or keep the rules unchanged with an empty fact and untyped methods:

use rust_runes::{Fact, FactValue, RuleEngine};
use std::collections::HashMap;
let mut engine = RuleEngine::new();
engine.register_method("Percent", |_df, args| {
    let (total, percent) = (args[0].as_number(), args[1].as_number());
    match (total, percent) {
        (Some(t), Some(p)) => Ok(FactValue::Float(t * p / 100.0)),
        _ => Err("Percent takes two numbers".to_string()),
    }
})?;
engine.add_rules_from_str(r#"
    rule Discount { when Order.Discount == 0 then Order.Discount = DF.Percent(Order.Total, 10); }
"#)?;

let mut facts = HashMap::from([
    ("DF".to_string(), Fact::from_object("DF".to_string(), HashMap::new())),
    ("Order".to_string(), Fact::from_serialize("Order", &HashMap::from([("Total", 80.0), ("Discount", 0.0)]))?),
]);
engine.execute(&mut facts)?;
assert_eq!(facts["Order"].get_field("Discount"), Some(&FactValue::Float(8.0)));
Ok::<(), Box<dyn std::error::Error>>(())

Several rule sets

Grule’s knowledge library holds named, versioned knowledge bases. Here an engine holds one set of rules: create one engine per rule set and keep them in a map if you need to pick one by name. Engines are Send + Sync, and execute takes &self, so each can be shared across threads (in an Arc) and run concurrently.

Differences

  • No RETE network, as in Grule itself: there are no join nodes or partial matches. Results are cached and invalidated by the fact paths rules write (see Avoiding repeated work).
  • Loading is from GRL text, files and directories, not Grule’s JSON rule format, URLs, Git bundles or resource bundles.
  • Facts are dynamic values rather than Go structs. Assigning to a field that doesn’t exist yet creates it (Grule reports an error), unsigned integers above i64::MAX become floats, and Float64bits returns its 64 bits as a signed integer.
  • nil can be compared with == against any value (it equals only nil), and assigned to any field.
  • Doubled quotes inside a string ("say ""hi""") stand for one quote; Grule’s lexer accepts them but then rejects the string.
  • Grammar: a rule’s then block may be empty, and unary - applies to any expression.
  • Method results are never stale. Grule can cache a method call’s result in a condition across cycles even after the receiver changed; here a condition calling your methods is evaluated again every cycle.

Behavior that differs from Grule in any other way is a bug; please report it with the rules and facts that show it.

Performance

cargo bench runs criterion benchmarks (benches/grule_comparison.rs) that mirror Grule’s own, with the same rule set as Grule’s examples/benchmark/*.grl: 100 or 1000 identical rules plus one with higher salience, which matches and calls Complete().

Benchmark100 rules1000 rulesGrule (published)
Load rules (parse and compile)2.8 ms28.7 ms21–99 ms / 210–934 ms
Execute a fact (Grule’s benchmark)0.81 µs0.82 µs9.7 µs / 569 µs
Execute a fact that matches no rule2.2 µs16.8 µs
Chain of N cycles, each enabling the next rule68 µs0.72 ms
Same chain, condition caching off0.84 ms87 ms

Measured on an Apple M5. Grule’s figures come from its own benchmark documentation, on different hardware and across several runs, so compare orders of magnitude rather than exact ratios.

Where the difference comes from

  • Executing stays nearly flat as rules grow: identical sub-expressions across rules are evaluated once, rule results are reused until a fact they read changes, and lower rules aren’t evaluated once the cycle’s best match is found. See Avoiding repeated work.
  • Chains grow linearly with caching on, because each cycle only re-evaluates the rules whose inputs changed; with it off, every cycle re-evaluates every rule.
  • Loading is a parse into compiled rules; Grule also builds its RETE network.

Keeping rules fast

  • Load once, execute many times. Loading parses and compiles; execute reuses the compiled rules, and one engine can serve all threads.
  • Prefer fields to methods in conditions. Conditions that call your functions or methods, log, or use Now() can’t be cached and are evaluated every cycle. A typed method also converts its fact with serde on every call. Computing a value once (in a high-salience rule, or before executing) and reading it as a field avoids both.
  • Listeners and returned errors cost more. With a listener attached or set_return_err_on_failed_rule_evaluation(true), every rule is evaluated every cycle, as Grule does, so that each evaluation is observed.

API documentation

The API documentation describes every public type and method:

The main types are:

TypePurpose
RuleEngineLoads rules, registers functions and methods, and executes.
Fact, FactValueFacts and their values, and conversion from and to your types.
serde_timeSerde attributes that make DateTime fields time values.
EngineErrorEverything that can go wrong while executing.
EngineListenerCallbacks as cycles begin and rules are evaluated and fired.
Rule, ast::ExpressionRules built in code rather than loaded from GRL.