Skip to main content

ries_rs/
report.rs

1//! Report generation for categorized match output
2//!
3//! Selects top-K matches per category and formats output.
4
5use crate::expr::{Expression, OutputFormat};
6use crate::metrics::{MatchMetrics, OperatorFrequency};
7use crate::pool::{LhsKey, SignatureKey};
8use crate::search::Match;
9use crate::symbol::Symbol;
10use std::collections::HashSet;
11
12/// Display format for expressions (matches main.rs DisplayFormat)
13#[derive(Debug, Clone, Copy)]
14pub enum DisplayFormat {
15    /// Infix with optional format variant
16    Infix(OutputFormat),
17    /// Compact postfix (like "52/")
18    PostfixCompact,
19    /// Verbose postfix (like "5 2 /")
20    PostfixVerbose,
21    /// Alias for PostfixCompact (-F1)
22    Condensed,
23}
24
25/// Format an expression for display using the specified format
26fn format_expression_for_display(expression: &Expression, format: DisplayFormat) -> String {
27    match format {
28        DisplayFormat::Infix(inner) => expression.to_infix_with_format(inner),
29        DisplayFormat::PostfixCompact | DisplayFormat::Condensed => expression.to_postfix(),
30        DisplayFormat::PostfixVerbose => expression
31            .symbols()
32            .iter()
33            .map(|sym| postfix_verbose_token(*sym))
34            .collect::<Vec<_>>()
35            .join(" "),
36    }
37}
38
39fn postfix_verbose_token(sym: Symbol) -> String {
40    use Symbol;
41    match sym {
42        Symbol::Neg => "neg".to_string(),
43        Symbol::Recip => "recip".to_string(),
44        Symbol::Sqrt => "sqrt".to_string(),
45        Symbol::Square => "dup*".to_string(),
46        Symbol::Pow => "**".to_string(),
47        Symbol::Root => "root".to_string(),
48        Symbol::Log => "logn".to_string(),
49        Symbol::Exp => "exp".to_string(),
50        _ => sym.display_name(),
51    }
52}
53
54/// Report categories
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Category {
57    /// Exact matches (error < 1e-14)
58    Exact,
59    /// Best approximations (lowest error)
60    Best,
61    /// Elegant/efficient (lowest complexity)
62    Elegant,
63    /// Interesting/unexpected (high novelty)
64    Interesting,
65    /// Stable/robust (good conditioning)
66    Stable,
67}
68
69impl Category {
70    pub fn name(&self) -> &'static str {
71        match self {
72            Category::Exact => "Exact matches",
73            Category::Best => "Best approximations",
74            Category::Elegant => "Elegant/efficient",
75            Category::Interesting => "Interesting/unexpected",
76            Category::Stable => "Stable/robust",
77        }
78    }
79
80    /// Get the description for this category
81    ///
82    /// This method is part of the public API for library consumers who want
83    /// to display category descriptions in their output formatting.
84    #[allow(dead_code)]
85    pub fn description(&self) -> &'static str {
86        match self {
87            Category::Exact => "Equations that hold exactly at the target value",
88            Category::Best => "Closest approximations to the target",
89            Category::Elegant => "Simplest expressions with good accuracy",
90            Category::Interesting => "Novel or unusual equation structures",
91            Category::Stable => "Matches with robust numerical properties",
92        }
93    }
94}
95
96/// A categorized report of matches
97pub struct Report {
98    /// Top matches per category
99    pub categories: Vec<(Category, Vec<MatchWithMetrics>)>,
100    /// Target value
101    pub target: f64,
102}
103
104/// Match with computed metrics
105pub struct MatchWithMetrics {
106    pub m: Match,
107    pub metrics: MatchMetrics,
108}
109
110/// Configuration for report generation
111#[derive(Clone)]
112pub struct ReportConfig {
113    /// Number of matches per category
114    pub top_k: usize,
115    /// Which categories to include
116    pub categories: Vec<Category>,
117    /// Error cap for "interesting" category
118    pub interesting_error_cap: f64,
119}
120
121impl Default for ReportConfig {
122    fn default() -> Self {
123        Self {
124            top_k: 8,
125            categories: vec![
126                Category::Exact,
127                Category::Best,
128                Category::Elegant,
129                Category::Interesting,
130                Category::Stable,
131            ],
132            interesting_error_cap: 1e-6,
133        }
134    }
135}
136
137impl ReportConfig {
138    /// Create config with all categories (including stable)
139    ///
140    /// This method is part of the public API for library consumers who want
141    /// to ensure the stability category is included in their reports.
142    #[allow(dead_code)]
143    pub fn with_stable(mut self) -> Self {
144        if !self.categories.contains(&Category::Stable) {
145            self.categories.push(Category::Stable);
146        }
147        self
148    }
149
150    /// Remove stability category
151    pub fn without_stable(mut self) -> Self {
152        self.categories.retain(|c| *c != Category::Stable);
153        self
154    }
155
156    /// Set top-K
157    pub fn with_top_k(mut self, k: usize) -> Self {
158        self.top_k = k;
159        self
160    }
161
162    /// Set interesting error cap based on target
163    pub fn with_target(mut self, target: f64) -> Self {
164        // Scale error cap with target magnitude
165        self.interesting_error_cap = (1e-8_f64).max(1e-6 * target.abs());
166        self
167    }
168}
169
170impl Report {
171    /// Generate a report from a pool of matches
172    pub fn generate(matches: Vec<Match>, target: f64, config: &ReportConfig) -> Self {
173        // Build frequency map for novelty scoring
174        let mut freq_map = OperatorFrequency::new();
175        for m in &matches {
176            freq_map.add(m);
177        }
178
179        // Compute metrics for all matches
180        let mut with_metrics: Vec<MatchWithMetrics> = matches
181            .into_iter()
182            .map(|m| {
183                let metrics = MatchMetrics::from_match(&m, Some(&freq_map));
184                MatchWithMetrics { m, metrics }
185            })
186            .collect();
187
188        // Generate each category
189        let mut categories = Vec::new();
190
191        for &cat in &config.categories {
192            let selected = select_category(&mut with_metrics, cat, config);
193            categories.push((cat, selected));
194        }
195
196        Report { categories, target }
197    }
198
199    /// Print the report to stdout
200    pub fn print(&self, absolute: bool, solve: bool, format: DisplayFormat) {
201        for (category, matches) in &self.categories {
202            if matches.is_empty() {
203                continue;
204            }
205
206            println!();
207            println!("  -- {} ({}) --", category.name(), matches.len());
208            println!();
209
210            for mwm in matches {
211                print_match(&mwm.m, &mwm.metrics, self.target, absolute, solve, format);
212            }
213        }
214    }
215}
216
217/// Select top-K matches for a category
218fn select_category(
219    matches: &mut [MatchWithMetrics],
220    category: Category,
221    config: &ReportConfig,
222) -> Vec<MatchWithMetrics> {
223    // Filter and sort based on category
224    let mut candidates: Vec<_> = matches.iter().collect();
225
226    // Filter
227    candidates.retain(|mwm| category_filter(mwm, category, config));
228
229    // Sort (best first)
230    candidates.sort_by(|a, b| category_compare(a, b, category, config));
231
232    // Dedupe based on category
233    let mut result = Vec::new();
234    let mut seen_lhs: HashSet<LhsKey> = HashSet::new();
235    let mut seen_sig: HashSet<SignatureKey> = HashSet::new();
236
237    for mwm in candidates {
238        if result.len() >= config.top_k {
239            break;
240        }
241
242        // Category-specific dedupe
243        let accept = match category {
244            Category::Exact => {
245                // Dedupe by full equation (allow multiple forms)
246                true
247            }
248            Category::Best | Category::Elegant => {
249                // Dedupe by LHS (one match per LHS)
250                let lhs_key = LhsKey::from_match(&mwm.m);
251                if seen_lhs.contains(&lhs_key) {
252                    false
253                } else {
254                    seen_lhs.insert(lhs_key);
255                    true
256                }
257            }
258            Category::Interesting => {
259                // Dedupe by signature (force variety)
260                let sig_key = SignatureKey::from_match(&mwm.m);
261                if seen_sig.contains(&sig_key) {
262                    false
263                } else {
264                    seen_sig.insert(sig_key);
265                    true
266                }
267            }
268            Category::Stable => {
269                // Dedupe by LHS
270                let lhs_key = LhsKey::from_match(&mwm.m);
271                if seen_lhs.contains(&lhs_key) {
272                    false
273                } else {
274                    seen_lhs.insert(lhs_key);
275                    true
276                }
277            }
278        };
279
280        if accept {
281            result.push(mwm.clone());
282        }
283    }
284
285    result
286}
287
288/// Filter for category membership
289fn category_filter(mwm: &MatchWithMetrics, category: Category, config: &ReportConfig) -> bool {
290    match category {
291        Category::Exact => mwm.metrics.is_exact,
292        Category::Best => !mwm.metrics.is_exact, // Non-exact only (exact are in Exact)
293        Category::Elegant => true,               // All matches eligible
294        Category::Interesting => {
295            mwm.metrics.error <= config.interesting_error_cap && !mwm.metrics.is_exact
296        }
297        Category::Stable => mwm.metrics.stability > 0.3, // Reasonable conditioning
298    }
299}
300
301/// Compare for category ranking (return Ordering for sort)
302fn category_compare(
303    a: &MatchWithMetrics,
304    b: &MatchWithMetrics,
305    category: Category,
306    config: &ReportConfig,
307) -> std::cmp::Ordering {
308    use std::cmp::Ordering;
309
310    match category {
311        Category::Exact => {
312            // Sort by complexity, then by equation length (shorter first)
313            a.metrics
314                .complexity
315                .cmp(&b.metrics.complexity)
316                .then_with(|| {
317                    (a.m.lhs.expr.len() + a.m.rhs.expr.len())
318                        .cmp(&(b.m.lhs.expr.len() + b.m.rhs.expr.len()))
319                })
320        }
321        Category::Best => {
322            // Sort by error (lower first)
323            a.metrics
324                .error
325                .partial_cmp(&b.metrics.error)
326                .unwrap_or(Ordering::Equal)
327        }
328        Category::Elegant => {
329            // Sort by elegant score (lower first)
330            a.metrics
331                .elegant_score()
332                .partial_cmp(&b.metrics.elegant_score())
333                .unwrap_or(Ordering::Equal)
334        }
335        Category::Interesting => {
336            // Sort by interesting score (higher first)
337            b.metrics
338                .interesting_score(config.interesting_error_cap)
339                .partial_cmp(&a.metrics.interesting_score(config.interesting_error_cap))
340                .unwrap_or(Ordering::Equal)
341        }
342        Category::Stable => {
343            // Sort by stability score (higher first), then by error
344            b.metrics
345                .stable_score()
346                .partial_cmp(&a.metrics.stable_score())
347                .unwrap_or(Ordering::Equal)
348                .then_with(|| {
349                    a.metrics
350                        .error
351                        .partial_cmp(&b.metrics.error)
352                        .unwrap_or(Ordering::Equal)
353                })
354        }
355    }
356}
357
358/// Clone implementation for MatchWithMetrics
359impl Clone for MatchWithMetrics {
360    fn clone(&self) -> Self {
361        Self {
362            m: self.m.clone(),
363            metrics: self.metrics.clone(),
364        }
365    }
366}
367
368/// Print a single match
369fn print_match(
370    m: &Match,
371    metrics: &MatchMetrics,
372    _target: f64,
373    absolute: bool,
374    solve: bool,
375    format: DisplayFormat,
376) {
377    let lhs_str = format_expression_for_display(&m.lhs.expr, format);
378    let rhs_str = format_expression_for_display(&m.rhs.expr, format);
379
380    let error_str = if metrics.is_exact {
381        "('exact' match)".to_string()
382    } else if absolute {
383        format!("for x = {:.15}", m.x_value)
384    } else {
385        let sign = if m.error >= 0.0 { "+" } else { "-" };
386        format!("for x = T {} {:.6e}", sign, m.error.abs())
387    };
388
389    // Compact info string
390    let info = format!("{{{}}}", m.complexity);
391
392    if solve {
393        println!("     x = {:40} {} {}", rhs_str, error_str, info);
394    } else {
395        println!("{:>24} = {:<24} {} {}", lhs_str, rhs_str, error_str, info);
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::expr::{EvaluatedExpr, Expression};
403    use crate::symbol::NumType;
404
405    fn make_match(lhs: &str, rhs: &str, error: f64) -> Match {
406        let lhs_expr = Expression::parse(lhs).unwrap();
407        let rhs_expr = Expression::parse(rhs).unwrap();
408        Match {
409            lhs: EvaluatedExpr::new(lhs_expr.clone(), 0.0, 1.0, NumType::Integer),
410            rhs: EvaluatedExpr::new(rhs_expr.clone(), 0.0, 0.0, NumType::Integer),
411            x_value: 2.5,
412            error,
413            complexity: lhs_expr.complexity() + rhs_expr.complexity(),
414        }
415    }
416
417    #[test]
418    fn test_report_generation() {
419        let matches = vec![
420            make_match("2x*", "5", 0.0),      // Exact
421            make_match("xx^", "ps", 0.00066), // Interesting
422            make_match("x1+", "35/", 1e-10),  // Best approx
423        ];
424
425        let config = ReportConfig::default().with_target(2.5);
426        let report = Report::generate(matches, 2.5, &config);
427
428        // Should have entries in multiple categories
429        assert!(!report.categories.is_empty());
430    }
431}