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:
- Getting started: loading rules and running them against your types.
- Facts: how Rust values become facts and back.
- The rule language and the built-in functions.
- The execution model: which rules fire, in what order, and how often.
- Your own functions and methods, callable from rules.
- Migrating from Grule: the Go API side by side, and the differences.
- Performance: benchmarks against Grule’s.
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.grlfile under a directory, recursively, in path order.add_rule(rule)adds a rule built in code withRule::newand theast::Expressionconstructors.
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.grlfile, run against a serde struct.methods: methods on your own types called from rules, and anOutfact that rules report rejection reasons into.speed_up: Grule’s classicTestCarrule 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:
FactValue | GRL | From Rust |
|---|---|---|
String | "text" | String, &str, char |
Integer(i64) | 42 | signed and unsigned integers |
Float(f64) | 1.5 | f32, f64 |
Boolean | true | bool |
Time | Now(), MakeTime(...) | DateTime fields marked with serde_time |
Object | Fact.Field | structs and maps |
Array | Fact.Items[0] | Vec, slices, tuples |
Null | nil | None, () |
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.
nilcan 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;
Retractand 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
| Kind | Literals |
|---|---|
| Strings | "..." or '...', with Go’s escapes: \n, \t, \", \\, \x41, \101, é, \U0001F600 |
| Integers | 42, -7, 0x2A (hex), 052 (octal) |
| Floats | 1.5, .5, 1e3, 2.5E-3, 0x1p-2 |
| Booleans | true, false |
| Nil | nil |
Times have no literal; they come from Now(), MakeTime(...) or facts. Facts may also
hold objects and arrays.
Reading facts
Factreads a fact;Fact.Field.Innerreads nested fields.Fact.Items[0]indexes arrays, andFact.Codes["key"]orFact.Codes[404]maps.Fact.Method(args)calls a method, andFunction(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:
| Operators | Notes |
|---|---|
|| | 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
| Kind | Functions |
|---|---|
| Engine | Retract(name), Complete() (statements only); Changed(path), Forget(path) |
| Logging | Log(value), LogFormat(format, value) |
| Checks | IsNil(x), IsZero(x), StringContains(s, sub) |
| Time | Now(), 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) |
| Math | Max, 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
| Type | Methods |
|---|---|
| String | Len(), 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) |
| Array | Len(), 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:
- Find the rules whose conditions hold.
- Fire the one with the highest salience; among equal saliences, the one added first.
Firing runs its
thenstatements, which usually change facts. - 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::MaxCyclesExceededafter 5000 cycles.set_max_cycles(n)changes the limit. - Cancellation.
execute_with_cancel(&mut facts, &flag)stops withEngineError::Cancelledonce theAtomicBoolis set (from another thread), andexecute_with_timeout(&mut facts, duration)withEngineError::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’sFetchMatchingRules).add_listener(listener)attaches anEngineListener, 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 aVec(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
thenblock 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 = 100 | engine.set_max_cycles(100); |
eng.ReturnErrOnFailedRuleEvaluation = true | engine.set_return_err_on_failed_rule_evaluation(true); |
eng.Listeners = append(eng.Listeners, l) | engine.add_listener(l); |
| Methods on fact structs | engine.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::MAXbecome floats, andFloat64bitsreturns its 64 bits as a signed integer. nilcan be compared with==against any value (it equals onlynil), 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
thenblock 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().
| Benchmark | 100 rules | 1000 rules | Grule (published) |
|---|---|---|---|
| Load rules (parse and compile) | 2.8 ms | 28.7 ms | 21–99 ms / 210–934 ms |
| Execute a fact (Grule’s benchmark) | 0.81 µs | 0.82 µs | 9.7 µs / 569 µs |
| Execute a fact that matches no rule | 2.2 µs | 16.8 µs | |
| Chain of N cycles, each enabling the next rule | 68 µs | 0.72 ms | |
| Same chain, condition caching off | 0.84 ms | 87 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;
executereuses 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:
- Latest from
main, published with this guide. - docs.rs, for each released version.
The main types are:
| Type | Purpose |
|---|---|
RuleEngine | Loads rules, registers functions and methods, and executes. |
Fact, FactValue | Facts and their values, and conversion from and to your types. |
serde_time | Serde attributes that make DateTime fields time values. |
EngineError | Everything that can go wrong while executing. |
EngineListener | Callbacks as cycles begin and rules are evaluated and fired. |
Rule, ast::Expression | Rules built in code rather than loaded from GRL. |