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}