use crate::polish::solve_dense;
use crate::rng::SplitMix64;
use scirs2_core::ndarray::{Array1, Array2};
const EXP_CLAMP: f64 = 50.0;
const LN_EPS: f64 = 1e-12;
const ACTIVE_EPS: f64 = 1e-6;
#[derive(Clone, Debug)]
pub enum ANode {
Const(f64),
Linear {
coeffs: Vec<f64>,
b: f64,
},
LogLinear {
coeffs: Vec<f64>,
b: f64,
},
Eml(Box<ANode>, Box<ANode>),
}
fn active(coeffs: &[f64]) -> usize {
coeffs.iter().filter(|c| c.abs() > ACTIVE_EPS).count()
}
impl ANode {
#[must_use]
pub fn nodes(&self) -> usize {
match self {
ANode::Const(_) => 1,
ANode::Linear { coeffs, .. } | ANode::LogLinear { coeffs, .. } => 1 + active(coeffs),
ANode::Eml(l, r) => 1 + l.nodes() + r.nodes(),
}
}
#[must_use]
pub fn depth(&self) -> usize {
match self {
ANode::Eml(l, r) => 1 + l.depth().max(r.depth()),
_ => 0,
}
}
#[must_use]
pub fn pretty(&self) -> String {
match self {
ANode::Const(c) => format!("{c:.4}"),
ANode::Linear { coeffs, b } => format!("({})", combo(coeffs, *b, "x")),
ANode::LogLinear { coeffs, b } => format!("({})", combo(coeffs, *b, "ln x")),
ANode::Eml(l, r) => match const_value(r) {
Some(c) if (c - 1.0).abs() < 1e-6 => match l.as_ref() {
ANode::LogLinear { coeffs, b } => monomial(coeffs, *b, false),
_ => format!("exp({})", l.pretty()),
},
_ => format!("eml({}, {})", l.pretty(), r.pretty()),
},
}
}
}
fn const_value(node: &ANode) -> Option<f64> {
match node {
ANode::Const(c) => Some(*c),
ANode::Linear { coeffs, b } | ANode::LogLinear { coeffs, b } => {
coeffs.iter().all(|c| c.abs() <= ACTIVE_EPS).then_some(*b)
}
ANode::Eml(_, _) => None,
}
}
fn fmt_exp(a: f64) -> String {
if (a - a.round()).abs() < 1e-9 {
format!("{}", a.round() as i64)
} else {
format!("{a:.3}")
}
}
fn monomial(coeffs: &[f64], b: f64, latex: bool) -> String {
let mut parts: Vec<String> = coeffs
.iter()
.enumerate()
.filter(|(_, a)| a.abs() > ACTIVE_EPS)
.map(|(i, a)| match (latex, (a - 1.0).abs() < 1e-9) {
(true, true) => format!("x_{{{i}}}"),
(true, false) => format!("x_{{{i}}}^{{{}}}", fmt_exp(*a)),
(false, true) => format!("x{i}"),
(false, false) => format!("x{i}^{}", fmt_exp(*a)),
})
.collect();
if b.abs() > ACTIVE_EPS {
parts.push(if latex {
format!("e^{{{b:.3}}}")
} else {
format!("exp({b:.3})")
});
}
match (parts.is_empty(), latex) {
(true, _) => "1".to_string(),
(false, true) => parts.join(" \\cdot "),
(false, false) => parts.join("*"),
}
}
fn combo(coeffs: &[f64], b: f64, sym: &str) -> String {
let mut parts: Vec<String> = coeffs
.iter()
.enumerate()
.filter(|(_, c)| c.abs() > ACTIVE_EPS)
.map(|(i, c)| format!("{c:.3}*{sym}{i}"))
.collect();
if b.abs() > ACTIVE_EPS || parts.is_empty() {
parts.push(format!("{b:.3}"));
}
parts.join(" + ")
}
#[must_use]
pub fn eval(node: &ANode, x: &Array2<f64>) -> Array1<f64> {
let n = x.nrows();
match node {
ANode::Const(c) => Array1::from_elem(n, *c),
ANode::Linear { coeffs, b } => {
let mut out = Array1::from_elem(n, *b);
for (j, &cf) in coeffs.iter().enumerate() {
if cf != 0.0 {
for i in 0..n {
out[i] += cf * x[[i, j]];
}
}
}
out
}
ANode::LogLinear { coeffs, b } => {
let mut out = Array1::from_elem(n, *b);
for (j, &cf) in coeffs.iter().enumerate() {
if cf != 0.0 {
for i in 0..n {
out[i] += cf * x[[i, j]].max(LN_EPS).ln();
}
}
}
out
}
ANode::Eml(l, r) => {
let la = eval(l, x);
let rb = eval(r, x);
let mut out = Array1::zeros(n);
for i in 0..n {
let ea = la[i].clamp(-EXP_CLAMP, EXP_CLAMP).exp();
let lb = rb[i].max(LN_EPS).ln();
out[i] = ea - lb;
}
out
}
}
}
fn collect(node: &ANode, out: &mut Vec<f64>) {
match node {
ANode::Const(c) => out.push(*c),
ANode::Linear { coeffs, b } | ANode::LogLinear { coeffs, b } => {
out.extend_from_slice(coeffs);
out.push(*b);
}
ANode::Eml(l, r) => {
collect(l, out);
collect(r, out);
}
}
}
fn apply(node: &ANode, p: &[f64], idx: &mut usize) -> ANode {
match node {
ANode::Const(_) => {
let c = p[*idx];
*idx += 1;
ANode::Const(c)
}
ANode::Linear { coeffs, .. } => {
let n = coeffs.len();
let cs = p[*idx..*idx + n].to_vec();
let b = p[*idx + n];
*idx += n + 1;
ANode::Linear { coeffs: cs, b }
}
ANode::LogLinear { coeffs, .. } => {
let n = coeffs.len();
let cs = p[*idx..*idx + n].to_vec();
let b = p[*idx + n];
*idx += n + 1;
ANode::LogLinear { coeffs: cs, b }
}
ANode::Eml(l, r) => ANode::Eml(Box::new(apply(l, p, idx)), Box::new(apply(r, p, idx))),
}
}
fn reinit(node: &ANode, coeff_init: f64) -> ANode {
match node {
ANode::Const(_) => ANode::Const(1.0),
ANode::Linear { coeffs, .. } => ANode::Linear {
coeffs: vec![coeff_init; coeffs.len()],
b: 0.0,
},
ANode::LogLinear { coeffs, .. } => ANode::LogLinear {
coeffs: vec![coeff_init; coeffs.len()],
b: 0.0,
},
ANode::Eml(l, r) => ANode::Eml(
Box::new(reinit(l, coeff_init)),
Box::new(reinit(r, coeff_init)),
),
}
}
fn mse(pred: &Array1<f64>, y: &Array1<f64>) -> f64 {
let n = y.len().max(1) as f64;
let mut s = 0.0;
for (p, t) in pred.iter().zip(y.iter()) {
if !p.is_finite() {
return f64::INFINITY;
}
s += (p - t) * (p - t);
}
s / n
}
const SNAP_ABS: f64 = 0.03;
const SYMBOLIC_R2: f64 = 0.999;
const SNAP_REFIT_ITERS: usize = 60;
fn snap_rational(v: f64) -> Option<f64> {
if v.abs() < SNAP_ABS {
return Some(0.0);
}
for d in [1.0, 2.0, 3.0, 4.0, 6.0] {
let k = (v * d).round();
let cand = k / d;
if cand.abs() <= 12.0 && (v - cand).abs() < SNAP_ABS {
return Some(cand);
}
}
None
}
fn snap_value(v: f64) -> Option<f64> {
oxieml::symreg::snap_to_named_const(v)
.map(|nc| nc.value())
.or_else(|| snap_rational(v))
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Exp,
Lin,
Other,
}
fn tag(node: &ANode, out: &mut Vec<Kind>) {
match node {
ANode::Const(_) => out.push(Kind::Other),
ANode::Linear { coeffs, .. } => {
out.extend(std::iter::repeat_n(Kind::Lin, coeffs.len()));
out.push(Kind::Other);
}
ANode::LogLinear { coeffs, .. } => {
out.extend(std::iter::repeat_n(Kind::Exp, coeffs.len()));
out.push(Kind::Other);
}
ANode::Eml(l, r) => {
tag(l, out);
tag(r, out);
}
}
}
fn snap_residual(v: f64, k: Kind) -> f64 {
let target = match k {
Kind::Exp => snap_rational(v),
Kind::Lin => {
if v.abs() < SNAP_ABS {
Some(0.0)
} else {
snap_value(v)
}
}
Kind::Other => return f64::INFINITY,
};
target.map_or(f64::INFINITY, |t| (v - t).abs())
}
fn try_snap(tree: &ANode, x: &Array2<f64>, y: &Array1<f64>) -> Option<ANode> {
let mut theta = Vec::new();
collect(tree, &mut theta);
let mut kinds = Vec::new();
tag(tree, &mut kinds);
let mut order: Vec<usize> = (0..theta.len())
.filter(|&i| kinds[i] != Kind::Other)
.collect();
if order.is_empty() {
return None;
}
order.sort_by(|&a, &b| {
snap_residual(theta[a], kinds[a])
.partial_cmp(&snap_residual(theta[b], kinds[b]))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut fixed = vec![false; theta.len()];
for i in order {
let snapped_val = match kinds[i] {
Kind::Exp => snap_rational(theta[i])?,
Kind::Lin => {
if theta[i].abs() < SNAP_ABS {
0.0
} else {
snap_value(theta[i])?
}
}
Kind::Other => continue,
};
theta[i] = snapped_val;
fixed[i] = true;
let (refit, _) = lm_fit_masked(tree, x, y, SNAP_REFIT_ITERS, &theta, &fixed);
theta = refit;
theta[i] = snapped_val;
let pred = {
let mut idx = 0;
eval(&apply(tree, &theta, &mut idx), x)
};
if r2(&pred, y) < SYMBOLIC_R2 {
return None;
}
}
for i in 0..theta.len() {
if fixed[i] || kinds[i] != Kind::Other {
continue;
}
if let Some(cv) = snap_rational(theta[i]) {
if cv == theta[i] {
continue; }
let saved = theta[i];
theta[i] = cv;
let pred = {
let mut idx = 0;
eval(&apply(tree, &theta, &mut idx), x)
};
if r2(&pred, y) < SYMBOLIC_R2 {
theta[i] = saved; }
}
}
let mut idx = 0;
Some(apply(tree, &theta, &mut idx))
}
#[must_use]
pub fn r2(pred: &Array1<f64>, y: &Array1<f64>) -> f64 {
let mean = y.sum() / y.len().max(1) as f64;
let (mut sr, mut st) = (0.0, 0.0);
for (p, t) in pred.iter().zip(y.iter()) {
sr += (t - p) * (t - p);
st += (t - mean) * (t - mean);
}
if st == 0.0 {
return f64::NAN;
}
1.0 - sr / st
}
fn lm_fit_masked(
skel: &ANode,
x: &Array2<f64>,
y: &Array1<f64>,
iters: usize,
theta0: &[f64],
fixed: &[bool],
) -> (Vec<f64>, f64) {
let mut theta = theta0.to_vec();
let free: Vec<usize> = (0..theta.len()).filter(|&j| !fixed[j]).collect();
let p = free.len();
let eval_at = |th: &[f64]| -> Option<Array1<f64>> {
let mut idx = 0;
let pred = eval(&apply(skel, th, &mut idx), x);
pred.iter().all(|v| v.is_finite()).then_some(pred)
};
let Some(mut pred) = eval_at(&theta) else {
return (theta, f64::INFINITY);
};
let mut cost = mse(&pred, y);
if p == 0 {
return (theta, cost);
}
let n = y.len();
let mut lambda = 1e-2_f64;
for _ in 0..iters {
let r: Vec<f64> = pred.iter().zip(y.iter()).map(|(p, t)| p - t).collect();
let mut jac = vec![vec![0.0; p]; n];
let mut ok = true;
for (jc, &j) in free.iter().enumerate() {
let h = 1e-6 * (theta[j].abs() + 1.0);
let mut th = theta.clone();
th[j] += h;
let Some(pj) = eval_at(&th) else {
ok = false;
break;
};
for i in 0..n {
jac[i][jc] = (pj[i] - pred[i]) / h;
}
}
if !ok {
break;
}
let mut a = vec![vec![0.0; p]; p];
let mut grad = vec![0.0; p];
for col in 0..p {
for (row, jr) in jac.iter().enumerate() {
grad[col] += jr[col] * r[row];
}
for col2 in col..p {
let s: f64 = jac.iter().map(|jr| jr[col] * jr[col2]).sum();
a[col][col2] = s;
a[col2][col] = s;
}
}
let mut accepted = false;
for _ in 0..12 {
let mut ad = a.clone();
for d in 0..p {
ad[d][d] += lambda * a[d][d].max(1e-12);
}
let rhs: Vec<f64> = grad.iter().map(|g| -g).collect();
let Some(delta) = solve_dense(ad, rhs) else {
lambda *= 4.0;
continue;
};
let mut cand = theta.clone();
for (jc, &j) in free.iter().enumerate() {
cand[j] = theta[j] + delta[jc];
}
if let Some(pc) = eval_at(&cand) {
let cc = mse(&pc, y);
if cc < cost {
theta = cand;
pred = pc;
cost = cc;
lambda = (lambda * 0.5).max(1e-12);
accepted = true;
break;
}
}
lambda *= 4.0;
}
if !accepted {
break;
}
}
(theta, cost)
}
fn lm_fit(skel: &ANode, x: &Array2<f64>, y: &Array1<f64>, iters: usize) -> (ANode, f64) {
let mut theta0 = Vec::new();
collect(skel, &mut theta0);
let fixed = vec![false; theta0.len()];
let (theta, cost) = lm_fit_masked(skel, x, y, iters, &theta0, &fixed);
let mut idx = 0;
(apply(skel, &theta, &mut idx), cost)
}
fn lm_fit_best(skel: &ANode, x: &Array2<f64>, y: &Array1<f64>, iters: usize) -> (ANode, f64) {
let mut best = lm_fit(skel, x, y, iters);
let alt = lm_fit(&reinit(skel, 0.0), x, y, iters);
if alt.1 < best.1 {
best = alt;
}
best
}
#[derive(Clone)]
enum Skel {
Leaf,
Node(Box<Skel>, Box<Skel>),
}
impl Skel {
fn leaves(&self) -> usize {
match self {
Skel::Leaf => 1,
Skel::Node(l, r) => l.leaves() + r.leaves(),
}
}
}
fn skeletons(max_internal: usize) -> Vec<Skel> {
let mut by_k: Vec<Vec<Skel>> = vec![vec![Skel::Leaf]];
for k in 1..=max_internal {
let mut here = Vec::new();
for i in 0..k {
for l in &by_k[i] {
for r in &by_k[k - 1 - i] {
here.push(Skel::Node(Box::new(l.clone()), Box::new(r.clone())));
}
}
}
by_k.push(here);
}
by_k.into_iter().flatten().collect()
}
fn materialize(
skel: &Skel,
types: &[usize],
idx: &mut usize,
n_vars: usize,
coeff_init: f64,
) -> ANode {
match skel {
Skel::Leaf => {
let t = types[*idx];
*idx += 1;
match t {
0 => ANode::Const(1.0),
1 => ANode::Linear {
coeffs: vec![coeff_init; n_vars],
b: 0.0,
},
_ => ANode::LogLinear {
coeffs: vec![coeff_init; n_vars],
b: 0.0,
},
}
}
Skel::Node(l, r) => ANode::Eml(
Box::new(materialize(l, types, idx, n_vars, coeff_init)),
Box::new(materialize(r, types, idx, n_vars, coeff_init)),
),
}
}
#[derive(Clone, Debug)]
pub struct AffineSolution {
pub tree: ANode,
pub mse: f64,
pub r2: f64,
pub expr: String,
pub nodes: usize,
pub depth: usize,
pub symbolic: bool,
}
impl AffineSolution {
fn from_tree(tree: ANode, x: &Array2<f64>, y: &Array1<f64>, mse: f64) -> Self {
let pred = eval(&tree, x);
Self {
r2: r2(&pred, y),
expr: tree.pretty(),
nodes: tree.nodes(),
depth: tree.depth(),
symbolic: false,
mse,
tree,
}
}
#[must_use]
fn with_snap(mut self, x: &Array2<f64>, y: &Array1<f64>) -> Self {
if let Some(snapped) = try_snap(&self.tree, x, y) {
let pred = eval(&snapped, x);
self.mse = mse(&pred, y);
self.r2 = r2(&pred, y);
self.expr = snapped.pretty();
self.nodes = snapped.nodes();
self.depth = snapped.depth();
self.tree = snapped;
self.symbolic = true;
}
self
}
#[must_use]
pub fn predict(&self, x: &Array2<f64>) -> Array1<f64> {
eval(&self.tree, x)
}
#[must_use]
pub fn latex(&self) -> String {
to_latex(&self.tree)
}
}
#[must_use]
pub fn to_latex(node: &ANode) -> String {
match node {
ANode::Const(c) => format!("{c:.4}"),
ANode::Linear { coeffs, b } => combo_latex(coeffs, *b, false),
ANode::LogLinear { coeffs, b } => combo_latex(coeffs, *b, true),
ANode::Eml(l, r) => match const_value(r) {
Some(c) if (c - 1.0).abs() < 1e-6 => match l.as_ref() {
ANode::LogLinear { coeffs, b } => monomial(coeffs, *b, true),
_ => format!("e^{{{}}}", to_latex(l)),
},
_ => format!(
"\\left(e^{{{}}} - \\ln\\left({}\\right)\\right)",
to_latex(l),
to_latex(r)
),
},
}
}
fn combo_latex(coeffs: &[f64], b: f64, log: bool) -> String {
let mut parts: Vec<String> = coeffs
.iter()
.enumerate()
.filter(|(_, c)| c.abs() > ACTIVE_EPS)
.map(|(i, c)| {
if log {
format!("{c:.3}\\,\\ln x_{{{i}}}")
} else {
format!("{c:.3}\\,x_{{{i}}}")
}
})
.collect();
if b.abs() > ACTIVE_EPS || parts.is_empty() {
parts.push(format!("{b:.3}"));
}
parts.join(" + ")
}
fn build_pool(n_vars: usize, max_internal: usize, cand_cap: usize) -> Vec<ANode> {
const RADIX: usize = 3; const EXHAUSTIVE_MAX: u128 = 256;
const SAMPLES_PER_SKEL: usize = 200;
let mut rng = SplitMix64::new(0xA5F1_C0DE ^ n_vars as u64);
let mut pool: Vec<ANode> = Vec::new();
'outer: for skel in skeletons(max_internal) {
let leaves = skel.leaves();
let total = (RADIX as u128)
.checked_pow(leaves as u32)
.unwrap_or(u128::MAX);
if total <= EXHAUSTIVE_MAX {
for code in 0..total {
if pool.len() >= cand_cap {
break 'outer;
}
let mut types = vec![0usize; leaves];
let mut c = code;
for slot in types.iter_mut() {
*slot = (c % RADIX as u128) as usize;
c /= RADIX as u128;
}
let mut idx = 0;
pool.push(materialize(&skel, &types, &mut idx, n_vars, 1.0));
}
} else {
for _ in 0..SAMPLES_PER_SKEL {
if pool.len() >= cand_cap {
break 'outer;
}
let types: Vec<usize> = (0..leaves).map(|_| rng.below(RADIX)).collect();
let mut idx = 0;
pool.push(materialize(&skel, &types, &mut idx, n_vars, 1.0));
}
}
}
pool
}
fn fit_pool(pool: &[ANode], x: &Array2<f64>, y: &Array1<f64>) -> Vec<AffineSolution> {
const QUICK: usize = 10;
const REFIT: usize = 50;
const REFIT_K: usize = 40;
let mut scored: Vec<(usize, f64)> = pool
.iter()
.enumerate()
.map(|(i, c)| (i, lm_fit(c, x, y, QUICK).1))
.filter(|(_, m)| m.is_finite())
.collect();
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
scored
.iter()
.take(REFIT_K)
.filter_map(|(i, _)| {
let (fitted, m) = lm_fit_best(&pool[*i], x, y, REFIT);
m.is_finite()
.then(|| AffineSolution::from_tree(fitted, x, y, m))
})
.collect()
}
#[must_use]
pub fn discover_affine(
x: &Array2<f64>,
y: &Array1<f64>,
max_internal: usize,
cand_cap: usize,
) -> Option<AffineSolution> {
if x.nrows() == 0 || x.ncols() == 0 {
return None;
}
let pool = build_pool(x.ncols(), max_internal, cand_cap);
fit_pool(&pool, x, y)
.into_iter()
.min_by(|a, b| {
a.mse
.partial_cmp(&b.mse)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|s| s.with_snap(x, y))
}
#[must_use]
pub fn discover_affine_pareto(
x: &Array2<f64>,
y: &Array1<f64>,
max_internal: usize,
cand_cap: usize,
) -> Vec<AffineSolution> {
if x.nrows() == 0 || x.ncols() == 0 {
return Vec::new();
}
let pool = build_pool(x.ncols(), max_internal, cand_cap);
let cands: Vec<AffineSolution> = fit_pool(&pool, x, y)
.into_iter()
.map(|s| s.with_snap(x, y))
.collect();
let mut front: Vec<AffineSolution> = Vec::new();
for c in cands {
let dominated = front
.iter()
.any(|s| s.nodes <= c.nodes && s.mse <= c.mse && (s.nodes < c.nodes || s.mse < c.mse));
if dominated {
continue;
}
front.retain(|s| {
!(c.nodes <= s.nodes && c.mse <= s.mse && (c.nodes < s.nodes || c.mse < s.mse))
});
front.push(c);
}
front.sort_by(|a, b| {
a.nodes.cmp(&b.nodes).then(
a.mse
.partial_cmp(&b.mse)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
front
}
#[cfg(test)]
mod tests {
use super::*;
fn ds(f: impl Fn(&[f64]) -> f64, cols: &[(f64, f64)], n: usize) -> (Array2<f64>, Array1<f64>) {
let nv = cols.len();
let mut xv = Vec::with_capacity(n * nv);
let mut yv = Vec::with_capacity(n);
for i in 0..n {
let row: Vec<f64> = cols
.iter()
.enumerate()
.map(|(j, (lo, hi))| {
let idx = (i * (2 * j + 1) + 7 * j) % n;
lo + (hi - lo) * (idx as f64) / (n as f64 - 1.0)
})
.collect();
yv.push(f(&row));
xv.extend(&row);
}
(
Array2::from_shape_vec((n, nv), xv).expect("shape"),
Array1::from(yv),
)
}
#[test]
fn recovers_linear_combination() {
let (x, y) = ds(
|r| 3.0 * r[0] - 2.0 * r[1] + 1.0,
&[(0.5, 5.0), (1.0, 4.0)],
50,
);
let s = discover_affine(&x, &y, 3, 2000).expect("solution");
assert!(
s.r2 > 0.9999,
"linear combo not recovered: r2={} expr={}",
s.r2,
s.expr
);
}
#[test]
fn recovers_scaled_exponential() {
let (x, y) = ds(|r| (2.0 * r[0]).exp(), &[(0.0, 2.0)], 40);
let s = discover_affine(&x, &y, 3, 2000).expect("solution");
assert!(
s.r2 > 0.999,
"scaled exp not recovered: r2={} expr={}",
s.r2,
s.expr
);
}
#[test]
fn recovers_product() {
let (x, y) = ds(|r| r[0] * r[1], &[(0.5, 5.0), (0.5, 5.0)], 50);
let s = discover_affine(&x, &y, 3, 2000).expect("solution");
assert!(
s.r2 > 0.999,
"product not recovered: r2={} expr={}",
s.r2,
s.expr
);
}
#[test]
fn recovers_power_and_ratio() {
let (x, y) = ds(|r| r[0] * r[0] / r[1], &[(0.5, 5.0), (0.5, 5.0)], 50);
let s = discover_affine(&x, &y, 3, 2000).expect("solution");
assert!(
s.r2 > 0.999,
"power/ratio not recovered: r2={} expr={}",
s.r2,
s.expr
);
}
#[test]
fn symbolic_recovery_snaps_exponents() {
let (x, y) = ds(|r| r[0] * r[0] / r[1], &[(0.5, 5.0), (0.5, 5.0)], 50);
let s = discover_affine(&x, &y, 3, 2000).expect("solution");
assert!(
s.symbolic,
"x0^2/x1 should be a symbolic recovery: expr={}",
s.expr
);
assert!(s.r2 >= 0.999, "snapped form lost accuracy: r2={}", s.r2);
let (x2, y2) = ds(|r| (2.0 * r[0]).exp(), &[(0.0, 2.0)], 40);
let s2 = discover_affine(&x2, &y2, 3, 2000).expect("solution");
assert!(s2.r2 > 0.999);
}
#[test]
fn pareto_front_is_non_dominated_and_sorted() {
let (x, y) = ds(|r| r[0] * r[1], &[(0.5, 5.0), (0.5, 5.0)], 40);
let front = discover_affine_pareto(&x, &y, 3, 2000);
assert!(!front.is_empty(), "empty pareto front");
for w in front.windows(2) {
assert!(w[0].nodes <= w[1].nodes, "front not sorted by complexity");
}
assert!(
front.iter().any(|s| s.r2 > 0.999),
"no accurate solution on the front"
);
}
}