Skip to main content

qualia_core_db/specialized_libs/statistical_computing/
indexing.rs

1use super::*;
2
3/// Data indexing engine
4pub struct DataIndexingEngine {
5    indexes: HashMap<String, DataIndex>,
6    indexing_strategy: IndexingStrategy,
7    query_optimizer: QueryOptimizer,
8}
9
10/// Data index
11#[derive(Debug, Clone)]
12pub struct DataIndex {
13    pub index_id: String,
14    pub index_type: IndexType,
15    pub indexed_columns: Vec<String>,
16    pub statistics: IndexStatistics,
17}
18
19/// Index types
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub enum IndexType {
22    BTree,
23    Hash,
24    Bitmap,
25    FullText,
26    Spatial,
27    TimeSeries,
28}
29
30/// Index statistics
31#[derive(Debug, Clone)]
32pub struct IndexStatistics {
33    pub entries: u64,
34    pub size: u64,
35    pub selectivity: f64,
36    pub usage_count: u64,
37}
38
39/// Query optimizer
40pub struct QueryOptimizer {
41    optimization_rules: Vec<OptimizationRule>,
42    cost_model: CostModel,
43    execution_plan: ExecutionPlan,
44}
45
46/// Optimization rules
47#[derive(Debug, Clone, PartialEq)]
48pub enum OptimizationRule {
49    PredicatePushdown,
50    IndexSelection,
51    JoinOrder,
52    AggregationPushdown,
53    Materialization,
54}
55
56/// Cost model
57pub struct CostModel {
58    pub cpu_cost: f64,
59    pub io_cost: f64,
60    pub memory_cost: f64,
61    pub network_cost: f64,
62}
63
64/// Execution plan
65#[derive(Debug, Clone)]
66pub struct ExecutionPlan {
67    pub plan_id: String,
68    pub operations: Vec<QueryOperation>,
69    pub estimated_cost: f64,
70    pub execution_time: u64,
71}
72
73/// Join strategy selected by the query optimizer.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum JoinType {
76    NestedLoop,
77    HashJoin,
78}
79
80/// A single logical query operation. Each variant carries the data the
81/// optimizer needs to estimate cost, reorder steps, and select join strategies.
82#[derive(Debug, Clone, PartialEq)]
83pub enum QueryOperation {
84    /// Full table scan; `estimated_rows` is the table's row count.
85    Scan {
86        table: String,
87        estimated_rows: usize,
88    },
89    /// Predicate filter; `selectivity` (0.0–1.0) is the fraction of rows that pass.
90    Filter { predicate: String, selectivity: f64 },
91    /// Join of two inputs; `left_cost`/`right_cost` are estimated row counts
92    /// of the left and right inputs. The optimizer may override `join_type`.
93    Join {
94        left_cost: f64,
95        right_cost: f64,
96        join_type: JoinType,
97    },
98    /// Aggregation; `group_by` lists the grouping columns.
99    Aggregate { group_by: Vec<String> },
100    /// Sort by the given columns.
101    Sort { columns: Vec<String> },
102    /// Limit to at most `count` rows.
103    Limit { count: usize },
104    /// Column projection.
105    Project { columns: Vec<String> },
106}
107
108/// A single step in an optimized query plan: the operation plus its estimated
109/// cost and output row count.
110#[derive(Debug, Clone)]
111pub struct QueryStep {
112    pub operation: QueryOperation,
113    pub estimated_cost: f64,
114    pub estimated_rows: usize,
115}
116
117/// An optimized query plan: ordered steps with aggregate cost/row estimates.
118#[derive(Debug, Clone)]
119pub struct QueryPlan {
120    pub operations: Vec<QueryStep>,
121    pub estimated_cost: f64,
122    pub estimated_rows: usize,
123}
124
125impl QueryOperation {
126    /// A rough data-size proxy used by the legacy `estimate_cost` /
127    /// `optimize_with_cost` path when the input row count is not known
128    /// in isolation. The `optimize()` method uses proper per-step tracking.
129    fn data_size_hint(&self) -> f64 {
130        match self {
131            QueryOperation::Scan { estimated_rows, .. } => *estimated_rows as f64,
132            QueryOperation::Filter { selectivity, .. } => 100.0 / selectivity.max(0.01),
133            QueryOperation::Join {
134                left_cost,
135                right_cost,
136                ..
137            } => left_cost * right_cost,
138            QueryOperation::Aggregate { group_by } => group_by.len().max(1) as f64 * 100.0,
139            QueryOperation::Sort { columns } => columns.len().max(1) as f64 * 100.0,
140            QueryOperation::Limit { count } => *count as f64,
141            QueryOperation::Project { columns } => columns.len().max(1) as f64 * 100.0,
142        }
143    }
144
145    /// Canonical ordering priority for stable reordering by `optimize()`.
146    /// Lower = earlier in the plan. Operations with the same priority
147    /// preserve their relative input order (stable sort).
148    fn plan_priority(&self) -> u8 {
149        match self {
150            QueryOperation::Scan { .. } => 0,
151            QueryOperation::Project { .. } => 0,
152            QueryOperation::Filter { .. } => 1,
153            QueryOperation::Join { .. } => 2,
154            QueryOperation::Aggregate { .. } => 3,
155            QueryOperation::Sort { .. } => 4,
156            QueryOperation::Limit { .. } => 5,
157        }
158    }
159}
160
161impl DataIndexingEngine {
162    pub fn new() -> Self {
163        Self {
164            indexes: HashMap::new(),
165            indexing_strategy: IndexingStrategy::BTree,
166            query_optimizer: QueryOptimizer::new(),
167        }
168    }
169
170    pub fn initialize(&mut self) -> Result<(), StatisticalError> {
171        self.query_optimizer.initialize()?;
172        Ok(())
173    }
174
175    /// Returns the configured indexing strategy.
176    pub fn indexing_strategy(&self) -> &IndexingStrategy {
177        &self.indexing_strategy
178    }
179
180    /// Reconfigure the indexing strategy.
181    pub fn set_indexing_strategy(&mut self, strategy: IndexingStrategy) {
182        self.indexing_strategy = strategy;
183    }
184
185    /// Add (or replace) a named data index.
186    pub fn add_index(&mut self, index: DataIndex) {
187        self.indexes.insert(index.index_id.clone(), index);
188    }
189
190    /// Look up a data index by id.
191    pub fn get_index(&self, index_id: &str) -> Option<&DataIndex> {
192        self.indexes.get(index_id)
193    }
194
195    /// Remove a data index by id.
196    pub fn remove_index(&mut self, index_id: &str) -> Option<DataIndex> {
197        self.indexes.remove(index_id)
198    }
199
200    /// List the ids of all registered indexes.
201    pub fn list_index_ids(&self) -> Vec<String> {
202        self.indexes.keys().cloned().collect()
203    }
204
205    /// Returns the number of registered indexes.
206    pub fn index_count(&self) -> usize {
207        self.indexes.len()
208    }
209
210    /// Returns a reference to the query optimizer.
211    pub fn query_optimizer(&self) -> &QueryOptimizer {
212        &self.query_optimizer
213    }
214
215    /// Returns a mutable reference to the query optimizer.
216    pub fn query_optimizer_mut(&mut self) -> &mut QueryOptimizer {
217        &mut self.query_optimizer
218    }
219}
220
221impl QueryOptimizer {
222    pub fn new() -> Self {
223        Self {
224            optimization_rules: vec![
225                OptimizationRule::PredicatePushdown,
226                OptimizationRule::IndexSelection,
227            ],
228            cost_model: CostModel {
229                cpu_cost: 0.0,
230                io_cost: 0.0,
231                memory_cost: 0.0,
232                network_cost: 0.0,
233            },
234            execution_plan: ExecutionPlan {
235                plan_id: "default".to_string(),
236                operations: Vec::new(),
237                estimated_cost: 0.0,
238                execution_time: 0,
239            },
240        }
241    }
242
243    pub fn initialize(&mut self) -> Result<(), StatisticalError> {
244        Ok(())
245    }
246
247    /// Returns the list of optimization rules currently registered.
248    pub fn optimization_rules(&self) -> &[OptimizationRule] {
249        &self.optimization_rules
250    }
251
252    /// Add an optimization rule if it is not already present.
253    pub fn add_optimization_rule(&mut self, rule: OptimizationRule) {
254        if !self.optimization_rules.contains(&rule) {
255            self.optimization_rules.push(rule);
256        }
257    }
258
259    /// Returns `true` when the given rule is registered.
260    pub fn has_rule(&self, rule: &OptimizationRule) -> bool {
261        self.optimization_rules.contains(rule)
262    }
263
264    /// Estimate the cost of a single query operation based on its type and the
265    /// amount of data it operates on. Costs are dimensionless weights chosen so
266    /// that cheaper operations (Filter, Project, Limit) sort before expensive
267    /// ones (Scan, Join, Aggregate, Sort).
268    pub fn estimate_cost(&self, operation: &QueryOperation) -> CostModel {
269        let n = operation.data_size_hint();
270        match operation {
271            // Full scan: heavy I/O, light CPU.
272            QueryOperation::Scan { .. } => CostModel {
273                cpu_cost: 0.1 * n,
274                io_cost: 1.0 * n,
275                memory_cost: 0.05 * n,
276                network_cost: 0.0,
277            },
278            // Filter: cheap, mostly CPU.
279            QueryOperation::Filter { .. } => CostModel {
280                cpu_cost: 0.2 * n,
281                io_cost: 0.0,
282                memory_cost: 0.02 * n,
283                network_cost: 0.0,
284            },
285            // Project: cheap column selection.
286            QueryOperation::Project { .. } => CostModel {
287                cpu_cost: 0.1 * n,
288                io_cost: 0.0,
289                memory_cost: 0.02 * n,
290                network_cost: 0.0,
291            },
292            // Aggregate: moderate CPU + memory.
293            QueryOperation::Aggregate { .. } => CostModel {
294                cpu_cost: 0.5 * n,
295                io_cost: 0.1 * n,
296                memory_cost: 0.3 * n,
297                network_cost: 0.0,
298            },
299            // Join: the most expensive — CPU, memory, and network.
300            QueryOperation::Join { .. } => CostModel {
301                cpu_cost: 1.0 * n,
302                io_cost: 0.5 * n,
303                memory_cost: 1.0 * n,
304                network_cost: 0.5 * n,
305            },
306            // Sort: CPU + memory heavy.
307            QueryOperation::Sort { .. } => CostModel {
308                cpu_cost: 0.6 * n,
309                io_cost: 0.2 * n,
310                memory_cost: 0.5 * n,
311                network_cost: 0.0,
312            },
313            // Limit: very cheap.
314            QueryOperation::Limit { .. } => CostModel {
315                cpu_cost: 0.05 * n,
316                io_cost: 0.0,
317                memory_cost: 0.01 * n,
318                network_cost: 0.0,
319            },
320        }
321    }
322
323    /// Optimize a sequence of operations by reordering them to minimize total
324    /// cost. Uses a simple greedy strategy: estimate each operation's cost and
325    /// execute cheapest-first. The resulting [`ExecutionPlan`] is stored on the
326    /// optimizer and also returned.
327    pub fn optimize_with_cost(
328        &mut self,
329        operations: &[QueryOperation],
330    ) -> Result<ExecutionPlan, StatisticalError> {
331        let mut indexed: Vec<(usize, QueryOperation)> = operations
332            .iter()
333            .cloned()
334            .map(|op| op)
335            .enumerate()
336            .collect();
337        // Greedy: sort by estimated total cost, cheapest first. The original
338        // index is retained so callers can inspect the reordering if desired.
339        indexed.sort_by(|a, b| {
340            let ca = self.estimate_cost(&a.1).total_cost();
341            let cb = self.estimate_cost(&b.1).total_cost();
342            ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
343        });
344
345        let mut ordered: Vec<QueryOperation> = indexed.into_iter().map(|(_, op)| op).collect();
346        let total: f64 = ordered
347            .iter()
348            .map(|op| self.estimate_cost(op).total_cost())
349            .sum();
350
351        // Aggregate the per-operation costs into the optimizer's cost model so
352        // the field is actually used.
353        self.cost_model = ordered.iter().map(|op| self.estimate_cost(op)).fold(
354            CostModel {
355                cpu_cost: 0.0,
356                io_cost: 0.0,
357                memory_cost: 0.0,
358                network_cost: 0.0,
359            },
360            |acc, c| CostModel {
361                cpu_cost: acc.cpu_cost + c.cpu_cost,
362                io_cost: acc.io_cost + c.io_cost,
363                memory_cost: acc.memory_cost + c.memory_cost,
364                network_cost: acc.network_cost + c.network_cost,
365            },
366        );
367
368        let plan = ExecutionPlan {
369            plan_id: format!("plan_{}", self.next_plan_id()),
370            operations: std::mem::take(&mut ordered),
371            estimated_cost: total,
372            execution_time: 0,
373        };
374        self.execution_plan = plan.clone();
375        Ok(plan)
376    }
377
378    /// Returns the most recently optimized execution plan, if any.
379    pub fn get_execution_plan(&self) -> Option<&ExecutionPlan> {
380        if self.execution_plan.operations.is_empty() {
381            None
382        } else {
383            Some(&self.execution_plan)
384        }
385    }
386
387    /// Monotonic plan id counter (kept simple — no persistent state needed).
388    fn next_plan_id(&self) -> u64 {
389        // Use the current plan's operation count as a cheap discriminator.
390        self.execution_plan.operations.len() as u64 + 1
391    }
392
393    /// Optimize a sequence of operations into a [`QueryPlan`].
394    ///
395    /// Applies three rewrite rules:
396    /// 1. **Predicate pushdown** — filters are moved ahead of joins so rows are
397    ///    reduced before the expensive join.
398    /// 2. **Join-type selection** — HashJoin is selected when both join inputs
399    ///    are ≥ 1000 rows; NestedLoop when both are < 100; otherwise the
400    ///    caller-supplied join type is retained.
401    /// 3. **Limit-last** — Limit is always the final step.
402    ///
403    /// After reordering, per-step cost and output row count are estimated
404    /// using a simple cost model that tracks the running row count through
405    /// the plan.
406    pub fn optimize(&self, operations: Vec<QueryOperation>) -> Result<QueryPlan, StatisticalError> {
407        if operations.is_empty() {
408            return Ok(QueryPlan {
409                operations: Vec::new(),
410                estimated_cost: 0.0,
411                estimated_rows: 0,
412            });
413        }
414
415        // 1. Stable reorder by canonical plan priority (scan → filter → join →
416        //    aggregate → sort → limit).  This achieves both filter-pushdown
417        //    and limit-last in a single pass.
418        let mut reordered: Vec<QueryOperation> = operations;
419        reordered.sort_by_key(|op| op.plan_priority());
420
421        // 2. Join-type selection: override join_type based on input sizes.
422        for op in reordered.iter_mut() {
423            if let QueryOperation::Join {
424                left_cost,
425                right_cost,
426                join_type,
427            } = op
428            {
429                if *left_cost >= 1000.0 && *right_cost >= 1000.0 {
430                    *join_type = JoinType::HashJoin;
431                } else if *left_cost < 100.0 && *right_cost < 100.0 {
432                    *join_type = JoinType::NestedLoop;
433                }
434            }
435        }
436
437        // 3. Build plan with per-step cost and row estimates.
438        let mut steps: Vec<QueryStep> = Vec::with_capacity(reordered.len());
439        let mut current_rows: usize = 0;
440        let mut total_cost: f64 = 0.0;
441
442        for op in reordered {
443            let (cost, output_rows) = Self::estimate_step(&op, current_rows);
444            steps.push(QueryStep {
445                operation: op,
446                estimated_cost: cost,
447                estimated_rows: output_rows,
448            });
449            current_rows = output_rows;
450            total_cost += cost;
451        }
452
453        Ok(QueryPlan {
454            operations: steps,
455            estimated_cost: total_cost,
456            estimated_rows: current_rows,
457        })
458    }
459
460    /// Per-step cost and output-row estimation. `input_rows` is the running
461    /// row count from the previous step (0 for the first step).
462    fn estimate_step(op: &QueryOperation, input_rows: usize) -> (f64, usize) {
463        match op {
464            QueryOperation::Scan { estimated_rows, .. } => {
465                let cost = *estimated_rows as f64 * 0.01;
466                (cost, *estimated_rows)
467            }
468            QueryOperation::Filter { selectivity, .. } => {
469                let n = input_rows.max(1) as f64;
470                let cost = n * selectivity * 0.005;
471                let output = (n * selectivity).round() as usize;
472                (cost, output)
473            }
474            QueryOperation::Join {
475                left_cost,
476                right_cost,
477                ..
478            } => {
479                let n = left_cost * right_cost;
480                let cost = n * 0.001;
481                let output = n.round() as usize;
482                (cost, output)
483            }
484            QueryOperation::Aggregate { .. } => {
485                let n = input_rows.max(1) as f64;
486                let cost = n * 0.01;
487                (cost, input_rows)
488            }
489            QueryOperation::Sort { .. } => {
490                let n = input_rows.max(1) as f64;
491                let cost = n * 0.01;
492                (cost, input_rows)
493            }
494            QueryOperation::Limit { count } => {
495                let cost = *count as f64 * 0.001;
496                let output = (*count).min(input_rows.max(1));
497                (cost, output)
498            }
499            QueryOperation::Project { .. } => {
500                let n = input_rows.max(1) as f64;
501                let cost = n * 0.001;
502                (cost, input_rows)
503            }
504        }
505    }
506}
507
508impl CostModel {
509    /// Sum of all cost components.
510    pub fn total_cost(&self) -> f64 {
511        self.cpu_cost + self.io_cost + self.memory_cost + self.network_cost
512    }
513
514    /// Returns `true` when `self` is cheaper than `other`.
515    pub fn is_better_than(&self, other: &CostModel) -> bool {
516        self.total_cost() < other.total_cost()
517    }
518}