Skip to main content

ries_rs/
pool.rs

1//! Bounded priority pool for streaming match collection
2//!
3//! Implements a bounded pool that keeps the best matches across
4//! multiple dimensions (error, complexity) with deduplication.
5//!
6//! # Key Design
7//!
8//! Keys use `Expression` directly rather than `String` for zero-allocation
9//! deduplication. Since `Expression` uses `SmallVec<[Symbol; 21]>`, expressions
10//! within the length limit stay inline on the stack, avoiding heap allocation
11//! during hashing and comparison.
12
13use crate::expr::Expression;
14use crate::search::Match;
15use crate::thresholds::{
16    ACCEPT_ERROR_RELAX_CAPACITY_FRACTION, ACCEPT_ERROR_TIGHTEN_FACTOR, BEST_ERROR_TIGHTEN_FACTOR,
17    EXACT_MATCH_TOLERANCE, NEWTON_TOLERANCE, STRICT_GATE_CAPACITY_FRACTION, STRICT_GATE_FACTOR,
18};
19use std::cmp::Ordering;
20use std::collections::{BinaryHeap, HashSet};
21
22/// Match ranking mode for pool eviction and final ordering.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub enum RankingMode {
25    /// Sort by exactness -> error -> complexity (current default behavior)
26    #[default]
27    Complexity,
28    /// Sort by exactness -> error -> legacy signed parity score -> complexity
29    Parity,
30}
31
32/// Keys for full equation deduplication (LHS + RHS pair)
33///
34/// Uses a pair of expressions directly for zero-allocation hashing.
35/// The tuple (lhs, rhs) uniquely identifies an equation.
36#[derive(Clone, PartialEq, Eq, Hash)]
37pub struct EqnKey {
38    /// LHS expression (contains x)
39    lhs: Expression,
40    /// RHS expression (constants only)
41    rhs: Expression,
42}
43
44impl EqnKey {
45    /// Create a key from a match
46    #[inline]
47    pub fn from_match(m: &Match) -> Self {
48        Self {
49            lhs: m.lhs.expr.clone(),
50            rhs: m.rhs.expr.clone(),
51        }
52    }
53}
54
55/// Keys for LHS-only deduplication
56///
57/// Used by report.rs to prevent showing too many variants of the same LHS.
58#[derive(Clone, PartialEq, Eq, Hash)]
59pub struct LhsKey {
60    /// LHS expression
61    lhs: Expression,
62}
63
64impl LhsKey {
65    /// Create a key from a match
66    #[inline]
67    pub fn from_match(m: &Match) -> Self {
68        Self {
69            lhs: m.lhs.expr.clone(),
70        }
71    }
72}
73
74/// Signature for operator/constant pattern (used for "interesting" dedupe)
75///
76/// Uses a boxed slice for efficient storage and hashing.
77/// Signatures are created during pool insertion (not the hot path).
78#[derive(Clone, PartialEq, Eq, Hash)]
79pub struct SignatureKey {
80    /// Operator pattern signature as bytes
81    key: Box<[u8]>,
82}
83
84impl SignatureKey {
85    pub fn from_match(m: &Match) -> Self {
86        // Build a signature from operator types and constants used
87        let expected_len = m.lhs.expr.len() + m.rhs.expr.len() + 1;
88        let mut ops = Vec::with_capacity(expected_len);
89
90        for sym in m.lhs.expr.symbols() {
91            ops.push(*sym as u8);
92        }
93        ops.push(b'=');
94        for sym in m.rhs.expr.symbols() {
95            ops.push(*sym as u8);
96        }
97
98        Self {
99            key: ops.into_boxed_slice(),
100        }
101    }
102}
103
104/// Compute legacy (original RIES style) signed parity score for an expression.
105pub fn legacy_parity_score_expr(expr: &Expression) -> i32 {
106    expr.symbols().iter().fold(0_i32, |acc, sym| {
107        acc.saturating_add(sym.legacy_parity_weight())
108    })
109}
110
111/// Compute legacy (original RIES style) signed parity score for a match.
112pub fn legacy_parity_score_match(m: &Match) -> i32 {
113    legacy_parity_score_expr(&m.lhs.expr).saturating_add(legacy_parity_score_expr(&m.rhs.expr))
114}
115
116#[inline]
117fn compare_expr(a: &Expression, b: &Expression) -> Ordering {
118    a.symbols()
119        .iter()
120        .map(|s| *s as u8)
121        .cmp(b.symbols().iter().map(|s| *s as u8))
122}
123
124/// Compare two matches according to the selected ranking mode.
125pub fn compare_matches(a: &Match, b: &Match, ranking_mode: RankingMode) -> Ordering {
126    let a_exactness = if a.error.abs() < EXACT_MATCH_TOLERANCE {
127        0_u8
128    } else {
129        1_u8
130    };
131    let b_exactness = if b.error.abs() < EXACT_MATCH_TOLERANCE {
132        0_u8
133    } else {
134        1_u8
135    };
136
137    // Matches within exact tolerance are treated as equally exact: sub-tolerance
138    // residual differences are floating-point noise, not a meaningful quality
139    // signal. Collapsing them to zero lets complexity (simplicity) decide among
140    // exact matches, which both matches the original RIES intent and keeps the
141    // bounded pool stable instead of churning on residual noise.
142    let a_eff_err = if a_exactness == 0 { 0.0 } else { a.error.abs() };
143    let b_eff_err = if b_exactness == 0 { 0.0 } else { b.error.abs() };
144
145    let mut ord = a_exactness
146        .cmp(&b_exactness)
147        .then_with(|| a_eff_err.partial_cmp(&b_eff_err).unwrap_or(Ordering::Equal));
148
149    if ord != Ordering::Equal {
150        return ord;
151    }
152
153    ord = match ranking_mode {
154        RankingMode::Complexity => a.complexity.cmp(&b.complexity),
155        RankingMode::Parity => legacy_parity_score_match(a)
156            .cmp(&legacy_parity_score_match(b))
157            .then_with(|| a.complexity.cmp(&b.complexity)),
158    };
159
160    if ord != Ordering::Equal {
161        return ord;
162    }
163
164    compare_expr(&a.lhs.expr, &b.lhs.expr).then_with(|| compare_expr(&a.rhs.expr, &b.rhs.expr))
165}
166
167/// Wrapper for Match that implements ordering for the heap
168/// We keep the worst-ranked entry at the heap top for eviction.
169#[derive(Clone)]
170struct PoolEntry {
171    m: Match,
172    rank_key: (u8, i64, i32, u32), // (exactness, error_bits, mode_tie, complexity)
173}
174
175impl PoolEntry {
176    fn new(m: Match, ranking_mode: RankingMode) -> Self {
177        let is_exact = m.error.abs() < EXACT_MATCH_TOLERANCE;
178        let exactness_rank = if is_exact { 0 } else { 1 };
179        // Convert error to sortable integer, handling special values.
180        // For IEEE 754 doubles, positive values' bit patterns preserve ordering
181        // when interpreted as unsigned, but we need to handle NaN/Infinity specially.
182        let error_abs = m.error.abs();
183        let error_bits = if is_exact {
184            // Exact matches collapse to a single rank so complexity (not residual
185            // floating-point noise) decides between them; see `compare_matches`.
186            0
187        } else if error_abs.is_nan() {
188            // NaN should sort as worst (largest) error
189            i64::MAX
190        } else if error_abs.is_infinite() {
191            // Infinity should also sort as worst (just below NaN)
192            i64::MAX - 1
193        } else {
194            // Safe: positive f64 bit patterns have sign bit 0, so to_bits() < i64::MAX.
195            error_abs.to_bits() as i64
196        };
197        let mode_tie = match ranking_mode {
198            RankingMode::Complexity => m.complexity as i32,
199            RankingMode::Parity => legacy_parity_score_match(&m),
200        };
201        Self {
202            rank_key: (exactness_rank, error_bits, mode_tie, m.complexity),
203            m,
204        }
205    }
206}
207
208impl PartialEq for PoolEntry {
209    fn eq(&self, other: &Self) -> bool {
210        self.rank_key == other.rank_key
211            && self.m.lhs.expr == other.m.lhs.expr
212            && self.m.rhs.expr == other.m.rhs.expr
213    }
214}
215
216impl Eq for PoolEntry {}
217
218impl PartialOrd for PoolEntry {
219    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
220        Some(self.cmp(other))
221    }
222}
223
224impl Ord for PoolEntry {
225    fn cmp(&self, other: &Self) -> Ordering {
226        // Keep worst (least exact, largest error, largest complexity) at top for eviction.
227        self.rank_key
228            .cmp(&other.rank_key)
229            .then_with(|| compare_expr(&self.m.lhs.expr, &other.m.lhs.expr))
230            .then_with(|| compare_expr(&self.m.rhs.expr, &other.m.rhs.expr))
231    }
232}
233
234/// Statistics from pool operations
235#[derive(Clone, Debug, Default)]
236pub struct PoolStats {
237    /// Number of successful insertions
238    pub insertions: usize,
239    /// Number rejected due to error threshold
240    pub rejections_error: usize,
241    /// Number rejected due to deduplication
242    pub rejections_dedupe: usize,
243    /// Number evicted to maintain capacity
244    pub evictions: usize,
245}
246
247/// Bounded pool for collecting matches
248pub struct TopKPool {
249    /// Max capacity
250    capacity: usize,
251    /// Priority queue (worst at top for eviction)
252    heap: BinaryHeap<PoolEntry>,
253    /// Seen equation keys for dedupe
254    seen_eqn: HashSet<EqnKey>,
255    /// Best error seen so far (for threshold tightening)
256    pub best_error: f64,
257    /// Accept error threshold (tightens slowly for diversity)
258    pub accept_error: f64,
259    /// Statistics
260    pub stats: PoolStats,
261    /// Show diagnostic output for database adds (-DG)
262    show_db_adds: bool,
263    /// Ranking mode for eviction and output ordering
264    ranking_mode: RankingMode,
265}
266
267impl TopKPool {
268    /// Create a new pool with given capacity
269    #[allow(dead_code)]
270    pub fn new(capacity: usize, initial_max_error: f64) -> Self {
271        Self {
272            capacity,
273            heap: BinaryHeap::with_capacity(capacity + 1),
274            seen_eqn: HashSet::new(),
275            best_error: initial_max_error,
276            accept_error: initial_max_error,
277            stats: PoolStats::default(),
278            show_db_adds: false,
279            ranking_mode: RankingMode::Complexity,
280        }
281    }
282
283    /// Create a new pool with diagnostic options
284    pub fn new_with_diagnostics(
285        capacity: usize,
286        initial_max_error: f64,
287        show_db_adds: bool,
288        ranking_mode: RankingMode,
289    ) -> Self {
290        Self {
291            capacity,
292            heap: BinaryHeap::with_capacity(capacity + 1),
293            seen_eqn: HashSet::new(),
294            best_error: initial_max_error,
295            accept_error: initial_max_error,
296            stats: PoolStats::default(),
297            show_db_adds,
298            ranking_mode,
299        }
300    }
301
302    /// Try to insert a match into the pool
303    /// Returns true if inserted, false if rejected
304    pub fn try_insert(&mut self, m: Match) -> bool {
305        let error = m.error.abs();
306        let is_exact = error < EXACT_MATCH_TOLERANCE;
307
308        // Check error threshold (must be better than accept_error)
309        if !is_exact && error > self.accept_error {
310            self.stats.rejections_error += 1;
311            return false;
312        }
313
314        // Check equation-level dedupe
315        let eqn_key = EqnKey::from_match(&m);
316        if self.seen_eqn.contains(&eqn_key) {
317            self.stats.rejections_dedupe += 1;
318            return false;
319        }
320
321        // Insert
322        let entry = PoolEntry::new(m, self.ranking_mode);
323        self.seen_eqn.insert(eqn_key);
324
325        // Diagnostic output for -DG (before moving entry into heap)
326        if self.show_db_adds {
327            eprintln!(
328                "  [db add] lhs={:?} rhs={:?} error={:.6e} complexity={}",
329                entry.m.lhs.expr.to_postfix(),
330                entry.m.rhs.expr.to_postfix(),
331                entry.m.error,
332                entry.m.complexity
333            );
334        }
335
336        self.heap.push(entry);
337        self.stats.insertions += 1;
338
339        // Update thresholds
340        if is_exact {
341            // Exact match: tighten best_error aggressively but keep a floor
342            self.best_error =
343                EXACT_MATCH_TOLERANCE.max(self.best_error * BEST_ERROR_TIGHTEN_FACTOR);
344        } else if error < self.best_error {
345            // Better approximation: tighten best_error
346            self.best_error = error * BEST_ERROR_TIGHTEN_FACTOR - NEWTON_TOLERANCE;
347            self.best_error = self.best_error.max(EXACT_MATCH_TOLERANCE);
348        }
349
350        // Slowly tighten accept_error for diversity
351        if error < self.accept_error * ACCEPT_ERROR_TIGHTEN_FACTOR {
352            self.accept_error *= ACCEPT_ERROR_TIGHTEN_FACTOR;
353        }
354
355        // Evict worst if over capacity
356        if self.heap.len() > self.capacity {
357            if let Some(evicted) = self.heap.pop() {
358                // Remove the equation key so a *different* RHS for the same LHS can
359                // be inserted later.
360                self.seen_eqn.remove(&EqnKey::from_match(&evicted.m));
361                self.stats.evictions += 1;
362                self.relax_accept_error_after_eviction();
363            }
364        }
365
366        true
367    }
368
369    /// Re-anchor accept_error to pool contents when there is room after eviction.
370    fn relax_accept_error_after_eviction(&mut self) {
371        if self.heap.is_empty() {
372            return;
373        }
374
375        let relax_threshold =
376            (self.capacity as f64 * ACCEPT_ERROR_RELAX_CAPACITY_FRACTION).ceil() as usize;
377        if self.heap.len() >= relax_threshold {
378            return;
379        }
380
381        if let Some(worst) = self.heap.peek() {
382            let worst_error = worst.m.error.abs();
383            if worst_error >= EXACT_MATCH_TOLERANCE {
384                self.accept_error = self
385                    .accept_error
386                    .max(worst_error)
387                    .max(self.accept_error / ACCEPT_ERROR_TIGHTEN_FACTOR);
388            }
389        }
390    }
391
392    /// Complexity ceiling implied by a pool that is full of exact matches.
393    ///
394    /// When the pool is at capacity and its worst entry is already an exact
395    /// match, no candidate with complexity `>= ceiling` can ever enter: a
396    /// non-exact candidate loses on exactness, and an equally-exact candidate
397    /// loses on complexity (exact matches rank by complexity). Callers use this
398    /// to skip Newton refinement — and, since LHS are processed in ascending
399    /// complexity order, to stop the search entirely — once it fires.
400    ///
401    /// Returns `None` when the pool is not yet saturated with exact matches, so
402    /// the optimization never prunes while better matches are still reachable.
403    #[inline]
404    pub fn exact_complexity_ceiling(&self) -> Option<u32> {
405        if self.heap.len() < self.capacity {
406            return None;
407        }
408        let worst = self.heap.peek()?;
409        if worst.m.error.abs() < EXACT_MATCH_TOLERANCE {
410            Some(worst.m.complexity)
411        } else {
412            None
413        }
414    }
415
416    /// Check if a match would be accepted (for early pruning)
417    pub fn would_accept(&self, error: f64, is_exact: bool) -> bool {
418        if is_exact {
419            return true;
420        }
421        error <= self.accept_error
422    }
423
424    /// Check if a match would be accepted, with stricter gate when pool is near capacity
425    /// This is used as a pre-Newton filter to avoid expensive refinement calls
426    pub fn would_accept_strict(&self, coarse_error: f64, is_potentially_exact: bool) -> bool {
427        // Always accept potential exact matches
428        if is_potentially_exact {
429            return true;
430        }
431
432        // Basic threshold check
433        if coarse_error > self.accept_error {
434            return false;
435        }
436
437        // Stricter check when pool is near capacity:
438        // If we're at capacity fraction and have good matches, be more aggressive
439        if self.heap.len() as f64 >= self.capacity as f64 * STRICT_GATE_CAPACITY_FRACTION {
440            // Only accept if error is below strict gate threshold
441            // This avoids Newton calls for marginal candidates
442            if coarse_error > self.accept_error * STRICT_GATE_FACTOR {
443                return false;
444            }
445        }
446
447        true
448    }
449
450    /// Get all matches, sorted by ranking mode.
451    pub fn into_sorted(self) -> Vec<Match> {
452        let ranking_mode = self.ranking_mode;
453        let mut matches: Vec<Match> = self.heap.into_iter().map(|e| e.m).collect();
454        matches.sort_by(|a, b| compare_matches(a, b, ranking_mode));
455        matches
456    }
457
458    /// Get current pool size
459    pub fn len(&self) -> usize {
460        self.heap.len()
461    }
462
463    /// Check if pool is empty
464    #[allow(dead_code)]
465    pub fn is_empty(&self) -> bool {
466        self.heap.is_empty()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::expr::{EvaluatedExpr, Expression};
474    use crate::symbol::NumType;
475
476    fn make_match(lhs: &str, rhs: &str, error: f64, complexity: u32) -> Match {
477        let lhs_expr = Expression::parse(lhs).unwrap();
478        let rhs_expr = Expression::parse(rhs).unwrap();
479        Match {
480            lhs: EvaluatedExpr::new(lhs_expr, 0.0, 1.0, NumType::Integer),
481            rhs: EvaluatedExpr::new(rhs_expr, 0.0, 0.0, NumType::Integer),
482            x_value: 2.5,
483            error,
484            complexity,
485        }
486    }
487
488    #[test]
489    fn test_pool_basic() {
490        let mut pool = TopKPool::new(5, 1.0);
491
492        // Insert some matches
493        assert!(pool.try_insert(make_match("2x*", "5", 0.0, 27)));
494        assert!(pool.try_insert(make_match("x1+", "35/", 0.01, 34)));
495
496        assert_eq!(pool.len(), 2);
497    }
498
499    #[test]
500    fn test_pool_eviction() {
501        let mut pool = TopKPool::new(2, 1.0);
502
503        // Insert 3 matches into pool of capacity 2
504        // Worst (highest complexity): xs with complexity 50
505        // Best: 2x* with complexity 27 and exact match
506        // Medium: x1+ with complexity 34
507        pool.try_insert(make_match("xs", "64/", 0.1, 50));
508        pool.try_insert(make_match("2x*", "5", 0.0, 27));
509        pool.try_insert(make_match("x1+", "35/", 0.01, 34));
510
511        // Should have evicted the worst one (highest complexity)
512        assert_eq!(pool.len(), 2);
513
514        let sorted = pool.into_sorted();
515        // The two best matches should remain (by complexity)
516        // 2x* (27) and x1+ (34) should remain, xs (50) should be evicted
517        let remaining: Vec<_> = sorted.iter().map(|m| m.lhs.expr.to_postfix()).collect();
518        assert!(
519            remaining.contains(&"2x*".to_string()),
520            "Expected 2x* to remain, got: {:?}",
521            remaining
522        );
523        assert!(
524            remaining.contains(&"x1+".to_string()),
525            "Expected x1+ to remain, got: {:?}",
526            remaining
527        );
528    }
529
530    #[test]
531    fn test_pool_dedupe() {
532        let mut pool = TopKPool::new(10, 1.0);
533
534        // Try to insert same equation twice
535        assert!(pool.try_insert(make_match("2x*", "5", 0.0, 27)));
536        assert!(!pool.try_insert(make_match("2x*", "5", 0.0, 27)));
537
538        assert_eq!(pool.len(), 1);
539    }
540
541    #[test]
542    fn test_parity_score_prefers_operator_dense_form() {
543        let low_operator = make_match("2x*", "5", 1e-6, 10);
544        let high_operator = make_match("x1+", "3", 1e-6, 20);
545
546        let low_score = legacy_parity_score_match(&low_operator);
547        let high_score = legacy_parity_score_match(&high_operator);
548        assert!(
549            high_score < low_score,
550            "expected operator-dense form to have lower legacy parity score ({} vs {})",
551            high_score,
552            low_score
553        );
554    }
555
556    #[test]
557    fn test_parity_ranking_changes_ordering() {
558        let low_operator = make_match("2x*", "5", 1e-6, 10);
559        let high_operator = make_match("x1+", "3", 1e-6, 20);
560
561        // Complexity mode: simpler complexity first.
562        let mut complexity_pool =
563            TopKPool::new_with_diagnostics(10, 1.0, false, RankingMode::Complexity);
564        complexity_pool.try_insert(low_operator.clone());
565        complexity_pool.try_insert(high_operator.clone());
566        let complexity_sorted = complexity_pool.into_sorted();
567        assert_eq!(complexity_sorted[0].lhs.expr.to_postfix(), "2x*");
568
569        // Parity mode: legacy parity score first.
570        let mut parity_pool = TopKPool::new_with_diagnostics(10, 1.0, false, RankingMode::Parity);
571        parity_pool.try_insert(low_operator);
572        parity_pool.try_insert(high_operator);
573        let parity_sorted = parity_pool.into_sorted();
574        assert_eq!(parity_sorted[0].lhs.expr.to_postfix(), "x1+");
575    }
576
577    #[test]
578    fn test_pool_handles_nan_and_infinity_errors() {
579        let mut pool = TopKPool::new(10, f64::INFINITY);
580
581        // Normal error should be accepted
582        let normal = make_match("x", "1", 0.01, 25);
583        assert!(pool.try_insert(normal));
584
585        // Infinity error should sort as worst but still be accepted
586        let infinite = make_match("x1+", "2", f64::INFINITY, 30);
587        assert!(pool.try_insert(infinite));
588
589        // NaN error should also be handled (sorts as worst)
590        let nan_match = make_match("x2*", "3", f64::NAN, 35);
591        assert!(pool.try_insert(nan_match));
592
593        // All three should be in the pool
594        assert_eq!(pool.len(), 3);
595
596        // When sorted, the normal match should come first (lowest error)
597        let sorted = pool.into_sorted();
598        assert_eq!(sorted[0].lhs.expr.to_postfix(), "x");
599    }
600
601    #[test]
602    fn test_pool_relaxes_accept_error_after_eviction_when_under_half_capacity() {
603        let mut pool = TopKPool::new(4, 1.0);
604
605        pool.try_insert(make_match("x", "1", 0.5, 10));
606        pool.try_insert(make_match("x1+", "2", 0.4, 20));
607        pool.try_insert(make_match("x2*", "3", 0.3, 30));
608        pool.try_insert(make_match("x3/", "4", 0.2, 40));
609        pool.try_insert(make_match("x4-", "5", 0.1, 50));
610
611        assert_eq!(pool.len(), 4);
612        assert!(
613            pool.accept_error >= 0.2,
614            "expected accept_error to relax after eviction, got {}",
615            pool.accept_error
616        );
617    }
618
619    #[test]
620    fn test_exact_matches_rank_by_complexity_not_residual() {
621        // A simpler exact match with a (slightly) larger sub-tolerance residual
622        // must still outrank a more complex exact match with a tinier residual:
623        // residual differences below EXACT_MATCH_TOLERANCE are noise.
624        let simpler = make_match("x", "2p*", 5e-15, 46);
625        let complex = make_match("x1+", "2p*1+", 1e-16, 80);
626
627        let mut pool = TopKPool::new(10, 1.0);
628        pool.try_insert(complex);
629        pool.try_insert(simpler);
630
631        let sorted = pool.into_sorted();
632        assert_eq!(
633            sorted[0].complexity, 46,
634            "simpler exact match should rank first"
635        );
636    }
637
638    #[test]
639    fn test_exact_complexity_ceiling_only_when_saturated_with_exacts() {
640        let mut pool = TopKPool::new(2, 1.0);
641
642        // Not full yet -> no ceiling.
643        pool.try_insert(make_match("x", "1", 0.0, 30));
644        assert_eq!(pool.exact_complexity_ceiling(), None);
645
646        // Full, all exact -> ceiling is the worst (largest) exact complexity.
647        pool.try_insert(make_match("x1+", "2", 0.0, 50));
648        assert_eq!(pool.exact_complexity_ceiling(), Some(50));
649
650        // A simpler exact evicts the complexity-50 entry, tightening the ceiling.
651        pool.try_insert(make_match("x2*", "3", 0.0, 40));
652        assert_eq!(pool.exact_complexity_ceiling(), Some(40));
653    }
654
655    #[test]
656    fn test_exact_complexity_ceiling_absent_when_worst_is_inexact() {
657        let mut pool = TopKPool::new(2, 1.0);
658        pool.try_insert(make_match("x", "1", 0.0, 30));
659        pool.try_insert(make_match("x1+", "2", 0.01, 20));
660
661        // Pool is full but its worst entry is a non-exact approximation, so a
662        // simpler exact match could still arrive: no ceiling.
663        assert_eq!(pool.exact_complexity_ceiling(), None);
664    }
665
666    #[test]
667    fn test_pool_entry_distinct_with_same_rank_key() {
668        // Two matches with identical rank_key but different expressions
669        // should both be insertable (they're distinct equations)
670        let mut pool = TopKPool::new(10, 1.0);
671
672        // Both have error 0.0 and same complexity, but different LHS
673        let m1 = make_match("x", "1", 0.0, 25);
674        let m2 = make_match("x1-", "1", 0.0, 25);
675
676        assert!(pool.try_insert(m1));
677        assert!(pool.try_insert(m2));
678
679        // Both should be in the pool since they're different equations
680        assert_eq!(pool.len(), 2);
681    }
682}