Skip to main content

dataprof_core/
stop_condition.rs

1//! Composable stop conditions for early termination of stream-based profiling.
2//!
3//! A [`StopCondition`] describes *when* to stop; a [`StopEvaluator`] is the
4//! mutable runtime checker that tracks counters and evaluates the condition
5//! after each chunk.
6
7use crate::execution::TruncationReason;
8
9/// A composable condition that can trigger early termination of profiling.
10///
11/// Conditions are checked per-chunk (not per-row) for performance.
12/// The actual row count at termination may slightly exceed the limit.
13#[derive(Debug, Clone, Default)]
14pub enum StopCondition {
15    /// Stop after processing this many rows.
16    MaxRows(u64),
17    /// Stop after consuming this many bytes from the source.
18    MaxBytes(u64),
19    /// Stop when column types have not changed for approximately N rows
20    /// (accumulated across chunks).
21    SchemaStable {
22        /// Approximate number of rows with no type changes before stopping.
23        /// Rows are accumulated per-chunk, so the actual count depends on
24        /// chunk granularity.
25        consecutive_stable_rows: u64,
26    },
27    /// Stop when `rows_processed / estimated_total >= threshold`.
28    ///
29    /// Only meaningful when an estimated total row count is available.
30    /// When no estimate exists, this condition is inert.
31    ConfidenceThreshold(f64),
32    /// Stop when memory usage exceeds this fraction of the configured limit.
33    ///
34    /// Value in `0.0..=1.0` (e.g., `0.9` = 90% of memory limit).
35    MemoryPressure(f64),
36    /// Stop when **any** sub-condition triggers.
37    Any(Vec<StopCondition>),
38    /// Stop when **all** sub-conditions have triggered.
39    All(Vec<StopCondition>),
40    /// Never stop early — process the entire stream (default).
41    #[default]
42    Never,
43}
44
45impl StopCondition {
46    /// Preset for schema-only profiling: stop after 10K rows or when schema
47    /// stabilizes (no type changes for 1,000 consecutive rows).
48    pub fn schema_inference() -> Self {
49        StopCondition::Any(vec![
50            StopCondition::MaxRows(10_000),
51            StopCondition::SchemaStable {
52                consecutive_stable_rows: 1_000,
53            },
54        ])
55    }
56
57    /// Preset for quality sampling: stop after 50K rows, 50 MB, or 95% confidence.
58    pub fn quality_sample() -> Self {
59        StopCondition::Any(vec![
60            StopCondition::MaxRows(50_000),
61            StopCondition::MaxBytes(50 * 1024 * 1024),
62            StopCondition::ConfidenceThreshold(0.95),
63        ])
64    }
65
66    /// The row count at which this condition can first trigger on rows alone,
67    /// if any.
68    ///
69    /// Mirrors [`evaluate`]: `Any` fires as soon as one child fires, so it takes
70    /// the *minimum* of the children that a row count can trigger. `All` fires
71    /// only once every child has fired, so it takes the *maximum*, and yields
72    /// `None` if any child cannot be triggered by rows at all (a byte cap,
73    /// `Never`, …) — such a condition can never be satisfied by rows alone.
74    ///
75    /// Used two ways: parsers that can only enforce a row cap consult it after
76    /// checking [`is_row_limit_only`](Self::is_row_limit_only), and the
77    /// incremental engine uses it as a per-row guardrail alongside the real
78    /// evaluator.
79    pub fn max_rows(&self) -> Option<u64> {
80        match self {
81            StopCondition::MaxRows(n) => Some(*n),
82            // Earliest child cap wins: reaching it fires the whole `Any`.
83            StopCondition::Any(conditions) => {
84                conditions.iter().filter_map(StopCondition::max_rows).min()
85            }
86            // Every child must fire, so rows alone suffice only if every child is
87            // row-triggerable; then the last one to fire sets the bound.
88            StopCondition::All(conditions) => {
89                if conditions.is_empty() {
90                    return None; // `evaluate` never fires on an empty `All`
91                }
92                conditions
93                    .iter()
94                    .map(StopCondition::max_rows)
95                    .try_fold(0u64, |acc, cap| Some(acc.max(cap?)))
96            }
97            _ => None,
98        }
99    }
100
101    /// Whether this condition is expressible purely as a row limit.
102    ///
103    /// `Never` never fires; a bare `MaxRows` is a row cap; a composite qualifies
104    /// when every leaf is one of those. Anything else (byte caps, schema
105    /// stability, confidence) needs a real evaluator, so a parser that can only
106    /// cap rows must not silently accept it.
107    pub fn is_row_limit_only(&self) -> bool {
108        match self {
109            StopCondition::Never | StopCondition::MaxRows(_) => true,
110            StopCondition::Any(conditions) | StopCondition::All(conditions) => {
111                conditions.iter().all(StopCondition::is_row_limit_only)
112            }
113            _ => false,
114        }
115    }
116}
117
118/// Runtime evaluator that checks a [`StopCondition`] against accumulated counters.
119///
120/// Create one before the processing loop and call [`update`](Self::update)
121/// after each chunk. When it returns `true`, profiling should stop.
122pub struct StopEvaluator {
123    condition: StopCondition,
124    rows_processed: u64,
125    bytes_consumed: u64,
126    estimated_total_rows: Option<u64>,
127    triggered_reason: Option<TruncationReason>,
128}
129
130impl StopEvaluator {
131    pub fn new(condition: StopCondition) -> Self {
132        let condition = Self::clamp_thresholds(condition);
133        Self {
134            condition,
135            rows_processed: 0,
136            bytes_consumed: 0,
137            estimated_total_rows: None,
138            triggered_reason: None,
139        }
140    }
141
142    /// Clamp `ConfidenceThreshold` and `MemoryPressure` values to `0.0..=1.0`,
143    /// recursing into `Any`/`All` composites.
144    fn clamp_thresholds(condition: StopCondition) -> StopCondition {
145        match condition {
146            StopCondition::ConfidenceThreshold(t) => {
147                StopCondition::ConfidenceThreshold(t.clamp(0.0, 1.0))
148            }
149            StopCondition::MemoryPressure(t) => StopCondition::MemoryPressure(t.clamp(0.0, 1.0)),
150            StopCondition::Any(cs) => {
151                StopCondition::Any(cs.into_iter().map(Self::clamp_thresholds).collect())
152            }
153            StopCondition::All(cs) => {
154                StopCondition::All(cs.into_iter().map(Self::clamp_thresholds).collect())
155            }
156            other => other,
157        }
158    }
159
160    /// Provide an estimated total row count (enables `ConfidenceThreshold`).
161    pub fn with_estimated_total(mut self, rows: u64) -> Self {
162        self.estimated_total_rows = Some(rows);
163        self
164    }
165
166    /// Update counters and evaluate the stop condition.
167    ///
168    /// Returns `true` when profiling should stop.
169    ///
170    /// - `chunk_rows`: number of rows in the chunk just processed
171    /// - `chunk_bytes`: number of bytes consumed by this chunk
172    /// - `memory_fraction`: current memory usage as a fraction of the limit (`0.0..1.0`)
173    pub fn update(&mut self, chunk_rows: u64, chunk_bytes: u64, memory_fraction: f64) -> bool {
174        self.rows_processed += chunk_rows;
175        self.bytes_consumed += chunk_bytes;
176
177        if self.triggered_reason.is_some() {
178            return true;
179        }
180
181        let reason = evaluate(
182            &self.condition,
183            self.rows_processed,
184            self.bytes_consumed,
185            memory_fraction,
186            self.estimated_total_rows,
187        );
188
189        if reason.is_some() {
190            self.triggered_reason = reason;
191            true
192        } else {
193            false
194        }
195    }
196
197    /// Returns `true` if a stop condition has already been triggered.
198    pub fn should_stop(&self) -> bool {
199        self.triggered_reason.is_some()
200    }
201
202    /// The reason profiling stopped, mapped to [`TruncationReason`].
203    pub fn truncation_reason(&self) -> Option<TruncationReason> {
204        self.triggered_reason.clone()
205    }
206
207    /// Total rows processed so far.
208    pub fn rows_processed(&self) -> u64 {
209        self.rows_processed
210    }
211
212    /// Total bytes consumed so far.
213    pub fn bytes_consumed(&self) -> u64 {
214        self.bytes_consumed
215    }
216}
217
218/// Recursively evaluate a condition against current counters.
219/// Returns `Some(reason)` if the condition is met.
220fn evaluate(
221    condition: &StopCondition,
222    rows: u64,
223    bytes: u64,
224    memory_fraction: f64,
225    estimated_total: Option<u64>,
226) -> Option<TruncationReason> {
227    match condition {
228        StopCondition::MaxRows(limit) => {
229            if rows >= *limit {
230                Some(TruncationReason::MaxRows(*limit))
231            } else {
232                None
233            }
234        }
235        StopCondition::MaxBytes(limit) => {
236            if bytes >= *limit {
237                Some(TruncationReason::MaxBytes(*limit))
238            } else {
239                None
240            }
241        }
242        StopCondition::SchemaStable { .. } => {
243            // SchemaStable requires column type tracking which is handled
244            // at the engine level via `SchemaStabilityTracker`. The evaluator
245            // alone cannot detect schema changes.
246            // See: IncrementalProfiler and AsyncStreamingProfiler integration.
247            None
248        }
249        StopCondition::ConfidenceThreshold(threshold) => {
250            if let Some(total) = estimated_total
251                && total > 0
252            {
253                let confidence = rows as f64 / total as f64;
254                if confidence >= *threshold {
255                    return Some(TruncationReason::StopCondition(format!(
256                        "confidence_threshold({})",
257                        threshold
258                    )));
259                }
260            }
261            None
262        }
263        StopCondition::MemoryPressure(threshold) => {
264            if memory_fraction >= *threshold {
265                Some(TruncationReason::MemoryPressure)
266            } else {
267                None
268            }
269        }
270        StopCondition::Any(conditions) => {
271            for c in conditions {
272                if let Some(reason) = evaluate(c, rows, bytes, memory_fraction, estimated_total) {
273                    return Some(reason);
274                }
275            }
276            None
277        }
278        StopCondition::All(conditions) => {
279            if conditions.is_empty() {
280                return None;
281            }
282            // All must have triggered — collect reasons, return first
283            let mut first_reason = None;
284            for c in conditions {
285                let reason = evaluate(c, rows, bytes, memory_fraction, estimated_total)?;
286                if first_reason.is_none() {
287                    first_reason = Some(reason);
288                }
289            }
290            first_reason
291        }
292        StopCondition::Never => None,
293    }
294}
295
296/// Extracts the `consecutive_stable_rows` threshold from a `StopCondition`,
297/// searching through `Any`/`All` composites. Returns `None` if no
298/// `SchemaStable` variant is present.
299pub fn schema_stable_threshold(condition: &StopCondition) -> Option<u64> {
300    match condition {
301        StopCondition::SchemaStable {
302            consecutive_stable_rows,
303        } => Some(*consecutive_stable_rows),
304        StopCondition::Any(conditions) | StopCondition::All(conditions) => {
305            conditions.iter().find_map(schema_stable_threshold)
306        }
307        _ => None,
308    }
309}
310
311/// Tracks consecutive rows where the schema (column types) has not changed.
312/// Used by engines to implement `SchemaStable` stop conditions.
313///
314/// Accepts a fingerprint (hash) of the column types and accumulates rows
315/// across chunks. When the accumulated stable-row count reaches the
316/// threshold, the tracker signals that profiling may stop.
317pub struct SchemaStabilityTracker {
318    threshold: u64,
319    consecutive_stable: u64,
320    last_fingerprint: Option<u64>,
321}
322
323impl SchemaStabilityTracker {
324    /// Create a tracker for the given threshold. Returns `None` if no
325    /// `SchemaStable` condition is present.
326    pub fn from_condition(condition: &StopCondition) -> Option<Self> {
327        schema_stable_threshold(condition).map(|threshold| Self {
328            threshold,
329            consecutive_stable: 0,
330            last_fingerprint: None,
331        })
332    }
333
334    /// Update with the current schema fingerprint and the number of rows in
335    /// the chunk. Returns `true` when the accumulated stable-row count reaches
336    /// the threshold.
337    pub fn update(&mut self, fingerprint: u64, chunk_rows: u64) -> bool {
338        match self.last_fingerprint {
339            Some(prev) if prev == fingerprint => {
340                self.consecutive_stable += chunk_rows;
341            }
342            _ => {
343                self.consecutive_stable = chunk_rows;
344                self.last_fingerprint = Some(fingerprint);
345            }
346        }
347        self.consecutive_stable >= self.threshold
348    }
349
350    /// The truncation reason when schema stability is reached.
351    pub fn truncation_reason(&self) -> TruncationReason {
352        TruncationReason::StopCondition(format!("schema_stable({})", self.threshold))
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_max_rows_leaf_and_never() {
362        assert_eq!(StopCondition::MaxRows(10).max_rows(), Some(10));
363        assert_eq!(StopCondition::Never.max_rows(), None);
364        assert_eq!(StopCondition::MaxBytes(10).max_rows(), None);
365    }
366
367    #[test]
368    fn test_max_rows_any_takes_the_earliest_cap() {
369        // `Any` fires as soon as one child fires.
370        let condition =
371            StopCondition::Any(vec![StopCondition::MaxRows(20), StopCondition::MaxRows(10)]);
372        assert_eq!(condition.max_rows(), Some(10));
373
374        // Order must not matter.
375        let flipped =
376            StopCondition::Any(vec![StopCondition::MaxRows(10), StopCondition::MaxRows(20)]);
377        assert_eq!(flipped.max_rows(), Some(10));
378
379        // `Never` can never fire, so it does not constrain an `Any`.
380        let with_never = StopCondition::Any(vec![StopCondition::Never, StopCondition::MaxRows(10)]);
381        assert_eq!(with_never.max_rows(), Some(10));
382
383        // A row cap still fires the `Any` even beside a condition rows cannot trigger.
384        let mixed = StopCondition::Any(vec![
385            StopCondition::MaxBytes(1024),
386            StopCondition::MaxRows(10),
387        ]);
388        assert_eq!(mixed.max_rows(), Some(10));
389    }
390
391    #[test]
392    fn test_max_rows_all_needs_every_child_row_triggerable() {
393        // `All` fires only once every child has fired: the last cap bounds it.
394        let condition =
395            StopCondition::All(vec![StopCondition::MaxRows(10), StopCondition::MaxRows(20)]);
396        assert_eq!(condition.max_rows(), Some(20));
397
398        // A child rows cannot trigger means rows alone can never satisfy the `All`.
399        let with_bytes = StopCondition::All(vec![
400            StopCondition::MaxRows(10),
401            StopCondition::MaxBytes(1024),
402        ]);
403        assert_eq!(with_bytes.max_rows(), None);
404
405        let with_never = StopCondition::All(vec![StopCondition::MaxRows(10), StopCondition::Never]);
406        assert_eq!(with_never.max_rows(), None);
407
408        // `evaluate` never fires on an empty `All`.
409        assert_eq!(StopCondition::All(vec![]).max_rows(), None);
410        assert_eq!(StopCondition::Any(vec![]).max_rows(), None);
411    }
412
413    #[test]
414    fn test_is_row_limit_only_recurses() {
415        assert!(StopCondition::Never.is_row_limit_only());
416        assert!(StopCondition::MaxRows(10).is_row_limit_only());
417        assert!(!StopCondition::MaxBytes(10).is_row_limit_only());
418
419        // A composite of pure row caps stays expressible as a row cap.
420        assert!(
421            StopCondition::Any(vec![StopCondition::MaxRows(10), StopCondition::MaxRows(20)])
422                .is_row_limit_only()
423        );
424        assert!(
425            StopCondition::All(vec![StopCondition::Never, StopCondition::MaxRows(20)])
426                .is_row_limit_only()
427        );
428
429        // One non-row leaf disqualifies the whole tree.
430        assert!(
431            !StopCondition::Any(vec![
432                StopCondition::MaxRows(10),
433                StopCondition::MaxBytes(1024)
434            ])
435            .is_row_limit_only()
436        );
437
438        // The `schema_inference` preset mixes MaxRows with SchemaStable.
439        assert!(!StopCondition::schema_inference().is_row_limit_only());
440        // …but a row cap alone still fires its `Any`, so the guardrail holds.
441        assert_eq!(StopCondition::schema_inference().max_rows(), Some(10_000));
442    }
443
444    #[test]
445    fn test_max_rows_stops() {
446        let mut eval = StopEvaluator::new(StopCondition::MaxRows(100));
447        assert!(!eval.update(50, 0, 0.0));
448        assert!(!eval.should_stop());
449        assert!(eval.update(50, 0, 0.0));
450        assert!(eval.should_stop());
451        assert_eq!(
452            eval.truncation_reason(),
453            Some(TruncationReason::MaxRows(100))
454        );
455    }
456
457    #[test]
458    fn test_max_bytes_stops() {
459        let mut eval = StopEvaluator::new(StopCondition::MaxBytes(1000));
460        assert!(!eval.update(10, 500, 0.0));
461        assert!(eval.update(10, 600, 0.0));
462        assert_eq!(
463            eval.truncation_reason(),
464            Some(TruncationReason::MaxBytes(1000))
465        );
466    }
467
468    #[test]
469    fn test_memory_pressure_stops() {
470        let mut eval = StopEvaluator::new(StopCondition::MemoryPressure(0.9));
471        assert!(!eval.update(100, 0, 0.5));
472        assert!(eval.update(100, 0, 0.95));
473        assert_eq!(
474            eval.truncation_reason(),
475            Some(TruncationReason::MemoryPressure)
476        );
477    }
478
479    #[test]
480    fn test_confidence_threshold_without_estimate() {
481        // Without estimated_total, ConfidenceThreshold is inert
482        let mut eval = StopEvaluator::new(StopCondition::ConfidenceThreshold(0.95));
483        assert!(!eval.update(100_000, 0, 0.0));
484        assert!(!eval.should_stop());
485    }
486
487    #[test]
488    fn test_confidence_threshold_with_estimate() {
489        let mut eval =
490            StopEvaluator::new(StopCondition::ConfidenceThreshold(0.95)).with_estimated_total(1000);
491        assert!(!eval.update(900, 0, 0.0));
492        assert!(eval.update(100, 0, 0.0)); // 1000/1000 = 1.0 >= 0.95
493    }
494
495    #[test]
496    fn test_never_never_stops() {
497        let mut eval = StopEvaluator::new(StopCondition::Never);
498        for _ in 0..100 {
499            assert!(!eval.update(1_000_000, 1_000_000, 1.0));
500        }
501        assert!(!eval.should_stop());
502    }
503
504    #[test]
505    fn test_any_stops_on_first() {
506        let condition = StopCondition::Any(vec![
507            StopCondition::MaxRows(100),
508            StopCondition::MaxBytes(1_000_000),
509        ]);
510        let mut eval = StopEvaluator::new(condition);
511        // MaxRows triggers first (100 rows, only 500 bytes)
512        assert!(eval.update(100, 500, 0.0));
513        assert_eq!(
514            eval.truncation_reason(),
515            Some(TruncationReason::MaxRows(100))
516        );
517    }
518
519    #[test]
520    fn test_all_requires_all() {
521        let condition = StopCondition::All(vec![
522            StopCondition::MaxRows(100),
523            StopCondition::MaxBytes(1000),
524        ]);
525        let mut eval = StopEvaluator::new(condition);
526        // Only rows met, bytes not yet
527        assert!(!eval.update(100, 500, 0.0));
528        // Now both met
529        assert!(eval.update(0, 600, 0.0));
530        assert_eq!(
531            eval.truncation_reason(),
532            Some(TruncationReason::MaxRows(100))
533        );
534    }
535
536    #[test]
537    fn test_all_empty_never_triggers() {
538        let mut eval = StopEvaluator::new(StopCondition::All(vec![]));
539        assert!(!eval.update(100, 100, 1.0));
540    }
541
542    #[test]
543    fn test_convenience_schema_inference() {
544        let condition = StopCondition::schema_inference();
545        match &condition {
546            StopCondition::Any(conditions) => {
547                assert_eq!(conditions.len(), 2);
548                assert!(matches!(conditions[0], StopCondition::MaxRows(10_000)));
549                assert!(matches!(
550                    conditions[1],
551                    StopCondition::SchemaStable {
552                        consecutive_stable_rows: 1_000
553                    }
554                ));
555            }
556            _ => panic!("Expected Any variant"),
557        }
558    }
559
560    #[test]
561    fn test_convenience_quality_sample() {
562        let condition = StopCondition::quality_sample();
563        match &condition {
564            StopCondition::Any(conditions) => {
565                assert_eq!(conditions.len(), 3);
566                assert!(matches!(conditions[0], StopCondition::MaxRows(50_000)));
567                assert!(matches!(conditions[1], StopCondition::MaxBytes(52_428_800)));
568            }
569            _ => panic!("Expected Any variant"),
570        }
571    }
572
573    #[test]
574    fn test_once_triggered_stays_triggered() {
575        let mut eval = StopEvaluator::new(StopCondition::MaxRows(10));
576        assert!(eval.update(10, 0, 0.0));
577        // Calling update again still returns true
578        assert!(eval.update(5, 0, 0.0));
579        assert!(eval.should_stop());
580    }
581
582    #[test]
583    fn test_schema_stability_tracker() {
584        let condition = StopCondition::SchemaStable {
585            consecutive_stable_rows: 3,
586        };
587        let mut tracker = SchemaStabilityTracker::from_condition(&condition).unwrap();
588
589        let fp: u64 = 0xABCD;
590        assert!(!tracker.update(fp, 1)); // consecutive = 1
591        assert!(!tracker.update(fp, 1)); // consecutive = 2
592        assert!(tracker.update(fp, 1)); // consecutive = 3 -> triggers
593    }
594
595    #[test]
596    fn test_schema_stability_tracker_resets_on_change() {
597        let condition = StopCondition::SchemaStable {
598            consecutive_stable_rows: 3,
599        };
600        let mut tracker = SchemaStabilityTracker::from_condition(&condition).unwrap();
601
602        let fp1: u64 = 0x1111;
603        let fp2: u64 = 0x2222;
604
605        assert!(!tracker.update(fp1, 1)); // consecutive = 1
606        assert!(!tracker.update(fp1, 1)); // consecutive = 2
607        // Schema changes - counter resets
608        assert!(!tracker.update(fp2, 1)); // consecutive = 1
609        assert!(!tracker.update(fp2, 1)); // consecutive = 2
610        assert!(tracker.update(fp2, 1)); // consecutive = 3 -> triggers
611    }
612
613    #[test]
614    fn test_schema_stable_threshold_extraction() {
615        assert_eq!(schema_stable_threshold(&StopCondition::Never), None);
616        assert_eq!(
617            schema_stable_threshold(&StopCondition::SchemaStable {
618                consecutive_stable_rows: 500
619            }),
620            Some(500)
621        );
622        // Nested in Any
623        let nested = StopCondition::Any(vec![
624            StopCondition::MaxRows(100),
625            StopCondition::SchemaStable {
626                consecutive_stable_rows: 200,
627            },
628        ]);
629        assert_eq!(schema_stable_threshold(&nested), Some(200));
630    }
631
632    #[test]
633    fn test_rows_and_bytes_accessors() {
634        let mut eval = StopEvaluator::new(StopCondition::Never);
635        eval.update(100, 500, 0.0);
636        eval.update(200, 1000, 0.0);
637        assert_eq!(eval.rows_processed(), 300);
638        assert_eq!(eval.bytes_consumed(), 1500);
639    }
640}