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}