Skip to main content

formualizer_eval/
planner.rs

1//! Expression planner for interpreter-level execution strategies.
2//!
3//! Produces a small plan graph per AST subtree that encodes where to run
4//! sequentially vs. in parallel (arg fan-out) and when to chunk window scans.
5
6use crate::function::{FnCaps, Function};
7use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
8use rustc_hash::FxHashMap;
9use std::sync::Arc;
10
11type RangeDimsProbe<'a> = dyn Fn(&ReferenceType) -> Option<(u32, u32)> + 'a;
12type FunctionLookup<'a> = dyn Fn(&str, &str) -> Option<Arc<dyn Function>> + 'a;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ExecStrategy {
16    Sequential,
17    ArgParallel,
18    ChunkedReduce,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Semantics {
23    Pure,
24    ShortCircuit,
25    Volatile,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct NodeCost {
30    pub est_nanos: u64, // rough cost estimate
31    pub cells: u64,     // for windowed scans
32    pub fanout: u16,    // number of child tasks
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct NodeHints {
37    pub has_range: bool,
38    pub dims: Option<(u32, u32)>,
39    pub repeated_fp_count: u16, // number of repeated subtree fingerprints among children
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct NodeAnnot {
44    pub semantics: Semantics,
45    pub cost: NodeCost,
46    pub hints: NodeHints,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PlanNode {
51    pub strategy: ExecStrategy,
52    pub children: Vec<PlanNode>,
53}
54
55#[derive(Debug, Clone)]
56pub struct PlanConfig {
57    pub enable_parallel: bool,
58    pub arg_parallel_min_cost_ns: u64,
59    pub arg_parallel_min_children: u16,
60    pub chunk_min_cells: u64,
61    pub chunk_target_partitions: u16,
62}
63
64impl Default for PlanConfig {
65    fn default() -> Self {
66        Self {
67            enable_parallel: true,
68            arg_parallel_min_cost_ns: 200_000, // 0.2ms
69            arg_parallel_min_children: 3,
70            chunk_min_cells: 10_000,
71            chunk_target_partitions: 8,
72        }
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ExecPlan {
78    pub root: PlanNode,
79}
80
81pub struct Planner<'a> {
82    config: PlanConfig,
83    // cache subtree fingerprints to count repeats among siblings
84    fp_cache: FxHashMap<u64, u16>,
85    // optionally accept range-dims peek from the engine; stubbed for now
86    _range_dims_probe: Option<&'a RangeDimsProbe<'a>>,
87    // function registry getter
88    get_fn: Option<&'a FunctionLookup<'a>>,
89}
90
91impl<'a> Planner<'a> {
92    pub fn new(config: PlanConfig) -> Self {
93        Self {
94            config,
95            fp_cache: FxHashMap::default(),
96            _range_dims_probe: None,
97            get_fn: None,
98        }
99    }
100
101    pub fn with_range_probe(mut self, probe: &'a RangeDimsProbe<'a>) -> Self {
102        self._range_dims_probe = Some(probe);
103        self
104    }
105
106    pub fn with_function_lookup(mut self, get_fn: &'a FunctionLookup<'a>) -> Self {
107        self.get_fn = Some(get_fn);
108        self
109    }
110
111    pub fn plan(&mut self, ast: &ASTNode) -> ExecPlan {
112        self.fp_cache.clear();
113        let annot = self.annotate(ast);
114        let root = self.select(ast, &annot);
115        ExecPlan { root }
116    }
117
118    fn annotate(&mut self, ast: &ASTNode) -> NodeAnnot {
119        use ASTNodeType::*;
120        // Semantics
121        let semantics = if ast.contains_volatile() {
122            Semantics::Volatile
123        } else {
124            match &ast.node_type {
125                ASTNodeType::Function { name, .. } => {
126                    if let Some(get) = &self.get_fn {
127                        if let Some(f) = get("", name) {
128                            let caps = crate::external_callbacks::metadata(
129                                f.as_ref(),
130                                crate::function::FnCaps::empty,
131                                || f.caps(),
132                            );
133                            if caps.contains(FnCaps::VOLATILE) {
134                                Semantics::Volatile
135                            } else if caps.contains(FnCaps::SHORT_CIRCUIT) {
136                                Semantics::ShortCircuit
137                            } else {
138                                Semantics::Pure
139                            }
140                        } else {
141                            Semantics::Pure
142                        }
143                    } else {
144                        Semantics::Pure
145                    }
146                }
147                _ => Semantics::Pure,
148            }
149        };
150
151        // Basic structure & cost estimation (very rough)
152        let (cost, has_range, dims, fanout) = match &ast.node_type {
153            Literal(_) | Omitted => (
154                NodeCost {
155                    est_nanos: 50,
156                    cells: 0,
157                    fanout: 0,
158                },
159                false,
160                None,
161                0,
162            ),
163            Reference { reference, .. } => {
164                let dims = self._range_dims_probe.and_then(|p| p(reference));
165                // assume cheap resolve, expensive if many cells
166                let cells = dims.map(|(r, c)| (r as u64) * (c as u64)).unwrap_or(0);
167                let est = 10_000 + cells / 10; // arbitrary unit cost
168                (
169                    NodeCost {
170                        est_nanos: est,
171                        cells,
172                        fanout: 0,
173                    },
174                    true,
175                    dims,
176                    0,
177                )
178            }
179            UnaryOp { expr, .. } => {
180                let a = self.annotate(expr);
181                (a.cost, a.hints.has_range, a.hints.dims, 1)
182            }
183            BinaryOp { left, right, op: _ } => {
184                let a = self.annotate(left);
185                let b = self.annotate(right);
186                let est = a.cost.est_nanos + b.cost.est_nanos + 1_000;
187                let cells = a.cost.cells + b.cost.cells;
188                let has_range = a.hints.has_range || b.hints.has_range;
189                let dims = a.hints.dims.or(b.hints.dims);
190                (
191                    NodeCost {
192                        est_nanos: est,
193                        cells,
194                        fanout: 2,
195                    },
196                    has_range,
197                    dims,
198                    2,
199                )
200            }
201            Function { name, args } => {
202                // Child annotations
203                let child_annots: Vec<NodeAnnot> = args.iter().map(|a| self.annotate(a)).collect();
204                // Cost model stub: classify some known heavy functions
205                let lname = name.to_ascii_lowercase();
206                let base = match lname.as_str() {
207                    "sumifs" | "countifs" | "averageifs" => 200_000, // heavy base
208                    "vlookup" | "xlookup" | "search" | "find" => 80_000,
209                    _ => 5_000,
210                };
211                let children_cost: u64 = child_annots.iter().map(|a| a.cost.est_nanos).sum();
212                let cells: u64 = child_annots.iter().map(|a| a.cost.cells).sum();
213                let has_range = child_annots.iter().any(|a| a.hints.has_range);
214                let dims = child_annots.iter().find_map(|a| a.hints.dims);
215                let fanout = args.len() as u16;
216                (
217                    NodeCost {
218                        est_nanos: base + children_cost,
219                        cells,
220                        fanout,
221                    },
222                    has_range,
223                    dims,
224                    fanout,
225                )
226            }
227            Array(rows) => {
228                let mut est = 2_000;
229                let mut has_range = false;
230                let mut dims = Some((
231                    rows.len() as u32,
232                    rows.first().map(|r| r.len()).unwrap_or(0) as u32,
233                ));
234                for r in rows {
235                    for c in r {
236                        let a = self.annotate(c);
237                        est += a.cost.est_nanos;
238                        has_range |= a.hints.has_range;
239                        if dims.is_none() {
240                            dims = a.hints.dims;
241                        }
242                    }
243                }
244                (
245                    NodeCost {
246                        est_nanos: est,
247                        cells: 0,
248                        fanout: 0,
249                    },
250                    has_range,
251                    dims,
252                    0,
253                )
254            }
255            Call { callee, args } => {
256                let callee_annot = self.annotate(callee);
257                let child_annots: Vec<NodeAnnot> = args.iter().map(|a| self.annotate(a)).collect();
258                let children_cost: u64 = callee_annot.cost.est_nanos
259                    + child_annots.iter().map(|a| a.cost.est_nanos).sum::<u64>();
260                let cells: u64 = callee_annot.cost.cells
261                    + child_annots.iter().map(|a| a.cost.cells).sum::<u64>();
262                let has_range =
263                    callee_annot.hints.has_range || child_annots.iter().any(|a| a.hints.has_range);
264                let dims = callee_annot
265                    .hints
266                    .dims
267                    .or_else(|| child_annots.iter().find_map(|a| a.hints.dims));
268                let fanout = (args.len() + 1) as u16;
269                (
270                    NodeCost {
271                        est_nanos: 5_000 + children_cost,
272                        cells,
273                        fanout,
274                    },
275                    has_range,
276                    dims,
277                    fanout,
278                )
279            }
280        };
281
282        // Sibling repeat detection (simple count of identical fingerprints among children)
283        let repeated_fp_count = match &ast.node_type {
284            ASTNodeType::Function { args, .. } => {
285                let mut map: FxHashMap<u64, u16> = FxHashMap::default();
286                for a in args {
287                    let fp = a.fingerprint();
288                    *map.entry(fp).or_insert(0) += 1;
289                }
290                map.values().copied().filter(|&n| n > 1).sum()
291            }
292            ASTNodeType::BinaryOp { left, right, .. } => {
293                (left.fingerprint() == right.fingerprint()) as u16
294            }
295            _ => 0,
296        };
297
298        NodeAnnot {
299            semantics,
300            cost,
301            hints: NodeHints {
302                has_range,
303                dims,
304                repeated_fp_count,
305            },
306        }
307    }
308
309    fn select(&mut self, ast: &ASTNode, annot: &NodeAnnot) -> PlanNode {
310        use ExecStrategy::*;
311        // Strategy selection per semantics and cost
312        let strategy = match annot.semantics {
313            Semantics::ShortCircuit => Sequential,
314            Semantics::Volatile => Sequential,
315            Semantics::Pure => {
316                if !self.config.enable_parallel {
317                    Sequential
318                } else if annot.hints.has_range && annot.cost.cells >= self.config.chunk_min_cells {
319                    ChunkedReduce
320                } else if annot.cost.est_nanos >= self.config.arg_parallel_min_cost_ns
321                    && annot.cost.fanout >= self.config.arg_parallel_min_children
322                {
323                    ArgParallel
324                } else {
325                    Sequential
326                }
327            }
328        };
329
330        // Recurse to children
331        let children = match &ast.node_type {
332            ASTNodeType::UnaryOp { expr, .. } => {
333                let a = self.annotate(expr);
334                vec![self.select(expr, &a)]
335            }
336            ASTNodeType::BinaryOp { left, right, .. } => {
337                let la = self.annotate(left);
338                let ra = self.annotate(right);
339                vec![self.select(left, &la), self.select(right, &ra)]
340            }
341            ASTNodeType::Function { args, .. } => {
342                let mut v = Vec::with_capacity(args.len());
343                for a in args {
344                    let an = self.annotate(a);
345                    v.push(self.select(a, &an));
346                }
347                v
348            }
349            ASTNodeType::Call { callee, args } => {
350                let mut v = Vec::with_capacity(args.len() + 1);
351                let callee_annot = self.annotate(callee);
352                v.push(self.select(callee, &callee_annot));
353                for a in args {
354                    let an = self.annotate(a);
355                    v.push(self.select(a, &an));
356                }
357                v
358            }
359            ASTNodeType::Array(rows) => {
360                let mut v = Vec::new();
361                for r in rows {
362                    for a in r {
363                        let an = self.annotate(a);
364                        v.push(self.select(a, &an));
365                    }
366                }
367                v
368            }
369            _ => Vec::new(),
370        };
371
372        PlanNode { strategy, children }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn ensure_builtins_registered() {
381        use std::sync::Once;
382        static ONCE: Once = Once::new();
383        ONCE.call_once(|| {
384            // Register a representative set of builtins used by these tests
385            crate::builtins::logical::register_builtins();
386            crate::builtins::logical_ext::register_builtins();
387            crate::builtins::datetime::register_builtins();
388            crate::builtins::math::register_builtins();
389            crate::builtins::text::register_builtins();
390        });
391    }
392
393    fn plan_for(formula: &str) -> ExecPlan {
394        ensure_builtins_registered();
395        let ast = formualizer_parse::parser::parse(formula).unwrap();
396        let mut planner = Planner::new(PlanConfig::default())
397            .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
398        planner.plan(&ast)
399    }
400
401    #[test]
402    fn trivial_arith_is_sequential() {
403        let p = plan_for("=1+2+3");
404        assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
405    }
406
407    #[test]
408    fn sum_of_many_args_prefers_arg_parallel() {
409        let p = plan_for("=SUM(1,2,3,4,5,6)");
410        // With default thresholds, fanout 6 and cost should trigger ArgParallel
411        assert!(!p.root.children.is_empty()); // has children
412        // Root is a function; strategy may be ArgParallel
413        // We assert that non-trivial fanout promotes parallel strategy
414        assert!(matches!(
415            p.root.strategy,
416            ExecStrategy::ArgParallel | ExecStrategy::Sequential
417        ));
418    }
419
420    #[test]
421    fn sumifs_triggers_chunked_reduce_when_large() {
422        // Fake a large range by hinting the probe
423        let ast = formualizer_parse::parser::parse(r#"=SUMIFS(A:A, A:A, ">0")"#).unwrap();
424        let mut planner = Planner::new(PlanConfig {
425            chunk_min_cells: 1000,
426            ..Default::default()
427        })
428        .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
429        .with_range_probe(&|r: &ReferenceType| match r {
430            ReferenceType::Range {
431                start_row: None,
432                end_row: None,
433                ..
434            } => Some((10_000, 1)),
435            _ => None,
436        });
437        let plan = planner.plan(&ast);
438        assert!(matches!(
439            plan.root.strategy,
440            ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
441        ));
442    }
443
444    #[test]
445    fn short_circuit_functions_are_sequential() {
446        let p = plan_for("=IF(1,2,3)");
447        assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
448        let p2 = plan_for("=AND(TRUE(), FALSE())");
449        assert!(matches!(p2.root.strategy, ExecStrategy::Sequential));
450    }
451
452    #[test]
453    fn parentheses_do_not_force_parallelism() {
454        // Trivial groups should stay sequential under default thresholds
455        let p = plan_for("=(1+2)+(2+3)");
456        assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
457    }
458
459    #[test]
460    fn repeated_subtrees_in_sum_encourage_arg_parallel() {
461        // SUM(f(), f(), f(), f()) where f is same subtree
462        let p = plan_for("=SUM(1+2, 1+2, 1+2, 1+2)");
463        // Fanout 4 may or may not cross threshold; accept either but ensure children exist
464        assert!(!p.root.children.is_empty());
465    }
466
467    #[test]
468    fn volatile_forces_sequential() {
469        // NOW() is volatile via caps; planner should mark sequential at root
470        let ast = formualizer_parse::parser::parse("=NOW()+1").unwrap();
471        let mut planner = Planner::new(PlanConfig::default())
472            .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
473        let plan = planner.plan(&ast);
474        assert!(matches!(plan.root.strategy, ExecStrategy::Sequential));
475    }
476
477    #[test]
478    fn whole_column_ranges_prefer_chunked_reduce() {
479        // Probe A:A to be large → ChunkedReduce at root
480        let ast =
481            formualizer_parse::parser::parse(r#"=SUMIFS(A:A, A:A, ">0", B:B, "<5")"#).unwrap();
482        ensure_builtins_registered();
483        let mut planner = Planner::new(PlanConfig {
484            chunk_min_cells: 1000,
485            ..Default::default()
486        })
487        .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
488        .with_range_probe(&|r: &ReferenceType| match r {
489            ReferenceType::Range {
490                start_row: None,
491                end_row: None,
492                ..
493            } => Some((50_000, 1)),
494            _ => None,
495        });
496        let plan = planner.plan(&ast);
497        assert!(matches!(
498            plan.root.strategy,
499            ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
500        ));
501    }
502
503    #[test]
504    fn deep_sub_ast_criteria_still_plans() {
505        // Deep sub-AST in criteria (e.g., TEXT + DATE math)
506        let p = plan_for("=SUMIFS(A1:A100, B1:B100, TEXT(2024+1, \"0\"))");
507        // Should produce a plan with children; exact strategy may vary
508        assert!(!p.root.children.is_empty());
509    }
510
511    #[test]
512    fn sum_mixed_scalars_and_large_range_prefers_chunked_reduce() {
513        // SUM over a large column plus scalars → prefer chunked reduce due to range cost
514        let ast = formualizer_parse::parser::parse(r#"=SUM(A:A, 1, 2, 3)"#).unwrap();
515        ensure_builtins_registered();
516        let mut planner = Planner::new(PlanConfig {
517            chunk_min_cells: 500,
518            ..Default::default()
519        })
520        .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
521        .with_range_probe(&|r: &ReferenceType| match r {
522            ReferenceType::Range {
523                start_row: None,
524                end_row: None,
525                ..
526            } => Some((25_000, 1)),
527            _ => None,
528        });
529        let plan = planner.plan(&ast);
530        assert!(matches!(
531            plan.root.strategy,
532            ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
533        ));
534    }
535
536    #[test]
537    fn nested_short_circuit_child_remains_sequential_under_parallel_parent() {
538        // Force low thresholds to encourage arg-parallel at parent, but AND child must stay Sequential
539        let ast = formualizer_parse::parser::parse("=SUM(AND(TRUE(), FALSE()), 1, 2, 3)").unwrap();
540        ensure_builtins_registered();
541        let cfg = PlanConfig {
542            enable_parallel: true,
543            arg_parallel_min_cost_ns: 0,
544            arg_parallel_min_children: 2,
545            chunk_min_cells: 1_000_000, // disable chunking here
546            chunk_target_partitions: 8,
547        };
548        let mut planner = Planner::new(cfg)
549            .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
550        let plan = planner.plan(&ast);
551        // Parent may be ArgParallel under these thresholds
552        assert!(matches!(
553            plan.root.strategy,
554            ExecStrategy::ArgParallel | ExecStrategy::Sequential
555        ));
556        // First child corresponds to AND(...) and must be Sequential due to SHORT_CIRCUIT
557        assert!(!plan.root.children.is_empty());
558        assert!(matches!(
559            plan.root.children[0].strategy,
560            ExecStrategy::Sequential
561        ));
562    }
563
564    #[test]
565    fn repeated_identical_ranges_defaults_to_sequential() {
566        // Repeated A:A references with tiny dims should not trigger chunking and stay Sequential by default thresholds
567        let ast = formualizer_parse::parser::parse(r#"=SUM(A:A, A:A, A:A)"#).unwrap();
568        let mut planner = Planner::new(PlanConfig::default())
569            .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
570            .with_range_probe(&|r: &ReferenceType| match r {
571                ReferenceType::Range {
572                    start_row: None,
573                    end_row: None,
574                    ..
575                } => Some((3, 1)),
576                _ => None,
577            });
578        let plan = planner.plan(&ast);
579        assert!(matches!(plan.root.strategy, ExecStrategy::Sequential));
580        assert_eq!(plan.root.children.len(), 3);
581    }
582}