1use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::*;
20
21use phop_core::{AnySolution, Config, DataSet, Discoverer, Solution};
22use scirs2_core::ndarray::{Array1, Array2};
23
24#[derive(Debug, Deserialize)]
26struct DataJson {
27 x: Vec<Vec<f64>>,
29 y: Vec<f64>,
31}
32
33#[derive(Debug, Default, Deserialize)]
35struct ConfigJson {
36 population: Option<usize>,
38 max_depth: Option<usize>,
40 max_epochs: Option<usize>,
42 learning_rate: Option<f64>,
44 seed: Option<u64>,
46 top_k: Option<usize>,
48 analyze: Option<bool>,
51}
52
53#[derive(Debug, Serialize)]
55struct SolutionJson {
56 latex: String,
58 pretty: String,
60 mse: f64,
62 complexity: usize,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 derivative: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 antiderivative: Option<String>,
70}
71
72#[derive(Debug, Serialize)]
74struct ResultJson {
75 solutions: Vec<SolutionJson>,
77}
78
79#[derive(Debug, Serialize)]
81struct ErrorJson {
82 error: String,
84}
85
86#[wasm_bindgen]
89pub fn set_panic_hook() {
90 console_error_panic_hook::set_once();
91}
92
93#[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
109fn 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
182type DimVec = Vec<i32>;
192
193#[derive(Debug, Default, Deserialize)]
195struct VerifyConfigJson {
196 population: Option<usize>,
198 max_depth: Option<usize>,
200 max_epochs: Option<usize>,
202 learning_rate: Option<f64>,
204 seed: Option<u64>,
206 top_k: Option<usize>,
208 method: Option<String>,
211 analyze: Option<bool>,
213 canonical: Option<bool>,
215 certify: Option<bool>,
217 prove_no_root: Option<bool>,
219 units: Option<Vec<DimVec>>,
222 target_model: Option<String>,
225}
226
227#[derive(Debug, Serialize)]
229struct VerifiedSolutionJson {
230 source: String,
232 latex: String,
234 pretty: String,
236 mse: f64,
238 r2: f64,
240 complexity: usize,
242 symbolic: bool,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 derivative: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 antiderivative: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 canonical_latex: Option<String>,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 certified_range: Option<[f64; 2]>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 certified_root: Option<String>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 proven_no_root: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none")]
265 equivalent_to_target: Option<String>,
266}
267
268#[derive(Debug, Serialize)]
270struct Capabilities {
271 analyze: bool,
273 certify: bool,
275 canonical: bool,
277 smt: bool,
279}
280
281#[derive(Debug, Serialize)]
283struct VerifiedResultJson {
284 solutions: Vec<VerifiedSolutionJson>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pi_groups: Option<Vec<DimVec>>,
289 capabilities: Capabilities,
291}
292
293#[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#[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
320fn 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
336fn 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
350fn 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
362fn 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
374fn 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
386fn 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 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 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 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 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#[cfg(test)]
579mod tests {
580 use super::*;
581
582 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 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 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 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 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#[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}