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