datalogic_rs/node/logic.rs
1//! `Logic` — the compiled, thread-safe rule snapshot returned by
2//! `Engine::compile`. Includes the static-evaluation predicates the compiler
3//! consults to decide whether a sub-expression can be folded.
4
5use super::{CompiledNode, populate_lits};
6use crate::arena::DataValue;
7use crate::opcode::OpCode;
8use datavalue::OwnedDataValue;
9
10/// Compiled logic that can be evaluated multiple times across different data.
11///
12/// `Logic` represents a pre-processed JSONLogic expression that has been
13/// optimized for repeated evaluation. It's thread-safe and can be shared across
14/// threads using `Arc`.
15///
16/// # Performance Benefits
17///
18/// - **Parse once, evaluate many**: Avoid repeated JSON parsing
19/// - **Static evaluation**: Constant expressions are pre-computed
20/// - **OpCode dispatch**: Built-in operators use fast enum dispatch
21/// - **Thread-safe sharing**: Use `Arc` to share across threads
22///
23/// # Example
24///
25/// ```rust
26/// use std::sync::Arc;
27/// use datalogic_rs::Engine;
28///
29/// let engine = Engine::new();
30/// let compiled = Arc::new(engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap());
31///
32/// // Compiled logic can be cloned cheaply (atomic refcount) and sent across threads.
33/// let compiled_clone = Arc::clone(&compiled);
34/// std::thread::spawn(move || {
35/// let engine = Engine::new();
36/// let _result = engine
37/// .session()
38/// .eval_str(&compiled_clone, r#"{"score": 95}"#)
39/// .unwrap();
40/// });
41/// ```
42///
43/// `Logic` is `Clone` (deep-clones the compiled tree). Cloning is the right
44/// choice when a caller needs an independently mutable copy or wants to
45/// store the rule by value; for sharing the *same* compiled rule across
46/// threads or evaluations, prefer `Arc<Logic>` — the `Arc::clone` is a
47/// single atomic refcount bump rather than a tree walk.
48#[derive(Clone)]
49pub struct Logic {
50 /// The root node of the compiled logic tree.
51 pub(crate) root: CompiledNode,
52 /// Conservative upper bound on the static portion of arena allocations
53 /// this rule will need (literals, structured-object skeletons, etc.).
54 /// Used to size the per-call `Bump` so the first chunk is large enough.
55 /// `pub(crate)` — internal arena infrastructure.
56 pub(crate) arena_static_bytes: usize,
57 /// Pre-resolved operator name for the root node, attached to every
58 /// `Error` returned from the public `evaluate*` API. Cached at compile
59 /// time so the error-unwind path does no tree walk. `Cow::Borrowed`
60 /// for built-ins (zero alloc on attach), `Cow::Owned` for
61 /// `CustomOperator` (one alloc per compile, amortised over many
62 /// evaluations), `None` for `Value` literals.
63 pub(crate) root_op_name: Option<std::borrow::Cow<'static, str>>,
64}
65
66impl std::fmt::Debug for Logic {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("Logic")
69 .field("root", &self.root)
70 .field("arena_static_bytes", &self.arena_static_bytes)
71 .field("root_op_name", &self.root_op_name)
72 .finish_non_exhaustive()
73 }
74}
75
76/// Static operator-name lookup for the root node. Returns `Cow::Borrowed`
77/// for built-ins and the named compiled-node forms (`var`, `missing`,
78/// etc.) — these never allocate at compile time. `CustomOperator`
79/// returns `Cow::Owned` (one allocation per compile, then re-cloneable as
80/// many times as the rule errors). `Value` literals have no operator and
81/// return `None`.
82#[inline]
83fn root_op_name(node: &CompiledNode) -> Option<std::borrow::Cow<'static, str>> {
84 use std::borrow::Cow;
85 match node {
86 CompiledNode::BuiltinOperator { opcode, .. } => Some(Cow::Borrowed(opcode.as_str())),
87 CompiledNode::Var { .. } => Some(Cow::Borrowed("var")),
88 CompiledNode::Missing(_) => Some(Cow::Borrowed("missing")),
89 CompiledNode::MissingSome(_) => Some(Cow::Borrowed("missing_some")),
90 #[cfg(feature = "ext-control")]
91 CompiledNode::Exists(_) => Some(Cow::Borrowed("exists")),
92 #[cfg(feature = "error-handling")]
93 CompiledNode::Throw(_) => Some(Cow::Borrowed("throw")),
94 CompiledNode::CustomOperator(data) => Some(Cow::Owned(data.name.clone())),
95 CompiledNode::InvalidArgs { op_name, .. } => Some(Cow::Borrowed(op_name)),
96 _ => None,
97 }
98}
99
100impl Logic {
101 /// Creates a new compiled logic from a root node.
102 ///
103 /// Caches per-operator analysis results onto every `BuiltinOperator`
104 /// node. Trivial literals (Null/Bool/Number/empty) are pre-built by
105 /// [`super::populate::precompute_lit`] at construction; non-trivial literals
106 /// (non-empty Strings/Arrays/Objects) fall through to `literal_fallback`
107 /// at dispatch time.
108 ///
109 /// # Arguments
110 ///
111 /// * `root` - The root node of the compiled logic tree
112 pub(crate) fn new(mut root: CompiledNode) -> Self {
113 let arena_static_bytes = estimate_arena_static_bytes(&root);
114 populate_lits(&mut root);
115 let root_op_name = root_op_name(&root);
116 Self {
117 root,
118 arena_static_bytes,
119 root_op_name,
120 }
121 }
122
123 /// Check if this compiled logic is static (can be evaluated without context)
124 pub fn is_static(&self) -> bool {
125 node_is_static(&self.root)
126 }
127
128 /// Reconstruct a JSONLogic string from this compiled tree.
129 ///
130 /// Reflects the *compiled* shape — constant-folded sub-expressions
131 /// appear as literals, since the original operator is gone by then.
132 /// Re-parsing the output through [`crate::Engine::compile`] yields a
133 /// `Logic` that evaluates identically. Useful for caching keys, identity
134 /// checks across compiled rules, debug logging, and tooling.
135 ///
136 /// `Var` nodes serialise to `{"var": "..."}` for `scope_level == 0`
137 /// and to `{"val": [[<level>], ...]}` for `scope_level > 0` — that's
138 /// the shape the compiler accepts on round-trip.
139 ///
140 /// # Example
141 ///
142 /// ```rust
143 /// use datalogic_rs::Engine;
144 ///
145 /// let engine = Engine::new();
146 /// let compiled = engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap();
147 /// let json = compiled.to_json();
148 /// assert!(json.contains(r#""var": "score""#));
149 ///
150 /// // Round-trip: re-compiling the output produces an equivalent rule.
151 /// let recompiled = engine.compile(&json).unwrap();
152 /// assert_eq!(
153 /// engine.eval_str(&json, r#"{"score": 95}"#).unwrap(),
154 /// "true",
155 /// );
156 /// # let _ = (compiled, recompiled);
157 /// ```
158 pub fn to_json(&self) -> String {
159 crate::node_serialize::node_to_json_string(&self.root)
160 }
161}
162
163impl std::fmt::Display for Logic {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.write_str(&self.to_json())
166 }
167}
168
169/// Estimate the static (rule-dependent, data-independent) portion of arena
170/// bytes this rule will need at evaluation time. Conservative — overestimating
171/// is harmless (one larger bumpalo chunk), underestimating costs an extra
172/// chunk allocation. Data-dependent allocations (filter results, map outputs)
173/// can't be predicted here.
174fn estimate_arena_static_bytes(node: &CompiledNode) -> usize {
175 // Base cost per node when promoted to `DataValue`: the enum itself plus
176 // a slice-header fudge for nodes whose payload lives as `&[…]` in the
177 // arena (Array, Object, structured-object fields). The DataValue-size
178 // term tracks layout changes automatically — without datetime it's
179 // typically 24 bytes (8-byte discriminant + 16-byte fat-pointer
180 // payload), with datetime it grows to fit `DataDateTime`. String
181 // content for literals is added separately by `estimate_value_bytes`.
182 const PER_NODE: usize =
183 std::mem::size_of::<DataValue<'static>>() + std::mem::size_of::<&[u8]>();
184 let mut bytes = PER_NODE;
185
186 // Per-variant size contributions that aren't covered by recursing into
187 // AST children (literal payloads, structured-object key strings, etc.).
188 match node {
189 CompiledNode::Value { value, .. } => {
190 bytes += estimate_value_bytes(value);
191 }
192 #[cfg(feature = "error-handling")]
193 CompiledNode::Throw(data) => {
194 bytes += estimate_value_bytes(&data.error);
195 }
196 #[cfg(feature = "templating")]
197 CompiledNode::StructuredObject(data) => {
198 for (k, _) in data.fields.iter() {
199 bytes += k.len();
200 }
201 }
202 _ => {}
203 }
204
205 // Recurse into AST children via the shared visitor — single source of
206 // truth for "what are this node's children".
207 node.visit_children(&mut |child| {
208 bytes += estimate_arena_static_bytes(child);
209 });
210
211 bytes
212}
213
214fn estimate_value_bytes(v: &OwnedDataValue) -> usize {
215 match v {
216 OwnedDataValue::String(s) => s.len() + 16,
217 OwnedDataValue::Array(arr) => 16 + arr.iter().map(estimate_value_bytes).sum::<usize>(),
218 OwnedDataValue::Object(pairs) => {
219 16 + pairs
220 .iter()
221 .map(|(k, v)| k.len() + estimate_value_bytes(v))
222 .sum::<usize>()
223 }
224 _ => 0,
225 }
226}
227
228/// Check if a compiled node is static (can be evaluated without runtime context).
229pub(crate) fn node_is_static(node: &CompiledNode) -> bool {
230 match node {
231 CompiledNode::Value { .. } => true,
232 CompiledNode::Array { nodes, .. } => nodes.iter().all(node_is_static),
233 CompiledNode::BuiltinOperator { opcode, args, .. } => opcode_is_static(opcode, args),
234 CompiledNode::CustomOperator(_) => false,
235 CompiledNode::Var { .. } => false,
236 #[cfg(feature = "ext-control")]
237 CompiledNode::Exists(_) => false,
238 #[cfg(feature = "error-handling")]
239 CompiledNode::Throw(_) => false,
240 #[cfg(feature = "templating")]
241 CompiledNode::StructuredObject(data) => {
242 data.fields.iter().all(|(_, node)| node_is_static(node))
243 }
244 CompiledNode::Missing(_) | CompiledNode::MissingSome(_) => false,
245 // InvalidArgs is dynamic — it raises an error at runtime.
246 CompiledNode::InvalidArgs { .. } => false,
247 }
248}
249
250/// Check if an operator can be statically evaluated at compile time.
251///
252/// Static operators can be pre-computed during compilation when their arguments
253/// are also static, eliminating runtime evaluation overhead.
254///
255/// # Classification Criteria
256///
257/// An operator is **non-static** (dynamic) if it:
258/// 1. Reads from the data context (`var`, `val`, `missing`, `exists`)
259/// 2. Uses iterative callbacks with changing context (`map`, `filter`, `reduce`)
260/// 3. Has side effects or error handling (`try`, `throw`)
261/// 4. Depends on runtime state (`now` for current time)
262/// 5. Needs runtime disambiguation (`merge`, `min`, `max`)
263///
264/// All other operators are **static** when their arguments are static.
265fn opcode_is_static(opcode: &OpCode, args: &[CompiledNode]) -> bool {
266 use OpCode::*;
267
268 // Check if all arguments are static first (common pattern)
269 let args_static = || args.iter().all(node_is_static);
270
271 match opcode {
272 // Context-dependent: These operators read from the data context, which is
273 // not available at compile time. They must remain dynamic.
274 Val | Missing | MissingSome => false,
275 #[cfg(feature = "ext-control")]
276 Exists => false,
277
278 // Iteration operators: These push new contexts for each iteration and use
279 // callbacks that may reference the iteration variable. Even with static
280 // arrays, the callback logic depends on the per-element context.
281 Map | Filter | Reduce | All | Some | None => false,
282
283 // Error handling: These have control flow effects (early exit, error propagation)
284 // that should be preserved for runtime execution.
285 #[cfg(feature = "error-handling")]
286 Try | Throw => false,
287
288 // Time-dependent: Returns current UTC time, inherently non-static.
289 #[cfg(feature = "datetime")]
290 Now => false,
291
292 // Context-dependent in implicit form: when the bucketing
293 // expression is omitted, `fractional` reads `$flagd.flagKey` and
294 // `targetingKey` from the root data, so it cannot be folded even
295 // if every literal arg is static. Even the explicit form depends
296 // on the user expecting it to be evaluated per-call (the same
297 // input always produces the same output, but folding bakes in
298 // *one* bucketing key for the lifetime of the compiled rule —
299 // which is correct, but surprises users who rebuild the rule
300 // with a different bucketing strategy). Keep dynamic.
301 #[cfg(feature = "flagd")]
302 Fractional => false,
303 // `sem_ver` is pure given static args — `Version::parse` +
304 // comparison has no context dependency. Fold when every arg
305 // (version1, op, version2) is a literal. The common case is
306 // `sem_ver(var("app_version"), ">=", "1.2.0")` which has a
307 // dynamic var and stays dynamic naturally.
308 #[cfg(feature = "flagd")]
309 SemVer => args_static(),
310
311 // Runtime disambiguation needed: Merge/Min/Max have to distinguish
312 // a [1,2,3] literal from operator arguments at runtime to handle
313 // nested arrays correctly.
314 Merge | Min | Max => false,
315
316 // Pure operators: Static when all arguments are static. These perform
317 // deterministic transformations without side effects or context access.
318 _ => args_static(),
319 }
320}