1use crate::affine::discover_affine_pareto;
12use crate::any_solution::{merge_pareto, AnySolution};
13use crate::config::Config;
14use crate::dataset::DataSet;
15use crate::error::{PhopError, Result};
16use crate::fit::{fit_constants, n_constants};
17use crate::forest::eval_tree;
18use crate::pareto::ParetoFront;
19use crate::solution::Solution;
20use oxieml::symreg::{dedupe_by_semantics, enumerate_topologies};
21use oxieml::{EmlNode, EmlTree};
22use std::sync::Arc;
23
24#[cfg(any(feature = "gpu-cuda", feature = "gpu-metal"))]
29pub(crate) trait GpuFitEngine {
30 fn gpu_fit_constants(
32 &self,
33 template: &EmlTree,
34 ds: &DataSet,
35 learning_rate: f64,
36 max_epochs: usize,
37 ) -> Result<(EmlTree, f64)>;
38}
39
40#[cfg(feature = "gpu-cuda")]
41impl GpuFitEngine for crate::gpu::CudaEmlEngine {
42 fn gpu_fit_constants(
43 &self,
44 template: &EmlTree,
45 ds: &DataSet,
46 learning_rate: f64,
47 max_epochs: usize,
48 ) -> Result<(EmlTree, f64)> {
49 self.fit_constants(template, ds, learning_rate, max_epochs)
50 }
51}
52
53#[cfg(feature = "gpu-metal")]
54impl GpuFitEngine for crate::metal::MetalEmlEngine {
55 fn gpu_fit_constants(
56 &self,
57 template: &EmlTree,
58 ds: &DataSet,
59 learning_rate: f64,
60 max_epochs: usize,
61 ) -> Result<(EmlTree, f64)> {
62 self.fit_constants(template, ds, learning_rate, max_epochs)
63 }
64}
65
66const MAX_ENUM_DEPTH: usize = 3;
68const MAX_CANDIDATES: usize = 512;
70const MAX_FIT_CONSTS: usize = 6;
72const FIT_BUDGET: usize = 24;
79
80#[derive(Debug, Clone)]
82pub struct Discoverer {
83 cfg: Config,
84}
85
86fn constantize(node: &EmlNode) -> Arc<EmlNode> {
89 match node {
90 EmlNode::One => Arc::new(EmlNode::Const(1.0)),
91 EmlNode::Const(c) => Arc::new(EmlNode::Const(*c)),
92 EmlNode::Var(i) => Arc::new(EmlNode::Var(*i)),
93 EmlNode::Eml { left, right } => Arc::new(EmlNode::Eml {
94 left: constantize(left),
95 right: constantize(right),
96 }),
97 }
98}
99
100fn latex_is_finite(sol: &Solution) -> bool {
102 let tex = sol.latex();
103 let lower = tex.to_ascii_lowercase();
104 !lower.contains("nan") && !lower.contains("inf")
105}
106
107fn eval_one(topo: &EmlTree, ds: &DataSet) -> Option<(f64, Solution)> {
110 let pred = eval_tree(topo, &ds.x).ok()?;
111 let m = crate::fit::mse(&pred, &ds.y);
112 m.is_finite().then(|| (m, Solution::new(topo.clone(), m)))
113}
114
115#[cfg(feature = "parallel")]
119fn raw_evals(topos: &[EmlTree], ds: &DataSet) -> Vec<Option<(f64, Solution)>> {
120 scirs2_core::parallel_ops::parallel_map(topos, |topo| eval_one(topo, ds))
121}
122
123#[cfg(not(feature = "parallel"))]
125fn raw_evals(topos: &[EmlTree], ds: &DataSet) -> Vec<Option<(f64, Solution)>> {
126 topos.iter().map(|topo| eval_one(topo, ds)).collect()
127}
128
129impl Discoverer {
130 #[must_use]
132 pub fn new(cfg: Config) -> Self {
133 Self { cfg }
134 }
135
136 pub fn fit(&self, ds: &DataSet) -> Result<ParetoFront> {
141 if ds.is_empty() {
142 return Err(PhopError::ShapeMismatch("empty dataset".to_string()));
143 }
144 let n_vars = ds.n_vars();
145 let depth = self.cfg.max_depth.clamp(1, MAX_ENUM_DEPTH);
146
147 let mut topologies = dedupe_by_semantics(enumerate_topologies(depth, n_vars));
148 topologies.truncate(MAX_CANDIDATES);
149
150 let mut candidates: Vec<Solution> = Vec::new();
151
152 let mut ranked: Vec<(f64, usize)> = Vec::new();
157 for (i, eval) in raw_evals(&topologies, ds).into_iter().enumerate() {
158 if let Some((m, sol)) = eval {
159 candidates.push(sol);
160 ranked.push((m, i));
161 }
162 }
163
164 #[cfg(any(feature = "gpu-cuda", feature = "gpu-metal"))]
168 let gpu_engine: Option<Box<dyn GpuFitEngine>> = self.select_gpu_engine();
169
170 ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
172 let mut fits_done = 0;
173 for (_, i) in ranked {
174 if fits_done >= FIT_BUDGET {
175 break;
176 }
177 let ct = EmlTree::from_node(constantize(&topologies[i].root));
178 let nc = n_constants(&ct);
179 if (1..=MAX_FIT_CONSTS).contains(&nc) {
180 let fitted_res = {
181 #[cfg(any(feature = "gpu-cuda", feature = "gpu-metal"))]
182 {
183 self.fit_one(&ct, ds, gpu_engine.as_deref())
184 }
185 #[cfg(not(any(feature = "gpu-cuda", feature = "gpu-metal")))]
186 {
187 fit_constants(&ct, ds, &self.cfg)
188 }
189 };
190 if let Ok((fitted, _)) = fitted_res {
191 let (polished, _) = crate::polish::polish_constants(&fitted, ds, 40);
194 let (snapped, m) = crate::polish::snap_constants(&polished, ds, 0.02);
195 let sol = Solution::new(snapped, m);
196 if m.is_finite() && latex_is_finite(&sol) {
200 candidates.push(sol);
201 }
202 }
203 fits_done += 1;
204 }
205 }
206
207 if candidates.is_empty() {
208 return Err(PhopError::NotConverged(
209 "no finite-scoring candidate expression found".to_string(),
210 ));
211 }
212 Ok(ParetoFront::from_candidates(candidates))
213 }
214
215 #[cfg(any(feature = "gpu-cuda", feature = "gpu-metal"))]
219 fn select_gpu_engine(&self) -> Option<Box<dyn GpuFitEngine>> {
220 use crate::config::Backend;
221 match self.cfg.backend {
222 #[cfg(feature = "gpu-cuda")]
223 Backend::Cuda if crate::gpu::cuda_available() => crate::gpu::CudaEmlEngine::new()
224 .ok()
225 .map(|e| Box::new(e) as Box<dyn GpuFitEngine>),
226 #[cfg(feature = "gpu-metal")]
227 Backend::Metal if crate::metal::metal_available() => {
228 crate::metal::MetalEmlEngine::new()
229 .ok()
230 .map(|e| Box::new(e) as Box<dyn GpuFitEngine>)
231 }
232 _ => None,
233 }
234 }
235
236 #[cfg(any(feature = "gpu-cuda", feature = "gpu-metal"))]
238 fn fit_one(
239 &self,
240 ct: &EmlTree,
241 ds: &DataSet,
242 gpu: Option<&dyn GpuFitEngine>,
243 ) -> Result<(EmlTree, f64)> {
244 match gpu {
245 Some(engine) => {
246 engine.gpu_fit_constants(ct, ds, self.cfg.learning_rate, self.cfg.max_epochs)
247 }
248 None => fit_constants(ct, ds, &self.cfg),
249 }
250 }
251}
252
253pub fn discover_auto(ds: &DataSet, cfg: &Config) -> Result<ParetoFront> {
264 if ds.is_empty() {
265 return Err(PhopError::ShapeMismatch("empty dataset".to_string()));
266 }
267 let mut cands: Vec<Solution> = Vec::new();
268 if let Ok(f) = Discoverer::new(cfg.clone()).fit(ds) {
269 cands.extend(f.solutions);
270 }
271 if let Ok(f) = crate::gumbel::discover_gumbel(ds, cfg) {
272 cands.extend(f.solutions);
273 }
274 if let Ok(f) = crate::gated::discover_gated(ds, cfg) {
275 cands.extend(f.solutions);
276 }
277 cands.extend(oxieml_symreg_candidates(ds));
278 if cands.is_empty() {
279 return Err(PhopError::NotConverged(
280 "no discovery method produced a finite solution".to_string(),
281 ));
282 }
283 Ok(ParetoFront::from_candidates(cands))
284}
285
286pub fn discover_auto_all(
299 ds: &DataSet,
300 cfg: &Config,
301 max_internal: usize,
302 cand_cap: usize,
303) -> Result<Vec<AnySolution>> {
304 if ds.is_empty() {
305 return Err(PhopError::ShapeMismatch("empty dataset".to_string()));
306 }
307 let mut cands: Vec<AnySolution> = Vec::new();
308 if let Ok(front) = discover_auto(ds, cfg) {
311 cands.extend(front.solutions.into_iter().map(AnySolution::Eml));
312 }
313 cands.extend(
315 discover_affine_pareto(&ds.x, &ds.y, max_internal, cand_cap)
316 .into_iter()
317 .map(AnySolution::Affine),
318 );
319 Ok(merge_pareto(cands))
320}
321
322fn oxieml_symreg_candidates(ds: &DataSet) -> Vec<Solution> {
326 use oxieml::symreg::{SymRegConfig, SymRegEngine};
327 let inputs: Vec<Vec<f64>> = (0..ds.len())
328 .map(|i| (0..ds.n_vars()).map(|j| ds.x[[i, j]]).collect())
329 .collect();
330 let targets: Vec<f64> = ds.y.to_vec();
331 let _silencer = crate::silence::SilenceStdout::new();
333 let engine = SymRegEngine::new(SymRegConfig::quick());
334 let result = engine.discover_pareto(&inputs, &targets, ds.n_vars());
335 drop(_silencer);
336 match result {
337 Ok(formulas) => formulas
338 .into_iter()
339 .filter_map(|f| {
340 let pred = eval_tree(&f.eml_tree, &ds.x).ok()?;
341 let m = crate::fit::mse(&pred, &ds.y);
342 m.is_finite().then(|| Solution::new(f.eml_tree, m))
343 })
344 .collect(),
345 Err(_) => Vec::new(),
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use scirs2_core::ndarray::{Array1, Array2};
353
354 #[test]
355 fn discovers_exp() {
356 let xs: Vec<f64> = (0..30).map(|i| f64::from(i) * 0.1).collect();
358 let ys: Vec<f64> = xs.iter().map(|&x| x.exp()).collect();
359 let x = Array2::from_shape_vec((xs.len(), 1), xs).unwrap();
360 let y = Array1::from(ys);
361 let ds = DataSet::from_arrays(x, y).unwrap();
362
363 let cfg = Config::default().max_depth(1).max_epochs(300);
364 let front = Discoverer::new(cfg).fit(&ds).unwrap();
365 assert!(!front.is_empty());
366 let best = front.best().unwrap();
367 assert!(
368 best.mse < 1e-6,
369 "best mse = {} ({})",
370 best.mse,
371 best.pretty()
372 );
373 }
374
375 #[test]
376 fn enumerate_discovery_is_deterministic() {
377 let xs: Vec<f64> = (1..=30).map(|i| f64::from(i) * 0.1).collect();
379 let ys: Vec<f64> = xs.iter().map(|&x| x.exp() - 2.0_f64.ln()).collect();
380 let x = Array2::from_shape_vec((xs.len(), 1), xs).unwrap();
381 let ds = DataSet::from_arrays(x, Array1::from(ys)).unwrap();
382
383 let cfg = Config::default().max_depth(2).max_epochs(200).seed(5);
384 let a = Discoverer::new(cfg.clone()).fit(&ds).unwrap();
385 let b = Discoverer::new(cfg).fit(&ds).unwrap();
386 assert_eq!(a.len(), b.len(), "front sizes differ across identical runs");
387 for (sa, sb) in a.solutions.iter().zip(&b.solutions) {
388 assert_eq!(sa.complexity, sb.complexity);
389 assert!(
390 (sa.mse - sb.mse).abs() < 1e-9,
391 "non-deterministic MSE: {} vs {}",
392 sa.mse,
393 sb.mse
394 );
395 }
396 }
397
398 #[test]
399 fn discover_auto_merges_methods_and_recovers_exp() {
400 let xs: Vec<f64> = (0..24).map(|i| f64::from(i) * 0.1).collect();
402 let ys: Vec<f64> = xs.iter().map(|&x| x.exp()).collect();
403 let x = Array2::from_shape_vec((xs.len(), 1), xs).unwrap();
404 let ds = DataSet::from_arrays(x, Array1::from(ys)).unwrap();
405
406 let cfg = Config::default()
408 .max_depth(2)
409 .population(2)
410 .max_epochs(120)
411 .seed(0);
412 let front = discover_auto(&ds, &cfg).unwrap();
413 assert!(!front.is_empty());
414 assert!(
415 front.best().unwrap().mse < 1e-6,
416 "discover_auto did not recover exp: best mse = {}",
417 front.best().unwrap().mse
418 );
419 }
420
421 #[cfg(feature = "gpu-cuda")]
422 #[test]
423 fn discovers_exp_on_cuda_backend() {
424 use crate::config::Backend;
425 if !crate::gpu::cuda_available() {
426 eprintln!("skipping CUDA-backend test: no device");
427 return;
428 }
429 let xs: Vec<f64> = (0..30).map(|i| f64::from(i) * 0.1).collect();
431 let ys: Vec<f64> = xs.iter().map(|&x| x.exp()).collect();
432 let x = Array2::from_shape_vec((xs.len(), 1), xs).unwrap();
433 let ds = DataSet::from_arrays(x, Array1::from(ys)).unwrap();
434
435 let cfg = Config::default()
436 .max_depth(1)
437 .max_epochs(300)
438 .backend(Backend::Cuda);
439 let front = Discoverer::new(cfg).fit(&ds).unwrap();
440 let best = front.best().unwrap();
441 assert!(
442 best.mse < 1e-6,
443 "CUDA-backend best mse = {} ({})",
444 best.mse,
445 best.pretty()
446 );
447 }
448
449 #[cfg(all(target_os = "macos", feature = "gpu-metal"))]
450 #[test]
451 fn discovers_exp_on_metal_backend() {
452 use crate::config::Backend;
453 if !crate::metal::metal_available() {
454 eprintln!("skipping Metal-backend test: no device");
455 return;
456 }
457 let xs: Vec<f64> = (0..30).map(|i| f64::from(i) * 0.1).collect();
459 let ys: Vec<f64> = xs.iter().map(|&x| x.exp()).collect();
460 let x = Array2::from_shape_vec((xs.len(), 1), xs).unwrap();
461 let y = Array1::from(ys);
462 let ds = DataSet::from_arrays(x, y).unwrap();
463
464 let cfg = Config::default()
465 .max_depth(1)
466 .max_epochs(300)
467 .backend(Backend::Metal);
468 let front = Discoverer::new(cfg).fit(&ds).unwrap();
469 let best = front.best().unwrap();
470 assert!(
471 best.mse < 1e-6,
472 "Metal-backend best mse = {} ({})",
473 best.mse,
474 best.pretty()
475 );
476 }
477}