Skip to main content

polydat_core/iteration/comprehension/ir/
bounds.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! §9.3 resource-bound checker.
5//!
6//! Given an IR `Program`, compute the closed-form peak-memory
7//! bound symbolically. Per spec §9.3:
8//!
9//! ```text
10//! memory(C) ≤
11//!     O(depth(C))                                 // operator stack
12//!   + Σ (per-operator steady-state, see §6.2)     // O(1) for streaming ops
13//!   + Σ (zip(Cycle) shorter-child cardinality)    // barrier 1
14//!   + Σ (ORDER_MATERIALIZE working-set size)      // barrier 2
15//! ```
16//!
17//! This checker emits a [`ResourceBound`] structure rather than
18//! a single number — consumers (TUI, planner diagnostics, the
19//! optimizer's bounds-improvement test) get separated terms.
20
21use serde::{Deserialize, Serialize};
22
23use super::op::Op;
24use super::program::Program;
25use crate::iteration::comprehension::strategy::ZipMode;
26
27/// Closed-form peak-memory estimate for an IR `Program`.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ResourceBound {
30    /// Operator-stack depth term (O(depth(C))).
31    pub stack_depth: usize,
32
33    /// Sum of streaming-operator steady-state (O(1) per op
34    /// above its arity). Reported as an opcode count so the
35    /// caller can multiply by their per-op cost estimate.
36    pub streaming_op_count: usize,
37
38    /// Per-barrier working-set bound. Each entry is one
39    /// barrier in the program; the total memory cost is the
40    /// sum.
41    pub barriers: Vec<Bound>,
42}
43
44/// A single barrier's working-set bound.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Bound {
47    /// Which IR position the barrier sits at.
48    pub op_index: usize,
49    /// Symbolic description of the barrier (e.g.,
50    /// `"ORDER_MATERIALIZE(Halton, indexed=true, n=50)"`).
51    pub description: String,
52    /// Closed-form working-set size in tuples. `None` when
53    /// the size depends on a runtime cardinality (the
54    /// optimizer's input is assumed to be V6-rejected if any
55    /// barrier has unbounded input, so `None` should not occur
56    /// for well-formed programs).
57    pub working_set_size: Option<u64>,
58}
59
60impl ResourceBound {
61    /// Sum the per-barrier working sets. Returns `None` when
62    /// any barrier has an unbounded working set.
63    pub fn total_barrier_working_set(&self) -> Option<u64> {
64        let mut total: u64 = 0;
65        for b in &self.barriers {
66            let w = b.working_set_size?;
67            total = total.saturating_add(w);
68        }
69        Some(total)
70    }
71}
72
73/// Compute the resource bound for a program.
74pub fn check_bounds(program: &Program) -> ResourceBound {
75    let mut bounds = ResourceBound {
76        stack_depth: program.stack_depth(),
77        streaming_op_count: 0,
78        barriers: Vec::new(),
79    };
80
81    for (i, op) in program.ops().iter().enumerate() {
82        if op.is_barrier() {
83            bounds.barriers.push(barrier_for(i, op));
84        } else if !matches!(op, Op::Dispense) {
85            bounds.streaming_op_count += 1;
86        }
87    }
88
89    bounds
90}
91
92fn barrier_for(op_index: usize, op: &Op) -> Bound {
93    match op {
94        Op::OrderMaterialize {
95            strategy,
96            truncation,
97            indexed,
98            ..
99        } => {
100            // R2 push-down: working set = truncation count.
101            // Naïve form: unknown without input cardinality —
102            // reported as the truncation cap (conservative
103            // lower-bound; actual is input cardinality).
104            let ws = *truncation;
105            let description = format!(
106                "ORDER_MATERIALIZE({}, indexed={indexed}, truncation={truncation:?})",
107                strategy.as_str()
108            );
109            Bound {
110                op_index,
111                description,
112                working_set_size: ws,
113            }
114        }
115        Op::Zip {
116            n,
117            mode: ZipMode::Cycle,
118        } => {
119            // zip(Cycle) shorter-child barrier: working set =
120            // sum of non-longest child cardinalities. Without
121            // child cardinalities at this layer, report
122            // None — the metadata propagator carries the actual
123            // computed working set per spec §10.7.2.
124            Bound {
125                op_index,
126                description: format!("ZIP(Cycle, {n})"),
127                working_set_size: None,
128            }
129        }
130        _ => unreachable!("non-barrier op classified as barrier"),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::iteration::comprehension::source::Source;
138    use crate::iteration::comprehension::strategy::StrategyName;
139
140    fn push_clause(name: &str) -> Op {
141        Op::PushClause {
142            name: name.into(),
143            source: Source::Literal { values: vec![] },
144        }
145    }
146
147    #[test]
148    fn empty_program_has_zero_bounds() {
149        let p = Program::new(vec![]);
150        let b = check_bounds(&p);
151        assert_eq!(b.stack_depth, 0);
152        assert_eq!(b.streaming_op_count, 0);
153        assert!(b.barriers.is_empty());
154    }
155
156    #[test]
157    fn streaming_program_has_no_barriers() {
158        let p = Program::new(vec![
159            push_clause("a"),
160            push_clause("b"),
161            Op::Cartesian { n: 2 },
162            Op::Filter {
163                predicate: "true".into(),
164            },
165            Op::Dispense,
166        ]);
167        let b = check_bounds(&p);
168        assert_eq!(b.stack_depth, 2);
169        assert!(b.barriers.is_empty());
170        assert_eq!(b.streaming_op_count, 4); // push, push, cartesian, filter (dispense excluded)
171    }
172
173    #[test]
174    fn order_materialize_reports_barrier() {
175        let p = Program::new(vec![
176            push_clause("a"),
177            push_clause("b"),
178            Op::Cartesian { n: 2 },
179            Op::OrderMaterialize {
180                strategy: StrategyName::Halton,
181                truncation: Some(50),
182                indexed: true,
183                input_index_fn: None,
184            },
185            Op::Dispense,
186        ]);
187        let b = check_bounds(&p);
188        assert_eq!(b.barriers.len(), 1);
189        assert_eq!(b.barriers[0].working_set_size, Some(50));
190        assert_eq!(b.total_barrier_working_set(), Some(50));
191    }
192
193    #[test]
194    fn zip_cycle_reports_barrier_with_unknown_size_at_ir_layer() {
195        let p = Program::new(vec![
196            push_clause("a"),
197            push_clause("b"),
198            Op::Zip {
199                n: 2,
200                mode: ZipMode::Cycle,
201            },
202            Op::Dispense,
203        ]);
204        let b = check_bounds(&p);
205        assert_eq!(b.barriers.len(), 1);
206        // IR layer doesn't know child cardinalities.
207        assert!(b.barriers[0].working_set_size.is_none());
208        assert!(b.total_barrier_working_set().is_none());
209    }
210
211    #[test]
212    fn multiple_barriers_sum() {
213        let p = Program::new(vec![
214            push_clause("a"),
215            Op::OrderMaterialize {
216                strategy: StrategyName::Halton,
217                truncation: Some(10),
218                indexed: true,
219                input_index_fn: None,
220            },
221            Op::OrderMaterialize {
222                strategy: StrategyName::Shuffle,
223                truncation: Some(20),
224                indexed: true,
225                input_index_fn: None,
226            },
227            Op::Dispense,
228        ]);
229        let b = check_bounds(&p);
230        assert_eq!(b.barriers.len(), 2);
231        assert_eq!(b.total_barrier_working_set(), Some(30));
232    }
233}