Skip to main content

ries_rs/
search.rs

1//! Search and matching algorithms
2//!
3//! Finds equations by matching LHS and RHS expressions.
4
5use crate::eval::EvalContext;
6use crate::expr::EvaluatedExpr;
7use crate::pool::{RankingMode, TopKPool};
8use crate::profile::UserConstant;
9use crate::thresholds::{
10    DEGENERATE_DERIVATIVE, DEGENERATE_RANGE_TOLERANCE, DEGENERATE_TEST_THRESHOLD,
11    EXACT_MATCH_TOLERANCE, NEWTON_FINAL_TOLERANCE,
12};
13use std::collections::HashSet;
14use std::time::Duration;
15
16mod db;
17mod newton;
18#[cfg(test)]
19mod tests;
20
21use db::calculate_adaptive_search_radius;
22pub use db::{ComplexityTier, ExprDatabase, TieredExprDatabase};
23
24/// Returns true when a near-zero-derivative LHS should be skipped as degenerate.
25pub(crate) fn should_skip_degenerate_lhs(
26    lhs: &EvaluatedExpr,
27    target: f64,
28    eval: &EvalContext<'_>,
29) -> bool {
30    if lhs.derivative.abs() >= DEGENERATE_TEST_THRESHOLD {
31        return false;
32    }
33    let test_x = target + std::f64::consts::E;
34    match crate::eval::evaluate_fast_with_context(&lhs.expr, test_x, eval) {
35        Ok(test_result) => {
36            let value_unchanged = (test_result.value - lhs.value).abs() < DEGENERATE_TEST_THRESHOLD;
37            let deriv_still_zero = test_result.derivative.abs() < DEGENERATE_TEST_THRESHOLD;
38            deriv_still_zero || value_unchanged
39        }
40        Err(_) => true,
41    }
42}
43#[cfg(test)]
44use newton::newton_raphson;
45use newton::newton_raphson_with_constants;
46
47#[derive(Clone, Copy, Debug)]
48struct SearchTimer {
49    #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
50    start_ms: f64,
51    #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
52    start: std::time::Instant,
53}
54
55impl SearchTimer {
56    #[inline]
57    fn start() -> Self {
58        #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
59        {
60            Self {
61                start_ms: js_sys::Date::now(),
62            }
63        }
64
65        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
66        {
67            Self {
68                start: std::time::Instant::now(),
69            }
70        }
71    }
72
73    #[inline]
74    fn elapsed(self) -> Duration {
75        #[cfg(all(target_arch = "wasm32", feature = "wasm"))]
76        {
77            let elapsed_ms = (js_sys::Date::now() - self.start_ms).max(0.0);
78            Duration::from_secs_f64(elapsed_ms / 1000.0)
79        }
80
81        #[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
82        {
83            self.start.elapsed()
84        }
85    }
86}
87
88/// Statistics collected during search
89#[derive(Clone, Debug, Default)]
90pub struct SearchStats {
91    /// Time spent generating expressions
92    pub gen_time: Duration,
93    /// Time spent searching/matching
94    pub search_time: Duration,
95    /// Number of LHS expressions generated
96    pub lhs_count: usize,
97    /// Number of RHS expressions generated
98    pub rhs_count: usize,
99    /// Number of LHS expressions tested (after filtering)
100    pub lhs_tested: usize,
101    /// Number of candidate pairs tested (coarse filter)
102    pub candidates_tested: usize,
103    /// Total number of RHS expressions scanned across all candidate windows
104    pub candidate_window_total: usize,
105    /// Largest RHS candidate window scanned for any single LHS
106    pub candidate_window_max: usize,
107    /// Postfix form of the LHS that produced the largest candidate window
108    pub candidate_window_max_lhs_postfix: Option<String>,
109    /// LHS value for the largest candidate window
110    pub candidate_window_max_lhs_value: Option<f64>,
111    /// LHS derivative for the largest candidate window
112    pub candidate_window_max_lhs_derivative: Option<f64>,
113    /// LHS complexity for the largest candidate window
114    pub candidate_window_max_lhs_complexity: Option<u32>,
115    /// Number of candidates rejected by the strict pre-Newton gate
116    pub strict_gate_rejections: usize,
117    /// Number of Newton-Raphson calls
118    pub newton_calls: usize,
119    /// Number of successful Newton convergences
120    pub newton_success: usize,
121    /// Number of matches inserted into pool
122    pub pool_insertions: usize,
123    /// Number of matches rejected by pool (error threshold)
124    pub pool_rejections_error: usize,
125    /// Number of matches rejected by pool (dedupe)
126    pub pool_rejections_dedupe: usize,
127    /// Number of matches evicted from pool
128    pub pool_evictions: usize,
129    /// Final pool size
130    pub pool_final_size: usize,
131    /// Final best error in pool
132    pub pool_best_error: f64,
133    /// Whether search exited early
134    pub early_exit: bool,
135}
136
137impl SearchStats {
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    #[inline]
143    pub fn record_candidate_window(&mut self, lhs: &EvaluatedExpr, width: usize) {
144        self.candidate_window_total = self.candidate_window_total.saturating_add(width);
145        if width > self.candidate_window_max {
146            self.candidate_window_max = width;
147            self.candidate_window_max_lhs_postfix = Some(lhs.expr.to_postfix());
148            self.candidate_window_max_lhs_value = Some(lhs.value);
149            self.candidate_window_max_lhs_derivative = Some(lhs.derivative);
150            self.candidate_window_max_lhs_complexity = Some(lhs.expr.complexity());
151        }
152    }
153
154    /// Merge another stats record's additive search/pool counters into this one.
155    ///
156    /// Generation-side counts and timing are owned by the caller and are not
157    /// merged. The largest-candidate-window record keeps whichever side observed
158    /// the wider window. Used to combine per-thread stats from the turbo matcher.
159    #[cfg(feature = "parallel")]
160    pub fn merge_search_counters(&mut self, other: &SearchStats) {
161        self.lhs_tested = self.lhs_tested.saturating_add(other.lhs_tested);
162        self.candidates_tested = self
163            .candidates_tested
164            .saturating_add(other.candidates_tested);
165        self.candidate_window_total = self
166            .candidate_window_total
167            .saturating_add(other.candidate_window_total);
168        if other.candidate_window_max > self.candidate_window_max {
169            self.candidate_window_max = other.candidate_window_max;
170            self.candidate_window_max_lhs_postfix = other.candidate_window_max_lhs_postfix.clone();
171            self.candidate_window_max_lhs_value = other.candidate_window_max_lhs_value;
172            self.candidate_window_max_lhs_derivative = other.candidate_window_max_lhs_derivative;
173            self.candidate_window_max_lhs_complexity = other.candidate_window_max_lhs_complexity;
174        }
175        self.strict_gate_rejections = self
176            .strict_gate_rejections
177            .saturating_add(other.strict_gate_rejections);
178        self.newton_calls = self.newton_calls.saturating_add(other.newton_calls);
179        self.newton_success = self.newton_success.saturating_add(other.newton_success);
180        self.early_exit |= other.early_exit;
181    }
182
183    #[inline]
184    pub fn candidate_window_avg(&self) -> f64 {
185        if self.lhs_tested == 0 {
186            0.0
187        } else {
188            self.candidate_window_total as f64 / self.lhs_tested as f64
189        }
190    }
191
192    #[inline]
193    pub fn candidates_per_pool_insertion(&self) -> f64 {
194        if self.pool_insertions == 0 {
195            0.0
196        } else {
197            self.candidates_tested as f64 / self.pool_insertions as f64
198        }
199    }
200
201    #[inline]
202    pub fn newton_success_rate(&self) -> f64 {
203        if self.newton_calls == 0 {
204            0.0
205        } else {
206            self.newton_success as f64 / self.newton_calls as f64
207        }
208    }
209
210    #[inline]
211    pub fn pool_attempts(&self) -> usize {
212        self.pool_insertions
213            .saturating_add(self.pool_rejections_error)
214            .saturating_add(self.pool_rejections_dedupe)
215    }
216
217    #[inline]
218    pub fn pool_acceptance_rate(&self) -> f64 {
219        let attempts = self.pool_attempts();
220        if attempts == 0 {
221            0.0
222        } else {
223            self.pool_insertions as f64 / attempts as f64
224        }
225    }
226
227    /// Print stats to stdout
228    pub fn print(&self) {
229        println!();
230        println!("  === Search Statistics ===");
231        println!();
232        println!("  Generation:");
233        println!(
234            "    Time:            {:>10.3}ms",
235            self.gen_time.as_secs_f64() * 1000.0
236        );
237        println!("    LHS expressions: {:>10}", self.lhs_count);
238        println!("    RHS expressions: {:>10}", self.rhs_count);
239        println!();
240        println!("  Search:");
241        println!(
242            "    Time:            {:>10.3}ms",
243            self.search_time.as_secs_f64() * 1000.0
244        );
245        println!("    LHS tested:      {:>10}", self.lhs_tested);
246        println!("    Candidates:      {:>10}", self.candidates_tested);
247        println!("    Window avg:      {:>10.2}", self.candidate_window_avg());
248        println!("    Window max:      {:>10}", self.candidate_window_max);
249        if let Some(lhs) = &self.candidate_window_max_lhs_postfix {
250            println!("    Window max lhs:  {:>10}", lhs);
251        }
252        println!("    Gate rejects:    {:>10}", self.strict_gate_rejections);
253        println!(
254            "    Cand/insert:     {:>10.2}",
255            self.candidates_per_pool_insertion()
256        );
257        println!("    Newton calls:    {:>10}", self.newton_calls);
258        println!(
259            "    Newton success:  {:>10} ({:.1}%)",
260            self.newton_success,
261            100.0 * self.newton_success_rate()
262        );
263        if self.early_exit {
264            println!("    Early exit:      yes");
265        }
266        println!();
267        println!("  Pool:");
268        println!("    Insertions:      {:>10}", self.pool_insertions);
269        println!("    Rejected (err):  {:>10}", self.pool_rejections_error);
270        println!("    Rejected (dup):  {:>10}", self.pool_rejections_dedupe);
271        println!("    Evictions:       {:>10}", self.pool_evictions);
272        println!(
273            "    Acceptance:      {:>9.1}%",
274            100.0 * self.pool_acceptance_rate()
275        );
276        println!("    Final size:      {:>10}", self.pool_final_size);
277        println!("    Best error:      {:>14.2e}", self.pool_best_error);
278    }
279}
280
281/// Compute complexity limits from a search level.
282///
283/// This function provides a consistent mapping from integer search levels
284/// to complexity limits used by the library API (Python, WASM, and adaptive mode).
285///
286/// # Formula
287///
288/// - Base LHS complexity: 10
289/// - Base RHS complexity: 12
290/// - Level multiplier: 4
291/// - `max_lhs = 10 + 4 * level`
292/// - `max_rhs = 12 + 4 * level`
293///
294/// # Level Guidelines
295///
296/// | Level | LHS Max | RHS Max | Expression Count (approx) |
297/// |-------|---------|---------|---------------------------|
298/// | 0     | 10      | 12      | ~35K                      |
299/// | 1     | 14      | 16      | ~130K                     |
300/// | 2     | 18      | 20      | ~500K                     |
301/// | 3     | 22      | 24      | ~2M                       |
302/// | 4     | 26      | 28      | ~6M                       |
303/// | 5     | 30      | 32      | ~15M                      |
304///
305/// # Note
306///
307/// The CLI uses a different formula with higher bases (35/35) and multiplier (10)
308/// to match the original RIES command-line behavior. This function is for
309/// programmatic API consumers.
310#[inline]
311pub fn level_to_complexity(level: u32) -> (u32, u32) {
312    const BASE_LHS: u32 = 10;
313    const BASE_RHS: u32 = 12;
314    const LEVEL_MULTIPLIER: u32 = 4;
315
316    // Saturating arithmetic avoids panics/wraparound if an API caller passes
317    // an out-of-range level without validation.
318    let level_factor = LEVEL_MULTIPLIER.saturating_mul(level);
319    (
320        BASE_LHS.saturating_add(level_factor),
321        BASE_RHS.saturating_add(level_factor),
322    )
323}
324
325/// A matched equation
326#[derive(Clone, Debug)]
327pub struct Match {
328    /// Left-hand side expression (contains x)
329    pub lhs: EvaluatedExpr,
330    /// Right-hand side expression (constant)
331    pub rhs: EvaluatedExpr,
332    /// Solved value of x
333    pub x_value: f64,
334    /// Difference from target: x_value - target
335    pub error: f64,
336    /// Total complexity (LHS + RHS)
337    pub complexity: u32,
338}
339
340impl Match {
341    /// Format the match for display (used in tests)
342    #[cfg(test)]
343    pub fn display(&self, _target: f64) -> String {
344        let lhs_str = self.lhs.expr.to_infix();
345        let rhs_str = self.rhs.expr.to_infix();
346
347        let error_str = if self.error.abs() < EXACT_MATCH_TOLERANCE {
348            "('exact' match)".to_string()
349        } else {
350            let sign = if self.error >= 0.0 { "+" } else { "-" };
351            format!("for x = T {} {:.6e}", sign, self.error.abs())
352        };
353
354        format!(
355            "{:>24} = {:<24} {} {{{}}}",
356            lhs_str, rhs_str, error_str, self.complexity
357        )
358    }
359}
360
361/// Configuration for the search process.
362///
363/// Controls matching thresholds, result limits, symbol filtering, and search behavior.
364/// This struct is the main entry point for customizing how RIES searches for equations.
365///
366/// # Example
367///
368/// ```rust
369/// use ries_rs::search::SearchConfig;
370/// use ries_rs::pool::RankingMode;
371///
372/// let config = SearchConfig {
373///     target: std::f64::consts::PI,
374///     max_matches: 50,
375///     max_error: 1e-10,
376///     stop_at_exact: true,
377///     ranking_mode: RankingMode::Complexity,
378///     ..SearchConfig::default()
379/// };
380/// ```
381///
382/// # Fields Overview
383///
384/// - **Target**: `target` - the value to search for equations matching it
385/// - **Limits**: `max_matches`, `max_error` - control result quantity and quality
386/// - **Stopping**: `stop_at_exact`, `stop_below` - early termination conditions
387/// - **Refinement**: `newton_iterations`, `refine_with_newton`, `derivative_margin` - Newton-Raphson settings
388/// - **Filtering**: `zero_value_threshold`, `rhs_allowed_symbols`, `rhs_excluded_symbols` - prune unwanted results
389/// - **Extensions**: `user_constants`, `user_functions` - custom symbols
390/// - **Diagnostics**: `show_newton`, `show_match_checks`, etc. - debug output
391/// - **Ranking**: `ranking_mode` - how to order results
392#[derive(Clone, Debug)]
393pub struct SearchConfig {
394    /// Target value to find equations for.
395    ///
396    /// The search will find equations where LHS ≈ RHS ≈ target.
397    /// This is the number you're trying to "solve" or represent symbolically.
398    ///
399    /// Default: 0.0
400    pub target: f64,
401
402    /// Maximum number of matches to return in results.
403    ///
404    /// Once this many matches are found and confirmed, the pool will start
405    /// evicting lower-quality matches to make room for better ones.
406    ///
407    /// Default: 100
408    pub max_matches: usize,
409
410    /// Maximum acceptable error for a match to be included.
411    ///
412    /// Only expressions within this absolute error from the target are considered matches.
413    /// Smaller values give more precise but fewer results.
414    ///
415    /// Default: 1.0
416    pub max_error: f64,
417
418    /// Stop search when an exact match is found.
419    ///
420    /// When true and an expression matches within the exact match tolerance,
421    /// the search terminates early. Useful when you only need one good solution.
422    ///
423    /// Default: false
424    pub stop_at_exact: bool,
425
426    /// Stop search when error goes below this threshold.
427    ///
428    /// If set, the search will terminate once a match is found with error
429    /// below this value. Set to `Some(1e-12)` for high-precision early stopping.
430    ///
431    /// Default: None
432    pub stop_below: Option<f64>,
433
434    /// Threshold for pruning LHS expressions with near-zero values.
435    ///
436    /// LHS expressions with absolute values below this threshold are pruned
437    /// to prevent flooding results with trivial matches like `cospi(2.5) = 0`.
438    /// Set to 0.0 to disable this filtering.
439    ///
440    /// Default: 1e-4
441    pub zero_value_threshold: f64,
442
443    /// Maximum Newton-Raphson iterations for root refinement.
444    ///
445    /// Controls how many iterations are used to refine candidate solutions.
446    /// Higher values may find more precise roots but take longer.
447    ///
448    /// Default: 15
449    pub newton_iterations: usize,
450
451    /// User-defined constants for evaluation.
452    ///
453    /// Custom constants that can be used in expressions, defined via `-N\'name=value\'`.
454    /// Each constant has a name, value, and optional description.
455    ///
456    /// Default: empty
457    pub user_constants: Vec<UserConstant>,
458
459    /// User-defined functions for evaluation.
460    ///
461    /// Custom functions that can be used in expressions, defined via `-F\'name:formula\'`.
462    /// These extend the built-in functions (sin, cos, etc.).
463    ///
464    /// Default: empty
465    pub user_functions: Vec<crate::udf::UserFunction>,
466
467    /// Argument scale for `sinpi/cospi/tanpi` evaluation.
468    ///
469    /// The default is π, matching original `sinpi(x) = sin(πx)` semantics.
470    /// Override this for alternate trig conventions without relying on global state.
471    pub trig_argument_scale: f64,
472
473    /// Whether to refine candidate roots with Newton-Raphson iteration.
474    ///
475    /// When true, initial coarse matches are refined using Newton-Raphson
476    /// to find more precise solutions. Disable for faster but less precise search.
477    ///
478    /// Default: true
479    pub refine_with_newton: bool,
480
481    /// Optional RHS-only allowed symbol set.
482    ///
483    /// If set, all symbols used on the RHS must be in this set (specified as ASCII bytes).
484    /// This allows restricting RHS expressions to a subset of available symbols.
485    /// Combined with `rhs_excluded_symbols`, both filters apply.
486    ///
487    /// Default: None (all symbols allowed)
488    pub rhs_allowed_symbols: Option<HashSet<u8>>,
489
490    /// Optional RHS-only excluded symbol set.
491    ///
492    /// If set, RHS expressions using any of these symbols are rejected.
493    /// This allows excluding specific symbols from RHS expressions.
494    /// Combined with `rhs_allowed_symbols`, both filters apply.
495    ///
496    /// Default: None (no symbols excluded)
497    pub rhs_excluded_symbols: Option<HashSet<u8>>,
498
499    /// Show Newton-Raphson iteration diagnostic output.
500    ///
501    /// When true, prints detailed information about each Newton-Raphson iteration.
502    /// Enabled by `-Dn` command-line flag. Useful for debugging convergence issues.
503    ///
504    /// Default: false
505    pub show_newton: bool,
506
507    /// Show match check diagnostic output.
508    ///
509    /// When true, prints information about each candidate match evaluation.
510    /// Enabled by `-Do` command-line flag.
511    ///
512    /// Default: false
513    pub show_match_checks: bool,
514
515    /// Show pruned arithmetic diagnostic output.
516    ///
517    /// When true, prints information about arithmetic expressions that were pruned.
518    /// Enabled by `-DA` command-line flag.
519    ///
520    /// Default: false
521    #[allow(dead_code)]
522    pub show_pruned_arith: bool,
523
524    /// Show pruned range/zero diagnostic output.
525    ///
526    /// When true, prints information about expressions pruned due to range
527    /// constraints or near-zero values. Enabled by `-DB` command-line flag.
528    ///
529    /// Default: false
530    pub show_pruned_range: bool,
531
532    /// Show database adds diagnostic output.
533    ///
534    /// When true, prints information about expressions added to the database.
535    /// Enabled by `-DG` command-line flag.
536    ///
537    /// Default: false
538    pub show_db_adds: bool,
539
540    /// When true, matches must match all significant digits of the target.
541    ///
542    /// When enabled, the tolerance is computed based on the magnitude of the
543    /// target value to require full precision matching. The actual tolerance
544    /// is computed in `main.rs` and passed as `max_error`.
545    ///
546    /// Default: false
547    #[allow(dead_code)]
548    pub match_all_digits: bool,
549
550    /// Threshold below which derivative is considered degenerate in Newton-Raphson.
551    ///
552    /// If `|derivative| < derivative_margin` during Newton-Raphson iteration,
553    /// the refinement is skipped to avoid numerical instability and division
554    /// by near-zero values.
555    ///
556    /// Default: 1e-12 (from `DEGENERATE_DERIVATIVE` constant)
557    pub derivative_margin: f64,
558
559    /// Ranking mode for pool ordering and eviction.
560    ///
561    /// Determines how matches are ranked in the result pool:
562    /// - `Complexity`: Sort by expression complexity (simpler is better)
563    /// - `Error`: Sort by match error (more precise is better)
564    ///
565    /// Default: `RankingMode::Complexity`
566    pub ranking_mode: RankingMode,
567}
568
569impl Default for SearchConfig {
570    fn default() -> Self {
571        Self {
572            target: 0.0,
573            max_matches: 100,
574            max_error: 1.0,
575            stop_at_exact: false,
576            stop_below: None,
577            zero_value_threshold: 1e-4,
578            newton_iterations: 15,
579            user_constants: Vec::new(),
580            user_functions: Vec::new(),
581            trig_argument_scale: crate::eval::DEFAULT_TRIG_ARGUMENT_SCALE,
582            refine_with_newton: true,
583            rhs_allowed_symbols: None,
584            rhs_excluded_symbols: None,
585            show_newton: false,
586            show_match_checks: false,
587            show_pruned_arith: false,
588            show_pruned_range: false,
589            show_db_adds: false,
590            match_all_digits: false,
591            derivative_margin: DEGENERATE_DERIVATIVE,
592            ranking_mode: RankingMode::Complexity,
593        }
594    }
595}
596
597impl SearchConfig {
598    /// Build an explicit search context for this configuration.
599    pub fn context(&self) -> SearchContext<'_> {
600        SearchContext::new(self)
601    }
602
603    #[inline]
604    fn rhs_symbol_allowed(&self, rhs: &crate::expr::Expression) -> bool {
605        let symbols = rhs.symbols();
606
607        if let Some(allowed) = &self.rhs_allowed_symbols {
608            if symbols.iter().any(|s| !allowed.contains(&(*s as u8))) {
609                return false;
610            }
611        }
612
613        if let Some(excluded) = &self.rhs_excluded_symbols {
614            if symbols.iter().any(|s| excluded.contains(&(*s as u8))) {
615                return false;
616            }
617        }
618
619        true
620    }
621}
622
623/// Explicit per-run search context.
624#[derive(Clone, Copy, Debug)]
625pub struct SearchContext<'a> {
626    /// Immutable search configuration for this run.
627    pub config: &'a SearchConfig,
628    /// Evaluation context derived from the search configuration.
629    pub eval: EvalContext<'a>,
630}
631
632impl<'a> SearchContext<'a> {
633    pub fn new(config: &'a SearchConfig) -> Self {
634        Self {
635            config,
636            eval: EvalContext::from_slices(&config.user_constants, &config.user_functions)
637                .with_trig_argument_scale(config.trig_argument_scale),
638        }
639    }
640}
641
642/// Perform a complete search
643///
644/// This function is part of the public API for library consumers who want a simple
645/// search interface without statistics collection.
646#[allow(dead_code)]
647pub fn search(target: f64, gen_config: &crate::gen::GenConfig, max_matches: usize) -> Vec<Match> {
648    let (matches, _stats) = search_with_stats(target, gen_config, max_matches);
649    matches
650}
651
652/// Perform a complete search with statistics collection
653///
654/// This function is part of the public API for library consumers who want
655/// detailed statistics about the search process.
656#[allow(dead_code)]
657pub fn search_with_stats(
658    target: f64,
659    gen_config: &crate::gen::GenConfig,
660    max_matches: usize,
661) -> (Vec<Match>, SearchStats) {
662    search_with_stats_and_options(target, gen_config, max_matches, false, None)
663}
664
665/// Perform a complete search with statistics collection and early exit options
666pub fn search_with_stats_and_options(
667    target: f64,
668    gen_config: &crate::gen::GenConfig,
669    max_matches: usize,
670    stop_at_exact: bool,
671    stop_below: Option<f64>,
672) -> (Vec<Match>, SearchStats) {
673    if !target.is_finite() {
674        return (Vec::new(), SearchStats::default());
675    }
676    let config = SearchConfig {
677        target,
678        max_matches,
679        stop_at_exact,
680        stop_below,
681        user_constants: gen_config.user_constants.clone(),
682        user_functions: gen_config.user_functions.clone(),
683        ..Default::default()
684    };
685
686    search_with_stats_and_config(gen_config, &config)
687}
688
689/// Perform a complete search with a fully specified search configuration.
690///
691/// This function includes a safety fallback: if expression generation would
692/// exceed ~2M expressions (which would risk OOM), it automatically switches
693/// to streaming mode to avoid memory exhaustion.
694pub fn search_with_stats_and_config(
695    gen_config: &crate::gen::GenConfig,
696    config: &SearchConfig,
697) -> (Vec<Match>, SearchStats) {
698    if !config.target.is_finite() {
699        return (Vec::new(), SearchStats::default());
700    }
701
702    use crate::gen::generate_all_with_limit_and_context;
703
704    const MAX_EXPRESSIONS_BEFORE_STREAMING: usize = 2_000_000;
705    let context = SearchContext::new(config);
706
707    let gen_start = SearchTimer::start();
708
709    // Try bounded generation first — if limit exceeded, fall back to streaming
710    if let Some(generated) = generate_all_with_limit_and_context(
711        gen_config,
712        config.target,
713        &context.eval,
714        MAX_EXPRESSIONS_BEFORE_STREAMING,
715    ) {
716        let gen_time = gen_start.elapsed();
717
718        // Build database
719        let mut db = ExprDatabase::new();
720        db.insert_rhs(generated.rhs);
721
722        // Find matches with stats
723        let (matches, mut stats) = db.find_matches_with_stats_and_context(&generated.lhs, &context);
724
725        // Add generation stats
726        stats.gen_time = gen_time;
727        stats.lhs_count = generated.lhs.len();
728        stats.rhs_count = db.rhs_count();
729
730        (matches, stats)
731    } else {
732        // Limit exceeded — fall back to streaming mode which avoids OOM
733        search_streaming_with_config(gen_config, config)
734    }
735}
736
737// =============================================================================
738// ADAPTIVE SEARCH - Iterative LHS/RHS expansion like original RIES
739// =============================================================================
740
741/// Perform an adaptive search that iteratively expands LHS/RHS complexity
742///
743/// This implements the original RIES algorithm where expressions are generated
744/// incrementally, expanding the side (LHS or RHS) that has fewer expressions.
745/// This ensures balanced growth and matches the original's expression counts.
746///
747/// # Algorithm
748///
749/// 1. Start with minimal complexity limits (LHS: 1, RHS: 1)
750/// 2. Generate expressions at current complexity
751/// 3. Track how many expressions were generated for each side
752/// 4. Expand the side with fewer expressions (increase complexity by 1)
753/// 5. Repeat until target expression count is reached
754/// 6. Then perform matching on all generated expressions
755///
756/// # Advantages
757///
758/// - Matches original RIES behavior exactly
759/// - Generates similar number of expressions as original
760/// - Better memory efficiency than generating all at once
761/// - Can leverage parallelization more effectively
762///
763/// # Expression Count Formula
764///
765/// Target expressions = 2000 × 4^(2 + level)
766/// - Level 0: ~32,000 expressions
767/// - Level 1: ~128,000 expressions
768/// - Level 2: ~512,000 expressions
769/// - Level 3: ~2,048,000 expressions
770pub fn search_adaptive(
771    base_config: &crate::gen::GenConfig,
772    search_config: &SearchConfig,
773    level: u32,
774) -> (Vec<Match>, SearchStats) {
775    use crate::expr::EvaluatedExpr;
776    use crate::gen::{quantize_value, LhsKey};
777    use std::collections::HashMap;
778
779    if !search_config.target.is_finite() {
780        return (Vec::new(), SearchStats::default());
781    }
782
783    let gen_start = SearchTimer::start();
784    let context = SearchContext::new(search_config);
785    // Use HashMap so dedup keeps the simplest expression, not first-seen.
786    // With parallel generation the arrival order is non-deterministic, so
787    // first-seen could discard a simpler equivalent expression.
788    let mut lhs_map: HashMap<LhsKey, EvaluatedExpr> = HashMap::new();
789    let mut rhs_map: HashMap<i64, EvaluatedExpr> = HashMap::new();
790
791    // Target expression count: 2000 × 4^(2 + level)
792    // Level 0 ≈ 32 K, level 1 ≈ 128 K, level 2 ≈ 512 K, level 3 ≈ 2 M
793    let target_count = adaptive_target_count(level);
794
795    // Iterative adaptive growth: start at complexity (1, 1) and expand the side
796    // with fewer expressions until the target count is reached.  Each iteration
797    // re-generates all expressions up to the current bounds; the HashMap dedup
798    // ensures only the simplest equivalent expression per key is retained.
799    // Re-generation cost follows a geometric series so the overhead is ≤ 33 %
800    // of the final-iteration cost (sum 1 + 1/4 + 1/16 + … = 4/3).
801    let mut lhs_c: u32 = 1;
802    let mut rhs_c: u32 = 1;
803    const MAX_ADAPTIVE_COMPLEXITY: u32 = 60;
804
805    loop {
806        let mut config = base_config.clone();
807        // Use lhs_c/rhs_c directly — do NOT clamp against base_config bounds.
808        // Clamping would pin the loop to the caller's pre-computed level bounds and
809        // prevent growth from (1, 1), defeating the adaptive algorithm entirely.
810        config.max_lhs_complexity = lhs_c;
811        config.max_rhs_complexity = rhs_c;
812
813        let generated = {
814            #[cfg(feature = "parallel")]
815            {
816                crate::gen::generate_all_parallel_with_context(
817                    &config,
818                    search_config.target,
819                    &context.eval,
820                )
821            }
822            #[cfg(not(feature = "parallel"))]
823            {
824                crate::gen::generate_all_with_context(&config, search_config.target, &context.eval)
825            }
826        };
827
828        for expr in generated.lhs {
829            let key = (quantize_value(expr.value), quantize_value(expr.derivative));
830            lhs_map
831                .entry(key)
832                .and_modify(|existing| {
833                    if expr.expr.complexity() < existing.expr.complexity() {
834                        *existing = expr.clone();
835                    }
836                })
837                .or_insert(expr);
838        }
839        for expr in generated.rhs {
840            let key = quantize_value(expr.value);
841            rhs_map
842                .entry(key)
843                .and_modify(|existing| {
844                    if expr.expr.complexity() < existing.expr.complexity() {
845                        *existing = expr.clone();
846                    }
847                })
848                .or_insert(expr);
849        }
850
851        if lhs_map.len() + rhs_map.len() >= target_count {
852            break;
853        }
854        if lhs_c >= MAX_ADAPTIVE_COMPLEXITY && rhs_c >= MAX_ADAPTIVE_COMPLEXITY {
855            break;
856        }
857        // Expand the side with fewer expressions (original RIES algorithm)
858        if lhs_map.len() <= rhs_map.len() && lhs_c < MAX_ADAPTIVE_COMPLEXITY {
859            lhs_c += 1;
860        } else if rhs_c < MAX_ADAPTIVE_COMPLEXITY {
861            rhs_c += 1;
862        } else {
863            break;
864        }
865    }
866
867    let all_lhs: Vec<EvaluatedExpr> = lhs_map.into_values().collect();
868    let all_rhs: Vec<EvaluatedExpr> = rhs_map.into_values().collect();
869
870    let gen_time = gen_start.elapsed();
871
872    // Now perform the actual matching
873    let mut db = ExprDatabase::new();
874    db.insert_rhs(all_rhs);
875
876    let search_start = SearchTimer::start();
877    let (matches, mut stats) = db.find_matches_with_stats_and_context(&all_lhs, &context);
878    let search_time = search_start.elapsed();
879
880    // Overwrite the generation-side counts for the adaptive wrapper.
881    stats.gen_time = gen_time;
882    stats.search_time = search_time;
883    stats.lhs_count = all_lhs.len();
884    stats.rhs_count = db.rhs_count();
885
886    (matches, stats)
887}
888
889#[inline]
890fn adaptive_target_count(level: u32) -> usize {
891    2000_usize.saturating_mul(4_usize.saturating_pow(level.saturating_add(2)))
892}
893
894// =============================================================================
895// STREAMING SEARCH - Memory-efficient search for high complexity levels
896// =============================================================================
897
898/// Perform a streaming search that processes expressions as they're generated
899///
900/// This is the memory-efficient version of search that builds the RHS database
901/// incrementally. Instead of generating ALL expressions into memory first,
902/// it processes RHS expressions immediately and matches LHS expressions as
903/// they arrive.
904///
905/// # Memory Efficiency
906///
907/// - Traditional: O(expressions) memory - all expressions stored
908/// - Streaming: O(database) memory - only RHS database stored, LHS processed on-the-fly
909///
910/// # When to Use
911///
912/// Use streaming search when:
913/// - Complexity levels are high (L4+, where expressions count > 10M)
914/// - Memory is limited
915/// - You want to see results progressively
916///
917/// # Example
918///
919/// ```no_run
920/// use ries_rs::gen::GenConfig;
921/// use ries_rs::search::search_streaming;
922/// let config = GenConfig::default();
923/// let (matches, stats) = search_streaming(2.5, &config, 100, false, None);
924/// ```
925#[allow(dead_code)]
926pub fn search_streaming(
927    target: f64,
928    gen_config: &crate::gen::GenConfig,
929    max_matches: usize,
930    stop_at_exact: bool,
931    stop_below: Option<f64>,
932) -> (Vec<Match>, SearchStats) {
933    let config = SearchConfig {
934        target,
935        max_matches,
936        stop_at_exact,
937        stop_below,
938        user_constants: gen_config.user_constants.clone(),
939        user_functions: gen_config.user_functions.clone(),
940        ..Default::default()
941    };
942
943    search_streaming_with_config(gen_config, &config)
944}
945
946fn stop_condition_met(m: &Match, search_config: &SearchConfig) -> bool {
947    (search_config.stop_at_exact && m.error.abs() < EXACT_MATCH_TOLERANCE)
948        || search_config
949            .stop_below
950            .is_some_and(|threshold| m.error.abs() < threshold)
951}
952
953fn prefer_streaming_stop_match(candidate: &Match, current: &Match) -> bool {
954    use std::cmp::Ordering;
955
956    candidate
957        .lhs
958        .expr
959        .complexity()
960        .cmp(&current.lhs.expr.complexity())
961        .then_with(|| {
962            candidate
963                .rhs
964                .expr
965                .complexity()
966                .cmp(&current.rhs.expr.complexity())
967        })
968        .then_with(|| {
969            candidate
970                .error
971                .abs()
972                .partial_cmp(&current.error.abs())
973                .unwrap_or(Ordering::Equal)
974        })
975        .then_with(|| {
976            candidate
977                .lhs
978                .expr
979                .to_postfix()
980                .cmp(&current.lhs.expr.to_postfix())
981        })
982        .then_with(|| {
983            candidate
984                .rhs
985                .expr
986                .to_postfix()
987                .cmp(&current.rhs.expr.to_postfix())
988        })
989        .is_lt()
990}
991
992fn merge_priority_match(
993    mut matches: Vec<Match>,
994    priority: Match,
995    max_matches: usize,
996) -> Vec<Match> {
997    if let Some(idx) = matches
998        .iter()
999        .position(|m| m.lhs.expr == priority.lhs.expr && m.rhs.expr == priority.rhs.expr)
1000    {
1001        matches.remove(idx);
1002    }
1003    matches.insert(0, priority);
1004    matches.truncate(max_matches);
1005    matches
1006}
1007
1008/// Perform a streaming search with a fully specified search configuration.
1009///
1010/// Memory model: O(rhs_database) — LHS expressions are matched on-the-fly in a
1011/// second generator pass and never buffered. The only persistent allocation is the
1012/// deduplicated RHS database.
1013pub fn search_streaming_with_config(
1014    gen_config: &crate::gen::GenConfig,
1015    search_config: &SearchConfig,
1016) -> (Vec<Match>, SearchStats) {
1017    use crate::gen::{generate_streaming_with_context, StreamingCallbacks};
1018    use std::collections::HashMap;
1019
1020    if !search_config.target.is_finite() {
1021        return (Vec::new(), SearchStats::default());
1022    }
1023
1024    let gen_start = SearchTimer::start();
1025    let mut stats = SearchStats::new();
1026    let context = SearchContext::new(search_config);
1027
1028    let initial_max_error = search_config.max_error.max(1e-12);
1029    let mut pool = TopKPool::new_with_diagnostics(
1030        search_config.max_matches,
1031        initial_max_error,
1032        search_config.show_db_adds,
1033        search_config.ranking_mode,
1034    );
1035
1036    // === Pass 1: Build RHS database ===
1037    // Pass the original gen_config unchanged so the generator's internal
1038    // `has_rhs_symbol_overrides` branch fires when needed (--S-RHS, --N-RHS, etc.),
1039    // applying the rhs_only_config split.  The on_lhs no-op discards any LHS
1040    // expressions that the generator produces as part of that split.
1041    let mut rhs_db = TieredExprDatabase::new();
1042    let mut rhs_map: HashMap<i64, crate::expr::EvaluatedExpr> = HashMap::new();
1043
1044    {
1045        let mut callbacks = StreamingCallbacks {
1046            on_rhs: &mut |expr| {
1047                let key = crate::gen::quantize_value(expr.value);
1048                rhs_map
1049                    .entry(key)
1050                    .and_modify(|existing| {
1051                        if expr.expr.complexity() < existing.expr.complexity() {
1052                            *existing = expr.clone();
1053                        }
1054                    })
1055                    .or_insert_with(|| expr.clone());
1056                true
1057            },
1058            on_lhs: &mut |_| true,
1059        };
1060        generate_streaming_with_context(
1061            gen_config,
1062            search_config.target,
1063            &context.eval,
1064            &mut callbacks,
1065        );
1066    }
1067
1068    for expr in rhs_map.into_values() {
1069        rhs_db.insert(expr);
1070    }
1071    rhs_db.finalize();
1072    stats.rhs_count = rhs_db.total_count();
1073    stats.gen_time = gen_start.elapsed();
1074
1075    // === Pass 2: Stream LHS and match on-the-fly ===
1076    //
1077    // Streaming must keep O(rhs_db) memory even in classic/exact modes, so we never
1078    // buffer the full LHS set. stop_at_exact / stop_below therefore cannot perform a
1079    // literal early exit here because generator order is not complexity ordered.
1080    //
1081    // Instead, we remember the best qualifying match seen so far under a
1082    // complexity-first ordering and splice it to the front of the final results.
1083    // That preserves the "simplest acceptable match first" behavior without
1084    // reintroducing O(all_lhs) memory usage.
1085    let mut lhs_gen_config = gen_config.clone();
1086    lhs_gen_config.generate_rhs = false;
1087
1088    let search_start = SearchTimer::start();
1089    let mut best_stop_match: Option<Match> = None;
1090
1091    {
1092        let mut callbacks = StreamingCallbacks {
1093            on_rhs: &mut |_| true,
1094            on_lhs: &mut |lhs| {
1095                stats.lhs_count += 1;
1096
1097                // Skip LHS with value too close to 0
1098                if lhs.value.abs() < search_config.zero_value_threshold {
1099                    if search_config.show_pruned_range {
1100                        eprintln!(
1101                            "  [pruned range] value={:.6e} reason=\"near-zero\" expr=\"{}\"",
1102                            lhs.value,
1103                            lhs.expr.to_infix()
1104                        );
1105                    }
1106                    return true;
1107                }
1108
1109                // Skip degenerate expressions
1110                if should_skip_degenerate_lhs(lhs, search_config.target, &context.eval) {
1111                    return true;
1112                }
1113                if lhs.derivative.abs() < DEGENERATE_TEST_THRESHOLD {
1114                    stats.lhs_tested += 1;
1115                    stats.record_candidate_window(
1116                        lhs,
1117                        rhs_db.count_in_range(
1118                            lhs.value - DEGENERATE_RANGE_TOLERANCE,
1119                            lhs.value + DEGENERATE_RANGE_TOLERANCE,
1120                        ),
1121                    );
1122                    for rhs in rhs_db.iter_tiers_in_range(
1123                        lhs.value - DEGENERATE_RANGE_TOLERANCE,
1124                        lhs.value + DEGENERATE_RANGE_TOLERANCE,
1125                    ) {
1126                        if !search_config.rhs_symbol_allowed(&rhs.expr) {
1127                            continue;
1128                        }
1129                        stats.candidates_tested += 1;
1130                        if search_config.show_match_checks {
1131                            eprintln!(
1132                                "  [match] checking lhs={:.6} rhs={:.6}",
1133                                lhs.value, rhs.value
1134                            );
1135                        }
1136                        let val_diff = (lhs.value - rhs.value).abs();
1137                        if val_diff < DEGENERATE_RANGE_TOLERANCE && pool.would_accept(0.0, true) {
1138                            let m = Match {
1139                                lhs: lhs.clone(),
1140                                rhs: rhs.clone(),
1141                                x_value: search_config.target,
1142                                error: 0.0,
1143                                complexity: lhs.expr.complexity() + rhs.expr.complexity(),
1144                            };
1145                            if stop_condition_met(&m, search_config)
1146                                && best_stop_match
1147                                    .as_ref()
1148                                    .is_none_or(|current| prefer_streaming_stop_match(&m, current))
1149                            {
1150                                best_stop_match = Some(m.clone());
1151                            }
1152                            pool.try_insert(m);
1153                        }
1154                    }
1155                    return true;
1156                }
1157
1158                stats.lhs_tested += 1;
1159
1160                let search_radius = calculate_adaptive_search_radius(
1161                    lhs.derivative,
1162                    lhs.expr.complexity(),
1163                    pool.len(),
1164                    search_config.max_matches,
1165                    pool.best_error,
1166                    pool.accept_error,
1167                );
1168                let low = lhs.value - search_radius;
1169                let high = lhs.value + search_radius;
1170                stats.record_candidate_window(lhs, rhs_db.count_in_range(low, high));
1171
1172                for rhs in rhs_db.iter_tiers_in_range(low, high) {
1173                    if !search_config.rhs_symbol_allowed(&rhs.expr) {
1174                        continue;
1175                    }
1176                    stats.candidates_tested += 1;
1177                    if search_config.show_match_checks {
1178                        eprintln!(
1179                            "  [match] checking lhs={:.6} rhs={:.6}",
1180                            lhs.value, rhs.value
1181                        );
1182                    }
1183
1184                    let val_diff = lhs.value - rhs.value;
1185                    let x_delta = -val_diff / lhs.derivative;
1186                    let coarse_error = x_delta.abs();
1187
1188                    let is_potentially_exact = coarse_error < NEWTON_FINAL_TOLERANCE;
1189                    if !pool.would_accept_strict(coarse_error, is_potentially_exact) {
1190                        stats.strict_gate_rejections += 1;
1191                        continue;
1192                    }
1193
1194                    if !search_config.refine_with_newton {
1195                        let refined_x = search_config.target + x_delta;
1196                        let refined_error = x_delta;
1197                        let is_exact = refined_error.abs() < EXACT_MATCH_TOLERANCE;
1198
1199                        if pool.would_accept(refined_error.abs(), is_exact) {
1200                            let m = Match {
1201                                lhs: lhs.clone(),
1202                                rhs: rhs.clone(),
1203                                x_value: refined_x,
1204                                error: refined_error,
1205                                complexity: lhs.expr.complexity() + rhs.expr.complexity(),
1206                            };
1207                            if stop_condition_met(&m, search_config)
1208                                && best_stop_match
1209                                    .as_ref()
1210                                    .is_none_or(|current| prefer_streaming_stop_match(&m, current))
1211                            {
1212                                best_stop_match = Some(m.clone());
1213                            }
1214                            pool.try_insert(m);
1215                        }
1216                        continue;
1217                    }
1218
1219                    stats.newton_calls += 1;
1220                    let initial_guess = search_config.target + x_delta;
1221                    if let Some(refined_x) = newton_raphson_with_constants(
1222                        &lhs.expr,
1223                        rhs.value,
1224                        initial_guess,
1225                        search_config.newton_iterations,
1226                        &context.eval,
1227                        search_config.show_newton,
1228                        search_config.derivative_margin,
1229                    ) {
1230                        stats.newton_success += 1;
1231                        let refined_error = refined_x - search_config.target;
1232                        let is_exact = refined_error.abs() < EXACT_MATCH_TOLERANCE;
1233
1234                        if pool.would_accept(refined_error.abs(), is_exact) {
1235                            let m = Match {
1236                                lhs: lhs.clone(),
1237                                rhs: rhs.clone(),
1238                                x_value: refined_x,
1239                                error: refined_error,
1240                                complexity: lhs.expr.complexity() + rhs.expr.complexity(),
1241                            };
1242                            if stop_condition_met(&m, search_config)
1243                                && best_stop_match
1244                                    .as_ref()
1245                                    .is_none_or(|current| prefer_streaming_stop_match(&m, current))
1246                            {
1247                                best_stop_match = Some(m.clone());
1248                            }
1249                            pool.try_insert(m);
1250                        }
1251                    }
1252                }
1253                true
1254            },
1255        };
1256        generate_streaming_with_context(
1257            &lhs_gen_config,
1258            search_config.target,
1259            &context.eval,
1260            &mut callbacks,
1261        );
1262    }
1263
1264    stats.pool_insertions = pool.stats.insertions;
1265    stats.pool_rejections_error = pool.stats.rejections_error;
1266    stats.pool_rejections_dedupe = pool.stats.rejections_dedupe;
1267    stats.pool_evictions = pool.stats.evictions;
1268    stats.pool_final_size = pool.len();
1269    stats.pool_best_error = pool.best_error;
1270    stats.search_time = search_start.elapsed();
1271    stats.early_exit = false;
1272
1273    let matches = pool.into_sorted();
1274    let matches = if let Some(priority_match) = best_stop_match {
1275        merge_priority_match(matches, priority_match, search_config.max_matches)
1276    } else {
1277        matches
1278    };
1279
1280    (matches, stats)
1281}
1282
1283/// Perform one-sided search: generate RHS expressions only and match `x = RHS`.
1284pub fn search_one_sided_with_stats_and_config(
1285    gen_config: &crate::gen::GenConfig,
1286    config: &SearchConfig,
1287) -> (Vec<Match>, SearchStats) {
1288    use crate::eval::evaluate_with_context;
1289    use crate::expr::Expression;
1290    use crate::gen::generate_all_with_context;
1291    use crate::symbol::Symbol;
1292
1293    let gen_start = SearchTimer::start();
1294    let context = SearchContext::new(config);
1295
1296    let mut rhs_only = gen_config.clone();
1297    rhs_only.generate_lhs = false;
1298    rhs_only.generate_rhs = true;
1299
1300    let generated = generate_all_with_context(&rhs_only, config.target, &context.eval);
1301    let gen_time = gen_start.elapsed();
1302
1303    let search_start = SearchTimer::start();
1304    let initial_max_error = config.max_error.max(1e-12);
1305    let mut pool = TopKPool::new_with_diagnostics(
1306        config.max_matches,
1307        initial_max_error,
1308        config.show_db_adds,
1309        config.ranking_mode,
1310    );
1311    let mut stats = SearchStats::new();
1312    let mut early_exit = false;
1313
1314    let mut lhs_expr = Expression::new();
1315    lhs_expr.push_with_table(Symbol::X, &gen_config.symbol_table);
1316    let lhs_eval = evaluate_with_context(&lhs_expr, config.target, &context.eval);
1317    let lhs_eval = match lhs_eval {
1318        Ok(v) => v,
1319        Err(_) => {
1320            stats.gen_time = gen_time;
1321            stats.search_time = search_start.elapsed();
1322            return (Vec::new(), stats);
1323        }
1324    };
1325    let lhs = EvaluatedExpr::new(
1326        lhs_expr,
1327        lhs_eval.value,
1328        lhs_eval.derivative,
1329        lhs_eval.num_type,
1330    );
1331
1332    stats.lhs_count = 1;
1333    stats.rhs_count = generated.rhs.len();
1334    stats.lhs_tested = 1;
1335
1336    for rhs in generated.rhs {
1337        if !config.rhs_symbol_allowed(&rhs.expr) {
1338            continue;
1339        }
1340        stats.candidates_tested += 1;
1341        if config.show_match_checks {
1342            eprintln!(
1343                "  [match] checking lhs={:.6} rhs={:.6}",
1344                lhs.value, rhs.value
1345            );
1346        }
1347
1348        let error = rhs.value - config.target;
1349        let is_exact = error.abs() < EXACT_MATCH_TOLERANCE;
1350        if !pool.would_accept(error.abs(), is_exact) {
1351            continue;
1352        }
1353
1354        let m = Match {
1355            lhs: lhs.clone(),
1356            rhs: rhs.clone(),
1357            x_value: rhs.value,
1358            error,
1359            complexity: lhs.expr.complexity() + rhs.expr.complexity(),
1360        };
1361
1362        pool.try_insert(m);
1363
1364        if config.stop_at_exact && is_exact {
1365            early_exit = true;
1366            break;
1367        }
1368        if let Some(threshold) = config.stop_below {
1369            if error.abs() < threshold {
1370                early_exit = true;
1371                break;
1372            }
1373        }
1374    }
1375
1376    stats.pool_insertions = pool.stats.insertions;
1377    stats.pool_rejections_error = pool.stats.rejections_error;
1378    stats.pool_rejections_dedupe = pool.stats.rejections_dedupe;
1379    stats.pool_evictions = pool.stats.evictions;
1380    stats.pool_final_size = pool.len();
1381    stats.pool_best_error = pool.best_error;
1382    stats.gen_time = gen_time;
1383    stats.search_time = search_start.elapsed();
1384    stats.early_exit = early_exit;
1385
1386    (pool.into_sorted(), stats)
1387}
1388
1389/// Perform a search whose generation phase uses Rayon.
1390///
1391/// This function is part of the public API for library consumers who want
1392/// parallel generation without statistics collection. Matching/Newton remains
1393/// the same serial batch matcher used by the sequential path.
1394#[cfg(feature = "parallel")]
1395#[allow(dead_code)]
1396pub fn search_parallel(
1397    target: f64,
1398    gen_config: &crate::gen::GenConfig,
1399    max_matches: usize,
1400) -> Vec<Match> {
1401    let (matches, _stats) = search_parallel_with_stats(target, gen_config, max_matches);
1402    matches
1403}
1404
1405/// Perform a search with parallel generation and statistics collection.
1406///
1407/// This function is part of the public API for library consumers who want
1408/// detailed statistics about the search process. Matching/Newton remains serial.
1409#[cfg(feature = "parallel")]
1410#[allow(dead_code)]
1411pub fn search_parallel_with_stats(
1412    target: f64,
1413    gen_config: &crate::gen::GenConfig,
1414    max_matches: usize,
1415) -> (Vec<Match>, SearchStats) {
1416    search_parallel_with_stats_and_options(target, gen_config, max_matches, false, None)
1417}
1418
1419/// Perform a parallel search with statistics collection and early exit options
1420#[cfg(feature = "parallel")]
1421pub fn search_parallel_with_stats_and_options(
1422    target: f64,
1423    gen_config: &crate::gen::GenConfig,
1424    max_matches: usize,
1425    stop_at_exact: bool,
1426    stop_below: Option<f64>,
1427) -> (Vec<Match>, SearchStats) {
1428    let config = SearchConfig {
1429        target,
1430        max_matches,
1431        stop_at_exact,
1432        stop_below,
1433        user_constants: gen_config.user_constants.clone(),
1434        user_functions: gen_config.user_functions.clone(),
1435        ..Default::default()
1436    };
1437
1438    search_parallel_with_stats_and_config(gen_config, &config)
1439}
1440
1441/// Perform a search with parallel generation and a fully specified search configuration.
1442///
1443/// Generation uses Rayon for parallelism, while RHS database construction and
1444/// LHS/RHS matching/Newton remain the same serial batch pipeline used by the
1445/// sequential path. A sequential count-only OOM-safety check runs first: if it
1446/// estimates the expression space would exceed ~2M entries the function falls
1447/// back to streaming mode automatically.
1448#[cfg(feature = "parallel")]
1449pub fn search_parallel_with_stats_and_config(
1450    gen_config: &crate::gen::GenConfig,
1451    config: &SearchConfig,
1452) -> (Vec<Match>, SearchStats) {
1453    use crate::gen::{
1454        count_expressions_with_limit_and_context, generate_all_parallel_with_context,
1455    };
1456
1457    if !config.target.is_finite() {
1458        return (Vec::new(), SearchStats::default());
1459    }
1460
1461    // Parallel generation does not preserve the sequential expression order.
1462    // A first-hit stop would therefore select a different match even though
1463    // this mode promises byte-identical results. Use the sequential pipeline
1464    // whenever the requested result depends on discovery order.
1465    if config.stop_at_exact || config.stop_below.is_some() {
1466        return search_with_stats_and_config(gen_config, config);
1467    }
1468
1469    const MAX_EXPRESSIONS_BEFORE_STREAMING: usize = 2_000_000;
1470    let context = SearchContext::new(config);
1471
1472    // OOM-safety gate: do a sequential count-only preflight to check whether the
1473    // search space fits in memory before committing to the parallel run.
1474    if count_expressions_with_limit_and_context(
1475        gen_config,
1476        config.target,
1477        &context.eval,
1478        MAX_EXPRESSIONS_BEFORE_STREAMING,
1479    )
1480    .is_none()
1481    {
1482        return search_streaming_with_config(gen_config, config);
1483    }
1484
1485    // Within the limit — generate in parallel using Rayon.
1486    let gen_start = SearchTimer::start();
1487    let generated = generate_all_parallel_with_context(gen_config, config.target, &context.eval);
1488    let gen_time = gen_start.elapsed();
1489
1490    let mut db = ExprDatabase::new();
1491    db.insert_rhs(generated.rhs);
1492
1493    let (matches, mut stats) = db.find_matches_with_stats_and_context(&generated.lhs, &context);
1494    stats.gen_time = gen_time;
1495    stats.lhs_count = generated.lhs.len();
1496    stats.rhs_count = db.rhs_count();
1497
1498    (matches, stats)
1499}
1500
1501/// Turbo search: canonical generation plus a parallel match/Newton phase.
1502///
1503/// This trades the parallel path's byte-identical full-output guarantee for
1504/// throughput. It returns the same single best (rank-1) match as serial search (see
1505/// [`ExprDatabase::find_matches_turbo_with_stats_and_context`] for why), but the
1506/// lower-ranked tail may differ from serial and may vary with thread count.
1507/// Turbo also tolerates a larger materialized expression set than the serial /
1508/// parallel paths before falling back to streaming, trading memory for speed.
1509#[cfg(feature = "parallel")]
1510pub fn search_turbo_with_stats_and_config(
1511    gen_config: &crate::gen::GenConfig,
1512    config: &SearchConfig,
1513) -> (Vec<Match>, SearchStats) {
1514    use crate::gen::{
1515        count_expressions_with_limit_and_context,
1516        generate_all_preserving_lhs_with_limit_and_context, generate_all_with_limit_and_context,
1517    };
1518
1519    if !config.target.is_finite() {
1520        return (Vec::new(), SearchStats::default());
1521    }
1522
1523    // A first-hit stopping condition is inherently order-dependent. Letting
1524    // each turbo band stop at its own first qualifying match can skip a better
1525    // match in that band (or in another band), violating turbo's rank-1 parity
1526    // guarantee. Preserve both the requested stopping semantics and canonical
1527    // result ordering by using the parallel-generation, serial-matching search
1528    // for these configurations.
1529    if config.stop_at_exact || config.stop_below.is_some() {
1530        return search_parallel_with_stats_and_config(gen_config, config);
1531    }
1532
1533    // Turbo trades memory for speed, so it tolerates a much larger materialized
1534    // expression set than the default batch path before falling back to the
1535    // serial streaming matcher. (The serial/parallel paths stream above ~2M to
1536    // bound memory; turbo's whole purpose is to use more resources.)
1537    const SERIAL_BATCH_LIMIT: usize = 2_000_000;
1538    const MAX_EXPRESSIONS_BEFORE_STREAMING: usize = 64_000_000;
1539    let context = SearchContext::new(config);
1540
1541    let gen_start = SearchTimer::start();
1542    let generated = if count_expressions_with_limit_and_context(
1543        gen_config,
1544        config.target,
1545        &context.eval,
1546        SERIAL_BATCH_LIMIT,
1547    )
1548    .is_some()
1549    {
1550        generate_all_with_limit_and_context(
1551            gen_config,
1552            config.target,
1553            &context.eval,
1554            SERIAL_BATCH_LIMIT,
1555        )
1556        .expect("preflight count established serial batch generation is within limit")
1557    } else {
1558        let Some(generated) = generate_all_preserving_lhs_with_limit_and_context(
1559            gen_config,
1560            config.target,
1561            &context.eval,
1562            MAX_EXPRESSIONS_BEFORE_STREAMING,
1563        ) else {
1564            return search_streaming_with_config(gen_config, config);
1565        };
1566        generated
1567    };
1568    let gen_time = gen_start.elapsed();
1569
1570    let mut db = ExprDatabase::new();
1571    db.insert_rhs(generated.rhs);
1572
1573    let (matches, mut stats) =
1574        db.find_matches_turbo_with_stats_and_context(&generated.lhs, &context);
1575    stats.gen_time = gen_time;
1576    stats.lhs_count = generated.lhs.len();
1577    stats.rhs_count = db.rhs_count();
1578
1579    (matches, stats)
1580}