ries 2.0.2

Find algebraic equations given their solution - Rust implementation
Documentation
//! WebAssembly bindings for ries-rs
//!
//! This module provides WASM bindings using wasm-bindgen, allowing ries-rs
//! to be used from JavaScript/TypeScript in browsers and Node.js.
//!
//! # Installation
//!
//! ```bash
//! npm install ries-rs
//! ```
//!
//! # Usage
//!
//! ```javascript
//! import { search, WasmMatch, listPresets, version } from 'ries-rs';
//!
//! // Simple search
//! const results = search(3.1415926535);
//! for (const m of results) {
//!   console.log(`${m.lhs} = ${m.rhs} (error: ${m.error.toExponential(2)})`);
//! }
//!
//! // With options
//! const results = search(1.618033988, {
//!   level: 3,
//!   maxMatches: 20,
//!   preset: 'physics'
//! });
//! ```

use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

const MAX_API_LEVEL: u32 = 5;
const MAX_API_MATCHES: usize = 10_000;

/// A matched equation from the search
#[wasm_bindgen]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WasmMatch {
    /// Left-hand side expression (contains x)
    #[wasm_bindgen(getter_with_clone)]
    pub lhs: String,
    /// Right-hand side expression (constants only)
    #[wasm_bindgen(getter_with_clone)]
    pub rhs: String,
    /// Postfix representation of LHS
    #[wasm_bindgen(getter_with_clone)]
    pub lhs_postfix: String,
    /// Postfix representation of RHS
    #[wasm_bindgen(getter_with_clone)]
    pub rhs_postfix: String,
    /// Solved x = expression (if analytically solvable)
    #[wasm_bindgen(getter_with_clone)]
    pub solve_for_x: Option<String>,
    /// Solved x = expression in postfix
    #[wasm_bindgen(getter_with_clone)]
    pub solve_for_x_postfix: Option<String>,
    /// Canonical key for deduplication
    #[wasm_bindgen(getter_with_clone)]
    pub canonical_key: String,
    /// Solved value of x
    pub x_value: f64,
    /// Error (x_value - target)
    pub error: f64,
    /// Complexity score
    pub complexity: u32,
    /// Number of operators in equation
    pub operator_count: usize,
    /// Maximum tree depth of equation
    pub tree_depth: usize,
    /// Whether this is an exact match
    pub is_exact: bool,
}

impl From<crate::search::Match> for WasmMatch {
    fn from(m: crate::search::Match) -> Self {
        Self::from(&crate::MatchSummary::from(m))
    }
}

impl From<&crate::MatchSummary> for WasmMatch {
    fn from(summary: &crate::MatchSummary) -> Self {
        Self {
            lhs: summary.lhs.clone(),
            rhs: summary.rhs.clone(),
            lhs_postfix: summary.lhs_postfix.clone(),
            rhs_postfix: summary.rhs_postfix.clone(),
            solve_for_x: summary.solve_for_x.clone(),
            solve_for_x_postfix: summary.solve_for_x_postfix.clone(),
            canonical_key: summary.canonical_key.clone(),
            x_value: summary.x_value,
            error: summary.error,
            complexity: summary.complexity,
            operator_count: summary.operator_count,
            tree_depth: summary.tree_depth,
            is_exact: summary.is_exact,
        }
    }
}

#[wasm_bindgen]
impl WasmMatch {
    /// Get a string representation
    #[allow(clippy::inherent_to_string)]
    pub fn to_string(&self) -> String {
        format!(
            "{} = {}  [error: {:.2e}] {{{}}}",
            self.lhs, self.rhs, self.error, self.complexity
        )
    }

    /// Convert to a plain JavaScript object
    pub fn to_json(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(self).map_err(|e| JsValue::from_str(&e.to_string()))
    }
}

/// Search options for WASM
#[wasm_bindgen]
#[derive(Clone, Debug, Serialize)]
pub struct SearchOptions {
    /// Search level (0-5). Higher = more expressions searched
    pub level: u32,
    /// Maximum number of matches to return
    pub max_matches: usize,
    /// Domain preset name
    #[wasm_bindgen(getter_with_clone)]
    pub preset: Option<String>,
}

#[wasm_bindgen]
impl SearchOptions {
    /// Create default search options
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        Self {
            level: 2,
            max_matches: 16,
            preset: None,
        }
    }

    /// Set the search level
    pub fn level(mut self, level: u32) -> Self {
        self.level = level;
        self
    }

    /// Set the maximum number of matches
    pub fn max_matches(mut self, max_matches: usize) -> Self {
        self.max_matches = max_matches;
        self
    }

    /// Set the domain preset
    pub fn preset(mut self, preset: String) -> Self {
        self.preset = Some(preset);
        self
    }

    /// Convert to a plain JavaScript object (for passing to search() or serialization)
    pub fn to_json(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(self).map_err(|e| JsValue::from_str(&e.to_string()))
    }
}

impl Default for SearchOptions {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
struct SearchOptionsInput {
    level: u32,
    #[serde(rename = "maxMatches", alias = "max_matches")]
    max_matches: usize,
    preset: Option<String>,
    #[serde(rename = "rankingMode", alias = "ranking_mode")]
    ranking_mode: Option<String>,
    #[serde(rename = "matchAllDigits", alias = "match_all_digits")]
    match_all_digits: bool,
    #[serde(rename = "usePslq", alias = "use_pslq")]
    use_pslq: bool,
}

impl Default for SearchOptionsInput {
    fn default() -> Self {
        Self {
            level: 2,
            max_matches: 16,
            preset: None,
            ranking_mode: None,
            match_all_digits: false,
            use_pslq: false,
        }
    }
}

fn parse_search_options(options: Option<JsValue>) -> Result<SearchOptionsInput, JsValue> {
    match options {
        None => Ok(SearchOptionsInput::default()),
        Some(value) if value.is_null() || value.is_undefined() => Ok(SearchOptionsInput::default()),
        Some(value) => serde_wasm_bindgen::from_value(value)
            .map_err(|e| JsValue::from_str(&format!("Invalid search options: {}", e))),
    }
}

fn parse_ranking_mode(value: Option<&str>) -> Result<crate::pool::RankingMode, JsValue> {
    match value.unwrap_or("complexity") {
        "complexity" => Ok(crate::pool::RankingMode::Complexity),
        "parity" => Ok(crate::pool::RankingMode::Parity),
        other => Err(JsValue::from_str(&format!(
            "Unknown rankingMode '{}'. Supported values: 'complexity', 'parity'.",
            other
        ))),
    }
}

fn compute_significant_digits_tolerance(target: f64) -> f64 {
    if target == 0.0 {
        return 1e-15;
    }

    let target_str = format!("{:.15}", target);
    let trimmed = target_str.trim_end_matches('0');
    let digits_after_decimal = trimmed
        .find('.')
        .map(|pos| trimmed.len().saturating_sub(pos + 1))
        .unwrap_or(0);

    (0.5 * 10_f64.powi(-(digits_after_decimal as i32))).max(1e-15)
}

/// Build a GenConfig from simple parameters
fn build_gen_config(
    max_lhs_complexity: u32,
    max_rhs_complexity: u32,
    profile: &crate::profile::Profile,
) -> Result<crate::gen::GenConfig, JsValue> {
    crate::gen::build_gen_config_from_profile(max_lhs_complexity, max_rhs_complexity, profile)
        .map_err(|e| JsValue::from_str(&e))
}

/// Search for algebraic equations given a target value
///
/// @param target - The target value to find equations for
/// @param options - Search options (level, maxMatches, preset)
/// @returns Array of WasmMatch objects sorted by error
///
/// @example
/// ```javascript
/// const results = search(3.14159);
/// console.log(results[0].lhs); // "x"
/// console.log(results[0].rhs); // "pi"
/// ```
#[wasm_bindgen]
pub fn search(target: f64, options: Option<JsValue>) -> Result<Vec<WasmMatch>, JsValue> {
    if !target.is_finite() {
        return Err(JsValue::from_str(
            "target must be a finite number (not NaN or Infinity)",
        ));
    }

    let opts = parse_search_options(options)?;
    if opts.use_pslq {
        return Err(JsValue::from_str(
            "usePslq is not supported in the WebAssembly build yet.",
        ));
    }
    if opts.level > MAX_API_LEVEL {
        return Err(JsValue::from_str(&format!(
            "Invalid level {}. Supported range is 0..={}.",
            opts.level, MAX_API_LEVEL
        )));
    }
    if opts.max_matches > MAX_API_MATCHES {
        return Err(JsValue::from_str(&format!(
            "maxMatches {} is too large. Maximum supported value is {}.",
            opts.max_matches, MAX_API_MATCHES
        )));
    }
    let internal_max_matches = opts
        .max_matches
        .checked_mul(2)
        .ok_or_else(|| JsValue::from_str("maxMatches is too large"))?;
    let ranking_mode = parse_ranking_mode(opts.ranking_mode.as_deref())?;

    // Use the standard level-to-complexity mapping
    let (max_lhs_complexity, max_rhs_complexity) = crate::search::level_to_complexity(opts.level);

    let mut profile = crate::profile::Profile::new();
    if let Some(preset_name) = opts.preset.as_deref() {
        let parsed = crate::presets::Preset::from_str(preset_name).ok_or_else(|| {
            JsValue::from_str(&format!(
                "Unknown preset '{}'. Use listPresets() for available options.",
                preset_name
            ))
        })?;
        profile = profile
            .merge(parsed.to_profile())
            .map_err(|e| JsValue::from_str(&e.to_string()))?;
    }

    // Build generation config
    let gen_config = build_gen_config(max_lhs_complexity, max_rhs_complexity, &profile)?;

    // Build search config
    let max_error = if opts.match_all_digits {
        compute_significant_digits_tolerance(target)
    } else {
        (target.abs() * 0.01).max(1e-12)
    };
    let search_config = crate::search::SearchConfig {
        target,
        max_matches: internal_max_matches,
        max_error,
        stop_at_exact: false,
        stop_below: None,
        zero_value_threshold: 1e-4,
        newton_iterations: 15,
        user_constants: gen_config.user_constants.clone(),
        user_functions: gen_config.user_functions.clone(),
        trig_argument_scale: crate::eval::DEFAULT_TRIG_ARGUMENT_SCALE,
        refine_with_newton: true,
        rhs_allowed_symbols: None,
        rhs_excluded_symbols: None,
        show_newton: false,
        show_match_checks: false,
        show_pruned_arith: false,
        show_pruned_range: false,
        show_db_adds: false,
        match_all_digits: opts.match_all_digits,
        derivative_margin: crate::thresholds::DEGENERATE_DERIVATIVE,
        ranking_mode,
    };

    // Perform search: parallel when wasm-threads (wasm-bindgen-rayon), else sequential
    let (matches, _stats) = {
        #[cfg(feature = "wasm-threads")]
        {
            crate::search::search_parallel_with_stats_and_config(&gen_config, &search_config)
        }
        #[cfg(not(feature = "wasm-threads"))]
        {
            crate::search::search_with_stats_and_config(&gen_config, &search_config)
        }
    };

    // Convert to WasmMatch and limit to max_matches
    Ok(matches
        .into_iter()
        .take(opts.max_matches)
        .map(WasmMatch::from)
        .collect())
}

/// Get list of available domain presets
///
/// @returns Object mapping preset names to descriptions
#[wasm_bindgen(js_name = listPresets)]
pub fn list_presets() -> Result<JsValue, JsValue> {
    let presets: std::collections::BTreeMap<String, String> = crate::presets::Preset::all()
        .iter()
        .map(|p| (p.name().to_string(), p.description().to_string()))
        .collect();

    serde_wasm_bindgen::to_value(&presets).map_err(|e| JsValue::from_str(&e.to_string()))
}

#[wasm_bindgen(js_name = list_presets)]
pub fn list_presets_compat() -> Result<JsValue, JsValue> {
    list_presets()
}

/// Get version information
///
/// @returns Version string
#[wasm_bindgen]
pub fn version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

/// Initialize the WASM module (call this before using other functions)
#[wasm_bindgen]
pub fn init() {
    // Set up panic hook for better error messages in browser console
    console_error_panic_hook::set_once();
}

// Re-export for threaded WASM build. JS must call initThreadPool(n) after init().
// The symbol is consumed by JavaScript, not by Rust code, so suppress the lint.
#[cfg(feature = "wasm-threads")]
#[allow(unused_imports)]
pub use wasm_bindgen_rayon::init_thread_pool;