Skip to main content

phop_wasm/
lib.rs

1//! # phop-wasm — WebAssembly bindings for phop
2//!
3//! Exposes the [`phop-core`](phop_core) discovery engine to JavaScript via
4//! `wasm-bindgen`. The single entry point [`discover_json`] takes JSON-encoded data
5//! and configuration, runs discovery, and returns a JSON-encoded Pareto front, making
6//! it trivial to call from a browser without any custom marshalling.
7//!
8//! ```ignore
9//! import init, { discover_json, set_panic_hook } from "./pkg/phop_wasm.js";
10//! await init();
11//! set_panic_hook();
12//! const data = JSON.stringify({ x: [[1],[2],[3]], y: [2,4,6] });
13//! const cfg  = JSON.stringify({ population: 64, max_epochs: 50, seed: 0 });
14//! const out  = JSON.parse(discover_json(data, cfg));
15//! console.log(out.solutions);
16//! ```
17
18use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::*;
20
21use phop_core::{AnySolution, Config, DataSet, Discoverer, Solution};
22use scirs2_core::ndarray::{Array1, Array2};
23
24/// Input dataset: a row-major matrix `x` and a target vector `y`.
25#[derive(Debug, Deserialize)]
26struct DataJson {
27    /// Feature matrix, `rows × n_vars`.
28    x: Vec<Vec<f64>>,
29    /// Target vector, length `rows`.
30    y: Vec<f64>,
31}
32
33/// Optional discovery hyperparameters. Missing fields fall back to defaults.
34#[derive(Debug, Default, Deserialize)]
35struct ConfigJson {
36    /// Population size (number of candidate trees).
37    population: Option<usize>,
38    /// Maximum tree depth.
39    max_depth: Option<usize>,
40    /// Maximum optimization epochs.
41    max_epochs: Option<usize>,
42    /// Gradient-descent learning rate.
43    learning_rate: Option<f64>,
44    /// RNG seed.
45    seed: Option<u64>,
46    /// Number of top solutions to report.
47    top_k: Option<usize>,
48    /// When `true`, include a symbolic CAS analysis (derivative + antiderivative w.r.t. x0) of
49    /// each returned solution — the in-browser "discover → analyze" path.
50    analyze: Option<bool>,
51}
52
53/// One solution in the returned Pareto front.
54#[derive(Debug, Serialize)]
55struct SolutionJson {
56    /// LaTeX rendering.
57    latex: String,
58    /// Human-readable infix rendering.
59    pretty: String,
60    /// Mean squared error.
61    mse: f64,
62    /// Structural complexity (node count).
63    complexity: usize,
64    /// `d/dx0` of the law (only when `analyze` was requested).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    derivative: Option<String>,
67    /// An antiderivative `∫ · dx0` (only when `analyze` was requested and a closed form exists).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    antiderivative: Option<String>,
70}
71
72/// The full result payload returned by [`discover_json`].
73#[derive(Debug, Serialize)]
74struct ResultJson {
75    /// Top Pareto-optimal solutions, best first.
76    solutions: Vec<SolutionJson>,
77}
78
79/// JSON error envelope returned when discovery fails.
80#[derive(Debug, Serialize)]
81struct ErrorJson {
82    /// Human-readable error message.
83    error: String,
84}
85
86/// Install [`console_error_panic_hook`] so Rust panics surface as readable
87/// `console.error` messages in the browser.
88#[wasm_bindgen]
89pub fn set_panic_hook() {
90    console_error_panic_hook::set_once();
91}
92
93/// Run symbolic discovery from JSON.
94///
95/// `data_json` must decode to `{ "x": [[..],..], "y": [..] }` and `config_json` to an
96/// object with optional `population`, `max_depth`, `max_epochs`, `learning_rate`,
97/// `seed`, and `top_k`. Returns a JSON string `{ "solutions": [ ... ] }` on success,
98/// or `{ "error": "..." }` on failure.
99#[wasm_bindgen]
100pub fn discover_json(data_json: &str, config_json: &str) -> String {
101    match run(data_json, config_json) {
102        Ok(result) => serde_json::to_string(&result)
103            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
104        Err(msg) => serde_json::to_string(&ErrorJson { error: msg })
105            .unwrap_or_else(|_| "{\"error\":\"unknown error\"}".to_string()),
106    }
107}
108
109/// Internal worker that performs the discovery and maps every failure to a `String`.
110fn run(data_json: &str, config_json: &str) -> Result<ResultJson, String> {
111    let data: DataJson = serde_json::from_str(data_json).map_err(|e| e.to_string())?;
112    let cfg_json: ConfigJson = if config_json.trim().is_empty() {
113        ConfigJson::default()
114    } else {
115        serde_json::from_str(config_json).map_err(|e| e.to_string())?
116    };
117
118    let rows = data.x.len();
119    if rows == 0 {
120        return Err("`x` must contain at least one row".to_string());
121    }
122    let cols = data.x[0].len();
123    if data.x.iter().any(|r| r.len() != cols) {
124        return Err("all rows of `x` must have the same length".to_string());
125    }
126    let flat: Vec<f64> = data.x.into_iter().flatten().collect();
127    let x_arr = Array2::from_shape_vec((rows, cols), flat).map_err(|e| e.to_string())?;
128    let y_arr = Array1::from(data.y);
129
130    let ds = DataSet::from_arrays(x_arr, y_arr).map_err(|e| e.to_string())?;
131
132    let mut config = Config::default();
133    if let Some(v) = cfg_json.population {
134        config = config.population(v);
135    }
136    if let Some(v) = cfg_json.max_depth {
137        config = config.max_depth(v);
138    }
139    if let Some(v) = cfg_json.max_epochs {
140        config = config.max_epochs(v);
141    }
142    if let Some(v) = cfg_json.learning_rate {
143        config = config.learning_rate(v);
144    }
145    if let Some(v) = cfg_json.seed {
146        config = config.seed(v);
147    }
148    let top_k = cfg_json.top_k.unwrap_or(config.top_k);
149    if let Some(v) = cfg_json.top_k {
150        config = config.top_k(v);
151    }
152
153    let front = Discoverer::new(config)
154        .fit(&ds)
155        .map_err(|e| e.to_string())?;
156
157    let analyze = cfg_json.analyze.unwrap_or(false);
158    let solutions = front
159        .pareto_top(top_k)
160        .into_iter()
161        .map(|s| {
162            let (derivative, antiderivative) = if analyze {
163                let a = s.analyze(0, 4);
164                (Some(a.derivative), a.antiderivative)
165            } else {
166                (None, None)
167            };
168            SolutionJson {
169                latex: s.latex(),
170                pretty: s.pretty(),
171                mse: s.mse,
172                complexity: s.complexity,
173                derivative,
174                antiderivative,
175            }
176        })
177        .collect();
178
179    Ok(ResultJson { solutions })
180}
181
182// ===================== Browser-complete verified pipeline =====================
183//
184// `discover_and_verify` runs the WHOLE pipeline client-side: discover a law, then (optionally)
185// canonicalize it (e-graph), enclose its range and search for a certified root (interval
186// Newton/Krawczyk), reduce dimensioned inputs to Buckingham-π groups, and **prove** properties —
187// that the law has no root over the data box, or that it is exactly equivalent to a supplied target
188// law — via the OxiZ SMT solver. Every tier is pure Rust compiled to wasm; no server is contacted.
189
190/// Per-feature SI dimension: 7 integer exponents `[T, L, M, Θ, I, N, J]`.
191type DimVec = Vec<i32>;
192
193/// Extended configuration for [`discover_and_verify`].
194#[derive(Debug, Default, Deserialize)]
195struct VerifyConfigJson {
196    /// Population / candidate budget.
197    population: Option<usize>,
198    /// Maximum tree depth (also bounds the rich-leaf eml-skeleton size).
199    max_depth: Option<usize>,
200    /// Maximum optimization epochs.
201    max_epochs: Option<usize>,
202    /// Gradient-descent learning rate.
203    learning_rate: Option<f64>,
204    /// RNG seed.
205    seed: Option<u64>,
206    /// Number of top solutions to report.
207    top_k: Option<usize>,
208    /// Search method: `"enumerate"` (default, fast/exact-shallow), `"auto"` (EML + rich-leaf
209    /// ensemble — recovers products/powers), or `"rich"` (affine/log-linear leaves only).
210    method: Option<String>,
211    /// Add a CAS derivative + antiderivative w.r.t. x0 (always available).
212    analyze: Option<bool>,
213    /// Add a stronger e-graph canonical LaTeX (requires the `egraph` feature).
214    canonical: Option<bool>,
215    /// Add a certified interval range enclosure + certified root search (always available).
216    certify: Option<bool>,
217    /// Prove (SMT) that the best law has no root over the data box (requires the `smt` feature).
218    prove_no_root: Option<bool>,
219    /// Optional per-feature SI dimensions; when present, the inputs are reduced to dimensionless
220    /// Buckingham-π groups before discovery and the groups are returned in [`VerifiedResultJson`].
221    units: Option<Vec<DimVec>>,
222    /// Optional serialized EML target law (from `Solution::to_model_json`); when present, the best
223    /// EML law is **proved equivalent** to it over the data box via SMT (requires `smt`).
224    target_model: Option<String>,
225}
226
227/// A discovered law plus every verification artifact that was requested and is supported.
228#[derive(Debug, Serialize)]
229struct VerifiedSolutionJson {
230    /// Producing engine: `"eml"` (differentiable core) or `"affine"` (rich-leaf).
231    source: String,
232    /// LaTeX rendering.
233    latex: String,
234    /// Human-readable infix rendering.
235    pretty: String,
236    /// Mean squared error on the (possibly reduced) data.
237    mse: f64,
238    /// Coefficient of determination on the data.
239    r2: f64,
240    /// Structural complexity.
241    complexity: usize,
242    /// Whether this is a clean symbolic recovery (affine engine) / exact EML form.
243    symbolic: bool,
244    /// `d/dx0` of the law (when `analyze`).
245    #[serde(skip_serializing_if = "Option::is_none")]
246    derivative: Option<String>,
247    /// An antiderivative `∫ · dx0` (when `analyze` and a closed form exists).
248    #[serde(skip_serializing_if = "Option::is_none")]
249    antiderivative: Option<String>,
250    /// Stronger e-graph canonical LaTeX (when `canonical` and the `egraph` feature is built).
251    #[serde(skip_serializing_if = "Option::is_none")]
252    canonical_latex: Option<String>,
253    /// Guaranteed range enclosure `[lo, hi]` of the law over the data box (when `certify`).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    certified_range: Option<[f64; 2]>,
256    /// Certified root search along x0 over the data box (when `certify`).
257    #[serde(skip_serializing_if = "Option::is_none")]
258    certified_root: Option<String>,
259    /// SMT verdict: does the law have no root over the data box? (when `prove_no_root` + `smt`).
260    #[serde(skip_serializing_if = "Option::is_none")]
261    proven_no_root: Option<String>,
262    /// SMT verdict: is the law exactly equivalent to the supplied target over the box? (when
263    /// `target_model` + `smt`).
264    #[serde(skip_serializing_if = "Option::is_none")]
265    equivalent_to_target: Option<String>,
266}
267
268/// Which verification tiers this particular wasm build supports (which features were compiled in).
269#[derive(Debug, Serialize)]
270struct Capabilities {
271    /// CAS analysis (always).
272    analyze: bool,
273    /// Interval certified range/root (always).
274    certify: bool,
275    /// E-graph canonicalization (`egraph` feature).
276    canonical: bool,
277    /// SMT proofs via OxiZ (`smt` feature).
278    smt: bool,
279}
280
281/// The full payload from [`discover_and_verify`].
282#[derive(Debug, Serialize)]
283struct VerifiedResultJson {
284    /// Top solutions, best first, each with its verification artifacts.
285    solutions: Vec<VerifiedSolutionJson>,
286    /// Buckingham-π group exponent vectors, when `units` were supplied.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pi_groups: Option<Vec<DimVec>>,
289    /// The verification tiers this build supports.
290    capabilities: Capabilities,
291}
292
293/// Report which verification tiers this wasm build supports — lets the UI light up only what is
294/// actually available (e.g. hide the SMT panel when built without `smt`).
295#[wasm_bindgen]
296pub fn capabilities() -> String {
297    let caps = Capabilities {
298        analyze: true,
299        certify: true,
300        canonical: cfg!(feature = "egraph"),
301        smt: cfg!(feature = "smt"),
302    };
303    serde_json::to_string(&caps).unwrap_or_else(|_| "{}".to_string())
304}
305
306/// Run the full discover→verify pipeline from JSON, entirely in-browser.
307///
308/// `data_json` decodes to `{ "x": [[..],..], "y": [..] }`; `config_json` to a `VerifyConfigJson`.
309/// Returns a JSON `VerifiedResultJson` on success, or `{ "error": "..." }` on failure.
310#[wasm_bindgen]
311pub fn discover_and_verify(data_json: &str, config_json: &str) -> String {
312    match verify_run(data_json, config_json) {
313        Ok(result) => serde_json::to_string(&result)
314            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
315        Err(msg) => serde_json::to_string(&ErrorJson { error: msg })
316            .unwrap_or_else(|_| "{\"error\":\"unknown error\"}".to_string()),
317    }
318}
319
320/// Coefficient of determination of `pred` vs `y`.
321fn r2_of(pred: &Array1<f64>, y: &Array1<f64>) -> f64 {
322    let n = y.len().max(1) as f64;
323    let mean = y.sum() / n;
324    let (mut sr, mut st) = (0.0, 0.0);
325    for (p, t) in pred.iter().zip(y.iter()) {
326        sr += (t - p) * (t - p);
327        st += (t - mean) * (t - mean);
328    }
329    if st == 0.0 {
330        f64::NAN
331    } else {
332        1.0 - sr / st
333    }
334}
335
336/// Per-feature `(min, max)` bounding box of the dataset — the domain over which range/root/proof
337/// verification is performed.
338fn data_box(ds: &DataSet) -> Vec<(f64, f64)> {
339    (0..ds.n_vars())
340        .map(|j| {
341            let col = ds.x.column(j);
342            (
343                col.iter().copied().fold(f64::INFINITY, f64::min),
344                col.iter().copied().fold(f64::NEG_INFINITY, f64::max),
345            )
346        })
347        .collect()
348}
349
350/// E-graph canonical LaTeX — `Some` only when the `egraph` feature is built in.
351fn canonical_of(_s: &Solution) -> Option<String> {
352    #[cfg(feature = "egraph")]
353    {
354        Some(_s.latex_egraph())
355    }
356    #[cfg(not(feature = "egraph"))]
357    {
358        None
359    }
360}
361
362/// SMT verdict that `_s` has no root over `_bounds` — `Some` only when the `smt` feature is built in.
363fn prove_no_root_of(_s: &Solution, _bounds: &[(f64, f64)]) -> Option<String> {
364    #[cfg(feature = "smt")]
365    {
366        Some(format!("{:?}", _s.prove_no_root(_bounds)))
367    }
368    #[cfg(not(feature = "smt"))]
369    {
370        None
371    }
372}
373
374/// SMT verdict that `_s` is equivalent to `_target` over `_bounds` — `Some` only with `smt`.
375fn prove_equiv_of(_s: &Solution, _target: &Solution, _bounds: &[(f64, f64)]) -> Option<String> {
376    #[cfg(feature = "smt")]
377    {
378        Some(format!("{:?}", _s.prove_equivalent(_target, _bounds)))
379    }
380    #[cfg(not(feature = "smt"))]
381    {
382        None
383    }
384}
385
386/// Internal worker for [`discover_and_verify`].
387fn verify_run(data_json: &str, config_json: &str) -> Result<VerifiedResultJson, String> {
388    let data: DataJson = serde_json::from_str(data_json).map_err(|e| e.to_string())?;
389    let cfg_json: VerifyConfigJson = if config_json.trim().is_empty() {
390        VerifyConfigJson::default()
391    } else {
392        serde_json::from_str(config_json).map_err(|e| e.to_string())?
393    };
394
395    let rows = data.x.len();
396    if rows == 0 {
397        return Err("`x` must contain at least one row".to_string());
398    }
399    let cols = data.x[0].len();
400    if data.x.iter().any(|r| r.len() != cols) {
401        return Err("all rows of `x` must have the same length".to_string());
402    }
403    let flat: Vec<f64> = data.x.into_iter().flatten().collect();
404    let x_arr = Array2::from_shape_vec((rows, cols), flat).map_err(|e| e.to_string())?;
405    let y_arr = Array1::from(data.y);
406    let mut ds = DataSet::from_arrays(x_arr, y_arr).map_err(|e| e.to_string())?;
407
408    // Optional dimensional reduction to Buckingham-π groups.
409    let mut pi_groups: Option<Vec<DimVec>> = None;
410    if let Some(units) = &cfg_json.units {
411        let dims: Vec<phop_core::Dimension> = units
412            .iter()
413            .map(|v| {
414                <[i32; 7]>::try_from(v.clone())
415                    .map_err(|_| "each `units` entry needs exactly 7 integer exponents".to_string())
416            })
417            .collect::<Result<_, _>>()?;
418        let (reduced, groups) = ds.to_dimensionless(&dims).map_err(|e| e.to_string())?;
419        ds = reduced;
420        pi_groups = Some(groups);
421    }
422
423    let mut config = Config::default();
424    if let Some(v) = cfg_json.population {
425        config = config.population(v);
426    }
427    if let Some(v) = cfg_json.max_depth {
428        config = config.max_depth(v);
429    }
430    if let Some(v) = cfg_json.max_epochs {
431        config = config.max_epochs(v);
432    }
433    if let Some(v) = cfg_json.learning_rate {
434        config = config.learning_rate(v);
435    }
436    if let Some(v) = cfg_json.seed {
437        config = config.seed(v);
438    }
439    let top_k = cfg_json.top_k.unwrap_or(config.top_k);
440    config = config.top_k(top_k);
441
442    let max_internal = cfg_json.max_depth.unwrap_or(3).clamp(1, 5);
443    const WASM_CAND_CAP: usize = 800;
444    let front: Vec<AnySolution> = match cfg_json.method.as_deref() {
445        Some("rich") => {
446            phop_core::discover_affine_pareto(&ds.x, &ds.y, max_internal, WASM_CAND_CAP)
447                .into_iter()
448                .map(AnySolution::Affine)
449                .collect()
450        }
451        Some("auto") => phop_core::discover_auto_all(&ds, &config, max_internal, WASM_CAND_CAP)
452            .map_err(|e| e.to_string())?,
453        _ => Discoverer::new(config)
454            .fit(&ds)
455            .map_err(|e| e.to_string())?
456            .solutions
457            .into_iter()
458            .map(AnySolution::Eml)
459            .collect(),
460    };
461
462    // Score by R² on the data, then DISPLAY-order for the demo: drop near-trivial baselines (the
463    // R²≈0 constant that otherwise gets the full proof ceremony), and surface the most accurate, then
464    // SIMPLEST, then lowest-MSE solution first (Occam's razor among equally-accurate fits — so a clean
465    // depth-1 law ranks above a higher-complexity "bush" that happens to shave 1e-31 off the MSE).
466    let mut scored: Vec<(AnySolution, f64)> = front
467        .into_iter()
468        .map(|s| {
469            let r2 = s
470                .predict(&ds.x)
471                .ok()
472                .map(|p| r2_of(&p, &ds.y))
473                .unwrap_or(f64::NAN);
474            (s, r2)
475        })
476        .collect();
477    const MIN_DISPLAY_R2: f64 = 0.5;
478    if scored
479        .iter()
480        .any(|(_, r2)| r2.is_finite() && *r2 >= MIN_DISPLAY_R2)
481    {
482        scored.retain(|(_, r2)| r2.is_finite() && *r2 >= MIN_DISPLAY_R2);
483    }
484    scored.sort_by(|a, b| {
485        // Bucket R² so machine-precision ties (≈1.0) fall through to complexity, not raw MSE.
486        let (ra, rb) = ((a.1 * 1e6).round(), (b.1 * 1e6).round());
487        rb.partial_cmp(&ra)
488            .unwrap_or(std::cmp::Ordering::Equal)
489            .then(a.0.complexity().cmp(&b.0.complexity()))
490            .then(
491                a.0.mse()
492                    .partial_cmp(&b.0.mse())
493                    .unwrap_or(std::cmp::Ordering::Equal),
494            )
495    });
496    scored.truncate(top_k);
497
498    let analyze = cfg_json.analyze.unwrap_or(false);
499    let canonical = cfg_json.canonical.unwrap_or(false);
500    let certify = cfg_json.certify.unwrap_or(false);
501    let prove_no_root = cfg_json.prove_no_root.unwrap_or(false);
502    let bounds = data_box(&ds);
503    let target: Option<Solution> = match &cfg_json.target_model {
504        Some(m) => Some(Solution::from_model_json(m).map_err(|e| e.to_string())?),
505        None => None,
506    };
507
508    let solutions = scored
509        .iter()
510        .map(|(s, r2)| {
511            let r2 = *r2;
512            let mut out = VerifiedSolutionJson {
513                source: s.source().to_string(),
514                latex: s.latex(),
515                pretty: s.expr(),
516                mse: s.mse(),
517                r2,
518                complexity: s.complexity(),
519                symbolic: s.is_symbolic(),
520                derivative: None,
521                antiderivative: None,
522                canonical_latex: None,
523                certified_range: None,
524                certified_root: None,
525                proven_no_root: None,
526                equivalent_to_target: None,
527            };
528            // The CAS / certified / SMT tiers operate on EML-tree laws only.
529            if let Some(e) = s.as_eml() {
530                if analyze {
531                    let a = e.analyze(0, 4);
532                    out.derivative = Some(a.derivative);
533                    out.antiderivative = a.antiderivative;
534                }
535                if canonical {
536                    out.canonical_latex = canonical_of(e);
537                }
538                if certify {
539                    let (lo, hi) = e.certified_range(&bounds);
540                    out.certified_range = Some([lo, hi]);
541                    if let Some(&(x0lo, x0hi)) = bounds.first() {
542                        let others: Vec<f64> =
543                            bounds.iter().skip(1).map(|(a, b)| 0.5 * (a + b)).collect();
544                        out.certified_root = Some(match e.certified_root(0, &others, x0lo, x0hi) {
545                            Ok(cert) => format!("{cert:?}"),
546                            Err(err) => format!("error: {err}"),
547                        });
548                    }
549                }
550                if prove_no_root {
551                    out.proven_no_root = prove_no_root_of(e, &bounds);
552                }
553                if let Some(t) = &target {
554                    out.equivalent_to_target = prove_equiv_of(e, t, &bounds);
555                }
556            }
557            out
558        })
559        .collect();
560
561    Ok(VerifiedResultJson {
562        solutions,
563        pi_groups,
564        capabilities: Capabilities {
565            analyze: true,
566            certify: true,
567            canonical: cfg!(feature = "egraph"),
568            smt: cfg!(feature = "smt"),
569        },
570    })
571}
572
573/// Native (host-runnable) unit tests.
574///
575/// `discover_json` is plain Rust with no JS interop on its hot path, so it can be
576/// exercised directly on the host. These tests verify both the success and error
577/// envelopes end to end.
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    /// Build a JSON dataset for `y = exp(x)` sampled on a small grid.
583    fn exp_dataset_json() -> String {
584        let xs: Vec<f64> = (0..21).map(|i| i as f64 * 0.1).collect();
585        let x: Vec<Vec<f64>> = xs.iter().map(|&v| vec![v]).collect();
586        let y: Vec<f64> = xs.iter().map(|&v| v.exp()).collect();
587        serde_json::to_string(&serde_json::json!({ "x": x, "y": y })).expect("dataset serializes")
588    }
589
590    #[test]
591    fn discover_json_recovers_exp() {
592        let data = exp_dataset_json();
593        let cfg = serde_json::to_string(&serde_json::json!({
594            "max_epochs": 100,
595            "max_depth": 1,
596            "seed": 0,
597            "top_k": 3,
598        }))
599        .expect("config serializes");
600
601        let out = discover_json(&data, &cfg);
602        assert!(
603            !out.contains("\"error\""),
604            "discovery should not return an error envelope, got: {out}"
605        );
606
607        let parsed: serde_json::Value =
608            serde_json::from_str(&out).expect("result must be valid JSON");
609        let obj = parsed.as_object().expect("result must be a JSON object");
610        let solutions = obj
611            .get("solutions")
612            .and_then(|v| v.as_array())
613            .expect("result must contain a `solutions` array");
614        assert!(
615            !solutions.is_empty(),
616            "the Pareto front must contain at least one solution"
617        );
618
619        let mse = solutions[0]
620            .get("mse")
621            .and_then(|v| v.as_f64())
622            .expect("first solution must report a numeric `mse`");
623        assert!(
624            mse < 0.5,
625            "best solution MSE should be small for y = exp(x), got {mse}"
626        );
627    }
628
629    #[test]
630    fn discover_json_includes_analysis_when_requested() {
631        // The in-browser "discover → analyze" path: with `analyze: true`, each solution carries a
632        // symbolic derivative computed via the oxieml CAS, entirely client-side.
633        let data = exp_dataset_json();
634        let cfg = serde_json::to_string(&serde_json::json!({
635            "max_epochs": 100, "max_depth": 1, "seed": 0, "top_k": 1, "analyze": true,
636        }))
637        .expect("config serializes");
638
639        let out = discover_json(&data, &cfg);
640        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
641        let first = parsed["solutions"][0]
642            .as_object()
643            .expect("a first solution object");
644        assert!(
645            first.contains_key("derivative"),
646            "analyze=true must add a `derivative` field, got: {out}"
647        );
648    }
649
650    #[test]
651    fn verify_pipeline_runs_and_certifies() {
652        // The full client-side pipeline: discover exp(x), attach a CAS derivative, and a CERTIFIED
653        // interval range enclosure — all without the smt/egraph features.
654        let data = exp_dataset_json();
655        let cfg = serde_json::json!({
656            "method": "enumerate", "max_epochs": 120, "max_depth": 1, "seed": 0, "top_k": 1,
657            "analyze": true, "certify": true,
658        })
659        .to_string();
660        let out = discover_and_verify(&data, &cfg);
661        assert!(!out.contains("\"error\""), "pipeline errored: {out}");
662        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
663        let s0 = &v["solutions"][0];
664        assert!(
665            s0["certified_range"].is_array(),
666            "certify must add a certified_range, got: {out}"
667        );
668        assert!(
669            s0["derivative"].is_string(),
670            "analyze must add a derivative"
671        );
672        let r2 = s0["r2"].as_f64().expect("r2 present");
673        assert!(r2 > 0.9, "exp fit should be accurate, r2 = {r2}");
674        assert!(v["capabilities"]["certify"].as_bool().unwrap_or(false));
675    }
676
677    #[cfg(feature = "smt")]
678    #[test]
679    fn verify_pipeline_proves_no_root_with_smt() {
680        // exp(x) > 0 everywhere → no root over the box. The OxiZ-backed SMT verdict must be present
681        // and the build must advertise the smt capability.
682        let data = exp_dataset_json();
683        let cfg = serde_json::json!({
684            "method": "enumerate", "max_epochs": 120, "max_depth": 1, "seed": 0, "top_k": 1,
685            "prove_no_root": true,
686        })
687        .to_string();
688        let out = discover_and_verify(&data, &cfg);
689        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
690        assert!(
691            v["solutions"][0]["proven_no_root"].is_string(),
692            "smt build must add a proven_no_root verdict, got: {out}"
693        );
694        assert!(v["capabilities"]["smt"].as_bool().unwrap_or(false));
695    }
696
697    #[cfg(feature = "smt")]
698    #[test]
699    fn verify_proves_equivalence_after_integer_snap() {
700        // Regression for the demo bug: at max_depth 3 the constant in eml(x0, c) fit to ~1.0000000438,
701        // leaving an ln(c)≠0 residue that made ≡-true-law return a Counterexample. Integer snapping
702        // must round it to exactly 1 so the law equals exp(x0) and SMT proves equivalence to the
703        // true model. (Also exercises the R²-filter dropping the trivial constant baseline.)
704        let data = exp_dataset_json();
705        let cfg = serde_json::json!({
706            "method": "enumerate", "max_depth": 3, "max_epochs": 200, "seed": 0, "top_k": 1,
707            "target_model": "{\"root\":{\"Eml\":{\"left\":{\"Var\":0},\"right\":\"One\"}},\"num_vars\":1}",
708        })
709        .to_string();
710        let out = discover_and_verify(&data, &cfg);
711        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
712        let s0 = &v["solutions"][0];
713        assert_eq!(
714            s0["equivalent_to_target"].as_str(),
715            Some("Proven"),
716            "≡ true law must be Proven after integer snapping, got: {out}"
717        );
718        assert_eq!(
719            s0["symbolic"].as_bool(),
720            Some(true),
721            "best law should be symbolic"
722        );
723    }
724
725    #[test]
726    fn discover_json_reports_malformed_input() {
727        let out = discover_json("{ this is not valid json", "{}");
728        let parsed: serde_json::Value =
729            serde_json::from_str(&out).expect("error envelope must itself be valid JSON");
730        let obj = parsed
731            .as_object()
732            .expect("error envelope must be an object");
733        assert!(
734            obj.contains_key("error"),
735            "malformed input must yield an `error` field, got: {out}"
736        );
737    }
738}
739
740/// WebAssembly smoke test.
741///
742/// This module is compiled only for `wasm32` targets and is exercised with
743/// `wasm-pack test` / `wasm-bindgen-test`. It does not run on the host but
744/// documents and enables the in-browser test path required by the TODO.
745#[cfg(target_arch = "wasm32")]
746#[cfg(test)]
747mod wasm_tests {
748    use super::*;
749    use wasm_bindgen_test::*;
750
751    wasm_bindgen_test_configure!(run_in_browser);
752
753    #[wasm_bindgen_test]
754    fn discover_json_runs_in_wasm() {
755        let data =
756            r#"{ "x": [[0.0],[0.1],[0.2],[0.3],[0.4]], "y": [1.0, 1.105, 1.221, 1.350, 1.492] }"#;
757        let cfg = r#"{ "max_epochs": 50, "max_depth": 1, "seed": 0, "top_k": 1 }"#;
758        let out = discover_json(data, cfg);
759        assert!(
760            !out.is_empty(),
761            "discover_json must return a non-empty string"
762        );
763    }
764}