fast_decision/lib.rs
1//! # fast-decision
2//!
3//! A high-performance rule engine with MongoDB-style query syntax.
4//!
5//! This crate provides a rule execution engine optimized for speed with zero-cost abstractions.
6//! Rules are defined using a MongoDB-style syntax and can be executed against JSON data.
7//!
8//! ## Features
9//!
10//! - **Priority-based execution**: Rules are sorted by priority (lower values = higher priority)
11//! - **Stop-on-first**: Per-category flag to stop execution after the first matching rule
12//! - **MongoDB-style operators**: `$eq`, `$ne`, `$gt`, `$lt`, `$gte`, `$lte`, `$and`, `$or`
13//! - **Zero-cost abstractions**: Optimized Rust core with minimal allocations in hot paths
14//! - **Python bindings**: Native performance accessible from Python via PyO3
15//!
16//! ## Architecture
17//!
18//! The engine consists of three main components:
19//! - Rule execution engine ([`RuleEngine`])
20//! - Type definitions and data structures ([`RuleSet`], [`Category`], [`Rule`], [`Predicate`])
21//! - Python bindings via PyO3 (`FastDecision` class)
22//!
23//! ## Performance Characteristics
24//!
25//! - O(n) rule evaluation where n is the number of rules in requested categories
26//! - O(d) nested field access where d is the depth of field path
27//! - Minimal allocations during execution (results only)
28//! - Optimized comparison operations with inline hints
29//!
30//! ## Example (Rust)
31//!
32//! ```rust,no_run
33//! use fast_decision::{RuleEngine, RuleSet};
34//! use serde_json::json;
35//!
36//! let rules_json = r#"
37//! {
38//! "categories": {
39//! "Pricing": {
40//! "stop_on_first": true,
41//! "rules": [{
42//! "id": "Premium",
43//! "priority": 1,
44//! "conditions": {"user.tier": {"$eq": "Gold"}},
45//! "action": "apply_discount"
46//! }]
47//! }
48//! }
49//! }
50//! "#;
51//!
52//! let ruleset: RuleSet = serde_json::from_str(rules_json).unwrap();
53//! let engine = RuleEngine::new(ruleset);
54//!
55//! let data = json!({"user": {"tier": "Gold"}});
56//! let results = engine.execute(&data, &["Pricing"]);
57//! println!("Triggered rules: {:?}", results);
58//! ```
59
60use pyo3::prelude::*;
61use pyo3::types::{PyDict, PyList};
62use serde_json::Value;
63
64mod engine;
65mod types;
66
67pub use crate::engine::RuleEngine;
68pub use crate::types::{Category, Comparison, Operator, Predicate, Rule, RuleSet};
69
70/// Converts a Python object to a `serde_json::Value`.
71///
72/// Supports:
73/// - Dictionaries → JSON objects
74/// - Lists → JSON arrays
75/// - Strings, integers, floats, booleans → corresponding JSON types
76/// - None → JSON null
77///
78/// # Errors
79///
80/// Returns `PyTypeError` if the object type is not supported.
81///
82/// # Performance
83///
84/// Recursively processes nested structures. Pre-allocates collections with known capacity.
85fn pyany_to_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
86 if let Ok(dict) = obj.downcast::<PyDict>() {
87 let mut map = serde_json::Map::with_capacity(dict.len());
88 for (key, value) in dict.iter() {
89 let key_str: String = key.extract()?;
90 map.insert(key_str, pyany_to_value(&value)?);
91 }
92 Ok(Value::Object(map))
93 } else if let Ok(list) = obj.downcast::<PyList>() {
94 let mut vec = Vec::with_capacity(list.len());
95 for item in list.iter() {
96 vec.push(pyany_to_value(&item)?);
97 }
98 Ok(Value::Array(vec))
99 } else if let Ok(s) = obj.extract::<String>() {
100 Ok(Value::String(s))
101 } else if let Ok(i) = obj.extract::<i64>() {
102 Ok(Value::Number(i.into()))
103 } else if let Ok(f) = obj.extract::<f64>() {
104 Ok(Value::Number(serde_json::Number::from_f64(f).ok_or_else(
105 || PyErr::new::<pyo3::exceptions::PyValueError, _>("Invalid float"),
106 )?))
107 } else if let Ok(b) = obj.extract::<bool>() {
108 Ok(Value::Bool(b))
109 } else if obj.is_none() {
110 Ok(Value::Null)
111 } else {
112 Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
113 "Unsupported type",
114 ))
115 }
116}
117
118/// Python interface to the rule engine.
119///
120/// This class provides Python bindings via PyO3, allowing native-performance
121/// rule execution from Python code.
122///
123/// # Example (Python)
124///
125/// ```python
126/// from fast_decision import FastDecision
127///
128/// engine = FastDecision("rules.json")
129/// data = {"user": {"tier": "Gold"}, "amount": 100}
130/// results = engine.execute(data, categories=["Pricing"])
131/// print(f"Triggered rules: {results}")
132/// ```
133#[pyclass]
134struct FastDecision {
135 engine: RuleEngine,
136}
137
138#[pymethods]
139impl FastDecision {
140 /// Creates a new FastDecision engine from a JSON rules file.
141 ///
142 /// # Arguments
143 ///
144 /// * `rules_path` - Path to the JSON file containing rule definitions
145 ///
146 /// # Errors
147 ///
148 /// - `PyIOError`: If the file cannot be read
149 /// - `PyValueError`: If the JSON is invalid or malformed
150 ///
151 /// # Example
152 ///
153 /// ```python
154 /// engine = FastDecision("path/to/rules.json")
155 /// ```
156 #[new]
157 fn new(rules_path: &str) -> PyResult<Self> {
158 let json_str = std::fs::read_to_string(rules_path).map_err(|e| {
159 PyErr::new::<pyo3::exceptions::PyIOError, _>(format!(
160 "Failed to read rules file: {}",
161 e
162 ))
163 })?;
164
165 let ruleset: RuleSet = serde_json::from_str(&json_str).map_err(|e| {
166 PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
167 "Failed to parse rules JSON: {}",
168 e
169 ))
170 })?;
171
172 Ok(FastDecision {
173 engine: RuleEngine::new(ruleset),
174 })
175 }
176
177 /// Executes rules against Python dictionary data.
178 ///
179 /// # Arguments
180 ///
181 /// * `data` - Python dictionary containing the data to evaluate
182 /// * `categories` - List of category names to execute
183 ///
184 /// # Returns
185 ///
186 /// List of rule IDs (as strings) that matched the data, in priority order.
187 ///
188 /// # Performance
189 ///
190 /// Converts Python dict to Rust `Value` once, then executes rules natively.
191 /// Recommended for in-memory data that's already in Python.
192 ///
193 /// # Example
194 ///
195 /// ```python
196 /// data = {"user": {"tier": "Gold"}}
197 /// results = engine.execute(data, categories=["Pricing"])
198 /// ```
199 fn execute(&self, data: &Bound<'_, PyDict>, categories: Vec<String>) -> PyResult<Vec<String>> {
200 let value = pyany_to_value(data.as_any())?;
201 let categories_refs: Vec<&str> = categories.iter().map(String::as_str).collect();
202 let results = self.engine.execute(&value, &categories_refs);
203
204 // Pre-allocate with exact capacity to minimize allocations
205 let mut owned_results = Vec::with_capacity(results.len());
206 for &rule_id in &results {
207 owned_results.push(rule_id.to_owned());
208 }
209 Ok(owned_results)
210 }
211
212 /// Executes rules against JSON string data.
213 ///
214 /// # Arguments
215 ///
216 /// * `data_json` - JSON string containing the data to evaluate
217 /// * `categories` - List of category names to execute
218 ///
219 /// # Returns
220 ///
221 /// List of rule IDs (as strings) that matched the data, in priority order.
222 ///
223 /// # Errors
224 ///
225 /// Returns `PyValueError` if the JSON string is invalid.
226 ///
227 /// # Performance
228 ///
229 /// Faster than `execute()` if data is already in JSON format
230 /// (avoids Python→Rust conversion overhead).
231 ///
232 /// # Example
233 ///
234 /// ```python
235 /// data_json = '{"user": {"tier": "Gold"}}'
236 /// results = engine.execute_json(data_json, categories=["Pricing"])
237 /// ```
238 fn execute_json(&self, data_json: &str, categories: Vec<String>) -> PyResult<Vec<String>> {
239 let value: Value = serde_json::from_str(data_json).map_err(|e| {
240 PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Invalid JSON: {}", e))
241 })?;
242
243 let categories_refs: Vec<&str> = categories.iter().map(String::as_str).collect();
244 let results = self.engine.execute(&value, &categories_refs);
245
246 // Pre-allocate with exact capacity to minimize allocations
247 let mut owned_results = Vec::with_capacity(results.len());
248 for &rule_id in &results {
249 owned_results.push(rule_id.to_owned());
250 }
251 Ok(owned_results)
252 }
253}
254
255#[pymodule]
256fn fast_decision(m: &Bound<'_, PyModule>) -> PyResult<()> {
257 m.add_class::<FastDecision>()?;
258 Ok(())
259}