Skip to main content

phop_core/
solution.rs

1//! A discovered candidate expression and its quality metrics.
2
3use crate::codegen;
4use crate::error::Result;
5use oxieml::EmlTree;
6use scirs2_core::ndarray::{Array1, Array2};
7
8/// A single discovered expression together with its accuracy and complexity.
9#[derive(Debug, Clone)]
10pub struct Solution {
11    /// The EML tree (with fitted constants).
12    pub tree: EmlTree,
13    /// Mean-squared error on the training data.
14    pub mse: f64,
15    /// Structural complexity (EML node count).
16    pub complexity: usize,
17}
18
19impl Solution {
20    /// Build a solution from a tree and its MSE; complexity is the node count.
21    #[must_use]
22    pub fn new(tree: EmlTree, mse: f64) -> Self {
23        let complexity = tree.size();
24        Self {
25            tree,
26            mse,
27            complexity,
28        }
29    }
30
31    /// Render the expression as a LaTeX math string (via oxieml lowering + simplification).
32    #[must_use]
33    pub fn latex(&self) -> String {
34        self.tree.lower().simplify().to_latex()
35    }
36
37    /// A human-readable EML form, e.g. `eml(x0, 1)`.
38    #[must_use]
39    pub fn pretty(&self) -> String {
40        format!("{}", self.tree)
41    }
42
43    /// Render the expression as a **stronger** canonical LaTeX via `scirs2-symbolic`'s e-graph
44    /// (equality saturation), which collapses forms a single-pass simplifier misses (e.g.
45    /// `ln(exp(x)) → x`). Falls back to [`Self::latex`] for laws containing special functions.
46    /// Requires the `egraph` feature.
47    #[cfg(feature = "egraph")]
48    #[must_use]
49    pub fn latex_egraph(&self) -> String {
50        crate::egraph::canonical_latex_egraph(&self.tree)
51    }
52
53    /// Generate a standalone Rust function for the expression.
54    #[must_use]
55    pub fn rust_code(&self) -> String {
56        codegen::rust_code(&self.tree)
57    }
58
59    /// Generate a NumPy-compatible Python lambda for the expression.
60    #[must_use]
61    pub fn numpy_code(&self) -> String {
62        codegen::numpy_code(&self.tree)
63    }
64
65    /// Generate a SymPy expression string for the expression.
66    #[must_use]
67    pub fn sympy_code(&self) -> String {
68        codegen::sympy_code(&self.tree)
69    }
70
71    /// Distill the expression into every supported output format at once
72    /// (canonical LaTeX/pretty plus Rust/NumPy/SymPy code).
73    #[must_use]
74    pub fn distill(&self) -> crate::distill::Distilled {
75        crate::distill::distill(&self.tree)
76    }
77
78    /// Evaluate the discovered expression on new data `x` (`[n_rows, n_vars]`), returning the
79    /// predictions `[n_rows]` through phop's guarded forward.
80    ///
81    /// # Errors
82    /// Returns [`crate::PhopError`] if evaluation fails or produces non-finite values.
83    pub fn predict(&self, x: &Array2<f64>) -> Result<Array1<f64>> {
84        crate::forest::eval_tree(&self.tree, x)
85    }
86
87    /// Serialize the discovered law (its EML tree) to a portable JSON string — the model artifact
88    /// `phop predict` consumes. Round-trips with [`Self::from_model_json`].
89    ///
90    /// # Errors
91    /// Returns [`crate::PhopError::Symbolic`] if serialization fails.
92    pub fn to_model_json(&self) -> Result<String> {
93        serde_json::to_string(&self.tree)
94            .map_err(|e| crate::error::PhopError::Symbolic(format!("model serialize: {e}")))
95    }
96
97    /// Reconstruct a law from [`Self::to_model_json`] output. The MSE is unknown until the law is
98    /// re-evaluated (left as `NaN`); complexity is recomputed from the tree.
99    ///
100    /// # Errors
101    /// Returns [`crate::PhopError::Parse`] if the JSON is not a valid serialized EML tree.
102    pub fn from_model_json(s: &str) -> Result<Self> {
103        let tree: EmlTree = serde_json::from_str(s)
104            .map_err(|e| crate::error::PhopError::Parse(format!("model deserialize: {e}")))?;
105        Ok(Self::new(tree, f64::NAN))
106    }
107
108    /// Symbolically analyze the discovered law via the oxieml CAS: derivative, antiderivative,
109    /// Maclaurin series, and the `+∞` limit, all w.r.t. variable `wrt` (see [`crate::analyze()`]).
110    #[must_use]
111    pub fn analyze(&self, wrt: usize, series_order: usize) -> crate::analyze::Analysis {
112        crate::analyze::analyze(&self.tree, wrt, series_order)
113    }
114
115    /// Find a **certified** root of the law in `[lo, hi]` along variable `wrt` (interval
116    /// Newton/Krawczyk verification). `others` fixes the remaining variables (`&[]` for 1-D).
117    ///
118    /// # Errors
119    /// Returns [`crate::PhopError`] if the verifier errors.
120    pub fn certified_root(
121        &self,
122        wrt: usize,
123        others: &[f64],
124        lo: f64,
125        hi: f64,
126    ) -> Result<oxieml::RootCertificate> {
127        crate::analyze::certified_root(&self.tree, wrt, others, lo, hi)
128    }
129
130    /// A **guaranteed** interval enclosure of the law's range over an axis-aligned `domain`
131    /// (`[lo, hi]` per variable): `f(x) ∈ [returned.0, returned.1]` for all `x` in the box.
132    #[must_use]
133    pub fn certified_range(&self, domain: &[(f64, f64)]) -> (f64, f64) {
134        crate::analyze::certified_range(&self.tree, domain)
135    }
136
137    /// Generate a standalone, **canonical** Rust function for the law (lowered + simplified over the
138    /// full elementary algebra via `oxieml`), suitable for embedding/deployment. This differs from
139    /// [`Self::rust_code`], which emits the raw `eml`-form; here the recovered law is simplified to
140    /// ordinary `exp/ln/+/-/…` first.
141    #[must_use]
142    pub fn compile_rust(&self, fn_name: &str) -> String {
143        oxieml::compile::compile_to_rust(&self.tree, fn_name)
144    }
145
146    /// **Prove** the law has no root over `bounds` (one `(lo, hi)` per variable) using the OxiZ
147    /// SMT backend. [`Verdict::Proven`](crate::verify::Verdict::Proven) is sound — it certifies
148    /// `f(x) ≠ 0` over the whole box. Requires the `smt` feature.
149    #[cfg(feature = "smt")]
150    #[must_use]
151    pub fn prove_no_root(&self, bounds: &[(f64, f64)]) -> crate::verify::Verdict {
152        crate::verify::prove_no_root(&self.tree, bounds)
153    }
154
155    /// **Prove** the lower bound `f(x) ≥ c` over `bounds` via the OxiZ SMT backend. Requires `smt`.
156    #[cfg(feature = "smt")]
157    #[must_use]
158    pub fn prove_lower_bound(&self, c: f64, bounds: &[(f64, f64)]) -> crate::verify::Verdict {
159        crate::verify::prove_lower_bound(&self.tree, c, bounds)
160    }
161
162    /// **Prove** the upper bound `f(x) ≤ c` over `bounds` via the OxiZ SMT backend. Requires `smt`.
163    #[cfg(feature = "smt")]
164    #[must_use]
165    pub fn prove_upper_bound(&self, c: f64, bounds: &[(f64, f64)]) -> crate::verify::Verdict {
166        crate::verify::prove_upper_bound(&self.tree, c, bounds)
167    }
168
169    /// **Prove** this law is equal to `other` over `bounds` via the OxiZ SMT backend. Requires `smt`.
170    #[cfg(feature = "smt")]
171    #[must_use]
172    pub fn prove_equivalent(
173        &self,
174        other: &Solution,
175        bounds: &[(f64, f64)],
176    ) -> crate::verify::Verdict {
177        crate::verify::prove_equivalent(&self.tree, &other.tree, bounds)
178    }
179
180    /// Map this discovered law to a **TensorLogic** expression (`tensorlogic_ir::TLExpr`) — the
181    /// neuro-symbolic bridge. The law is lowered + simplified, then translated by oxieml's
182    /// `tensorlogic` IR, so a recovered equation can enter a differentiable logic program. Requires
183    /// the `tensorlogic` feature.
184    #[cfg(feature = "tensorlogic")]
185    #[must_use]
186    pub fn to_tlexpr(&self) -> tensorlogic_ir::TLExpr {
187        oxieml::tensorlogic::to_tlexpr(&self.tree.lower().simplify())
188    }
189
190    /// Map this law to a **weighted** TensorLogic rule (`weight :: rule`), the form a neuro-symbolic
191    /// engine ingests as a soft constraint. Requires the `tensorlogic` feature.
192    #[cfg(feature = "tensorlogic")]
193    #[must_use]
194    pub fn to_tl_weighted_rule(&self, weight: f64) -> tensorlogic_ir::TLExpr {
195        tensorlogic_ir::TLExpr::WeightedRule {
196            weight,
197            rule: Box::new(self.to_tlexpr()),
198        }
199    }
200
201    /// Map this law to a weighted TensorLogic **equation** `target_var = f(…)` (the predicted target
202    /// on the left, the discovered expression on the right). Requires the `tensorlogic` feature.
203    #[cfg(feature = "tensorlogic")]
204    #[must_use]
205    pub fn to_tl_weighted_equation(&self, target_var: &str, weight: f64) -> tensorlogic_ir::TLExpr {
206        let lhs = tensorlogic_ir::TLExpr::Pred {
207            name: target_var.to_string(),
208            args: vec![tensorlogic_ir::Term::var(target_var)],
209        };
210        let eq = tensorlogic_ir::TLExpr::Eq(Box::new(lhs), Box::new(self.to_tlexpr()));
211        tensorlogic_ir::TLExpr::WeightedRule {
212            weight,
213            rule: Box::new(eq),
214        }
215    }
216
217    /// Does this solution (weakly) Pareto-dominate `other`?
218    ///
219    /// Domination minimizes both complexity and MSE, with strict improvement in at least one.
220    #[must_use]
221    pub fn dominates(&self, other: &Solution) -> bool {
222        self.complexity <= other.complexity
223            && self.mse <= other.mse
224            && (self.complexity < other.complexity || self.mse < other.mse)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn latex_renders() {
234        let tree = oxieml::Canonical::exp(&EmlTree::var(0));
235        let sol = Solution::new(tree, 0.0);
236        let tex = sol.latex();
237        assert!(!tex.is_empty());
238    }
239
240    #[test]
241    fn predict_matches_forward_eval() {
242        use scirs2_core::ndarray::Array2;
243        // eml(x0, 1) = exp(x0). predict should reproduce exp on fresh inputs.
244        let tree = EmlTree::eml(&EmlTree::var(0), &EmlTree::one());
245        let sol = Solution::new(tree, 0.0);
246        let x = Array2::from_shape_vec((3, 1), vec![0.0, 1.0, 2.0]).unwrap();
247        let p = sol.predict(&x).unwrap();
248        for (i, &xi) in [0.0_f64, 1.0, 2.0].iter().enumerate() {
249            assert!(
250                (p[i] - xi.exp()).abs() < 1e-9,
251                "row {i}: {} vs {}",
252                p[i],
253                xi.exp()
254            );
255        }
256    }
257
258    #[test]
259    fn model_json_round_trips() {
260        use scirs2_core::ndarray::Array2;
261        // eml(x0, 1) = exp(x0). Serialize → deserialize → the law must predict identically.
262        let tree = EmlTree::eml(&EmlTree::var(0), &EmlTree::one());
263        let sol = Solution::new(tree, 0.0);
264        let json = sol.to_model_json().expect("serialize");
265        let back = Solution::from_model_json(&json).expect("deserialize");
266        let x = Array2::from_shape_vec((3, 1), vec![0.0, 0.5, 1.0]).unwrap();
267        let a = sol.predict(&x).unwrap();
268        let b = back.predict(&x).unwrap();
269        for (pa, pb) in a.iter().zip(b.iter()) {
270            assert!(
271                (pa - pb).abs() < 1e-12,
272                "round-trip changed prediction: {pa} vs {pb}"
273            );
274        }
275        assert!(Solution::from_model_json("not json").is_err());
276    }
277
278    #[test]
279    fn compile_rust_emits_a_function() {
280        let tree = oxieml::Canonical::exp(&EmlTree::var(0));
281        let code = Solution::new(tree, 0.0).compile_rust("kepler");
282        assert!(
283            code.contains("fn kepler"),
284            "expected a named fn, got: {code}"
285        );
286    }
287
288    #[cfg(feature = "tensorlogic")]
289    #[test]
290    fn maps_to_weighted_logic_rule() {
291        // A discovered law becomes a weighted TensorLogic rule carrying the weight verbatim, and the
292        // equation form wraps an `Eq` under a `WeightedRule`.
293        let tree = oxieml::Canonical::exp(&EmlTree::var(0));
294        let sol = Solution::new(tree, 0.0);
295
296        let weight = match sol.to_tl_weighted_rule(0.7) {
297            tensorlogic_ir::TLExpr::WeightedRule { weight, .. } => weight,
298            _ => f64::NAN,
299        };
300        assert!(
301            (weight - 0.7).abs() < 1e-12,
302            "expected a WeightedRule carrying 0.7, got {weight}"
303        );
304
305        let eq = sol.to_tl_weighted_equation("y", 1.0);
306        assert!(matches!(eq, tensorlogic_ir::TLExpr::WeightedRule { .. }));
307    }
308
309    #[test]
310    fn domination_is_correct() {
311        let t = oxieml::Canonical::exp(&EmlTree::var(0));
312        let a = Solution {
313            tree: t.clone(),
314            mse: 0.1,
315            complexity: 3,
316        };
317        let b = Solution {
318            tree: t,
319            mse: 0.2,
320            complexity: 5,
321        };
322        assert!(a.dominates(&b));
323        assert!(!b.dominates(&a));
324    }
325}