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