fast_decision/lib.rs
1//! # fast-decision
2//!
3//! A high-performance rule engine.
4//!
5//! This crate provides a rule evaluation engine optimized for speed with zero-cost abstractions.
6//!
7//! ## Features
8//!
9//! - **Priority-based evaluation**: Rules are sorted by priority (lower values = higher priority)
10//! - **Stop-on-first**: Per-category flag to stop evaluation after the first matching rule
11//! - **Condition operators**: `$equals`, `$not-equals`, `$greater-than`, `$less-than`, `$greater-than-or-equals`, `$less-than-or-equals`, `$in`, `$not-in`, `$contains`, `$starts-with`, `$ends-with`, `$regex`, `$and`, `$or`
12//! - **Zero-cost abstractions**: Optimized Rust core with minimal allocations in hot paths
13//! - **Python bindings**: Native performance accessible from Python via PyO3
14//!
15//! ## Architecture
16//!
17//! The engine consists of three main components:
18//! - Rule evaluation engine ([`RuleEngine`])
19//! - Type definitions and data structures ([`RuleSet`], [`Category`], [`Rule`], [`Predicate`])
20//! - Python bindings via PyO3 (`FastDecision` class)
21//!
22//! ## Performance Characteristics
23//!
24//! - O(n) rule evaluation where n is the number of rules in requested categories
25//! - O(d) nested field access where d is the depth of field path
26//! - Minimal allocations during evaluation (results only)
27//! - Optimized comparison operations with inline hints
28//!
29//! ## Example (Rust)
30//!
31//! ```rust,no_run
32//! use fast_decision::{RuleEngine, RuleSet};
33//! use serde_json::json;
34//!
35//! let rules_json = r#"
36//! {
37//! "categories": {
38//! "Pricing": {
39//! "stop_on_first": true,
40//! "rules": [{
41//! "id": "Premium",
42//! "priority": 1,
43//! "conditions": {"user.tier": {"$equals": "Gold"}},
44//! "action": "apply_discount"
45//! }]
46//! }
47//! }
48//! }
49//! "#;
50//!
51//! let ruleset: RuleSet = serde_json::from_str(rules_json).unwrap();
52//! let engine = RuleEngine::new(ruleset);
53//!
54//! let data = json!({"user": {"tier": "Gold"}});
55//! let results = engine.evaluate_rules(&data, &["Pricing"]);
56//! println!("Triggered rules: {:?}", results);
57//! ```
58
59use pyo3::prelude::*;
60use pyo3::types::{PyDict, PyList};
61use pythonize::pythonize;
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 evaluation 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.evaluate_rules(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 /// Evaluates 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 evaluate
183 ///
184 /// # Returns
185 ///
186 /// List of rule objects (as Python dictionaries) that matched the data, in priority order.
187 /// Each dictionary contains: id, priority, conditions, action.
188 ///
189 /// # Performance
190 ///
191 /// Converts Python dict to Rust `Value` once, then evaluates rules natively.
192 /// Uses pythonize for efficient Rust → Python conversion without intermediate JSON.
193 ///
194 /// # Example
195 ///
196 /// ```python
197 /// data = {"user": {"tier": "Gold"}}
198 /// results = engine.evaluate_rules(data, categories=["Pricing"])
199 /// for rule in results:
200 /// print(f"Rule {rule['id']}: {rule['action']}")
201 /// ```
202 fn evaluate_rules(
203 &self,
204 py: Python<'_>,
205 data: &Bound<'_, PyDict>,
206 categories: Vec<String>,
207 ) -> PyResult<Vec<PyObject>> {
208 let value = pyany_to_value(data.as_any())?;
209 let categories_refs: Vec<&str> = categories.iter().map(String::as_str).collect();
210 let results = self.engine.evaluate_rules(&value, &categories_refs);
211
212 // Direct Rust → Python conversion with pythonize (no intermediate JSON)
213 let mut py_results = Vec::with_capacity(results.len());
214 for rule in results {
215 py_results.push(pythonize(py, rule)?.unbind());
216 }
217 Ok(py_results)
218 }
219
220 /// Evaluates rules against JSON string data.
221 ///
222 /// # Arguments
223 ///
224 /// * `data_json` - JSON string containing the data to evaluate
225 /// * `categories` - List of category names to evaluate
226 ///
227 /// # Returns
228 ///
229 /// List of rule objects (as Python dictionaries) that matched the data, in priority order.
230 /// Each dictionary contains: id, priority, conditions, action.
231 ///
232 /// # Errors
233 ///
234 /// Returns `PyValueError` if the JSON string is invalid.
235 ///
236 /// # Performance
237 ///
238 /// Faster than `evaluate()` if data is already in JSON format
239 /// (avoids Python→Rust conversion overhead).
240 /// Uses pythonize for efficient Rust → Python conversion.
241 ///
242 /// # Example
243 ///
244 /// ```python
245 /// data_json = '{"user": {"tier": "Gold"}}'
246 /// results = engine.evaluate_rules_from_json(data_json, categories=["Pricing"])
247 /// for rule in results:
248 /// print(f"Rule {rule['id']}: {rule['action']}")
249 /// ```
250 fn evaluate_rules_rules_from_json(
251 &self,
252 py: Python<'_>,
253 data_json: &str,
254 categories: Vec<String>,
255 ) -> PyResult<Vec<PyObject>> {
256 let value: Value = serde_json::from_str(data_json).map_err(|e| {
257 PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("Invalid JSON: {}", e))
258 })?;
259
260 let categories_refs: Vec<&str> = categories.iter().map(String::as_str).collect();
261 let results = self.engine.evaluate_rules(&value, &categories_refs);
262
263 // Direct Rust → Python conversion with pythonize (no intermediate JSON)
264 let mut py_results = Vec::with_capacity(results.len());
265 for rule in results {
266 py_results.push(pythonize(py, rule)?.unbind());
267 }
268 Ok(py_results)
269 }
270}
271
272#[pymodule]
273fn fast_decision(m: &Bound<'_, PyModule>) -> PyResult<()> {
274 m.add_class::<FastDecision>()?;
275 Ok(())
276}