1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Changeover {
29 Control,
30 Coordinate,
31 Fixture,
32}
33
34#[derive(Debug, Clone, PartialEq)]
39pub enum AxisValue {
40 Num(f64),
41 Label(String),
42 Bool(bool),
43}
44
45impl AxisValue {
46 pub fn as_num(&self) -> f64 {
50 match self {
51 AxisValue::Num(f) => *f,
52 AxisValue::Bool(b) => {
53 if *b {
54 1.0
55 } else {
56 0.0
57 }
58 }
59 AxisValue::Label(_) => 0.0,
60 }
61 }
62}
63
64impl std::fmt::Display for AxisValue {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 AxisValue::Num(n) => write!(f, "{n}"),
68 AxisValue::Label(s) => write!(f, "{s}"),
69 AxisValue::Bool(b) => write!(f, "{b}"),
70 }
71 }
72}
73
74impl From<f64> for AxisValue {
75 fn from(v: f64) -> Self {
76 AxisValue::Num(v)
77 }
78}
79
80pub type Coord = Vec<AxisValue>;
83
84#[derive(Debug, Clone)]
86pub enum AxisKind {
87 Continuous { lo: f64, hi: f64, min_step: f64 },
89 Discrete { detents: Vec<AxisValue> },
91 Categorical { options: Vec<AxisValue> },
96}
97
98#[derive(Debug, Clone)]
100pub struct Axis {
101 pub name: String,
102 pub kind: AxisKind,
103 pub changeover: Changeover,
104}
105
106impl Axis {
107 pub fn center(&self) -> AxisValue {
110 match &self.kind {
111 AxisKind::Continuous { lo, hi, .. } => AxisValue::Num(0.5 * (lo + hi)),
112 AxisKind::Discrete { detents } => {
113 let mut d = detents.clone();
114 d.sort_by(|a, b| {
115 a.as_num()
116 .partial_cmp(&b.as_num())
117 .unwrap_or(std::cmp::Ordering::Equal)
118 });
119 d.get(d.len() / 2).cloned().unwrap_or(AxisValue::Num(0.0))
120 }
121 AxisKind::Categorical { options } => {
122 options.first().cloned().unwrap_or(AxisValue::Num(0.0))
123 }
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct SearchSpace {
131 pub axes: Vec<Axis>,
132}
133
134impl SearchSpace {
135 pub fn new(axes: Vec<Axis>) -> Self {
136 Self { axes }
137 }
138 pub fn dims(&self) -> usize {
139 self.axes.len()
140 }
141 pub fn center(&self) -> Coord {
142 self.axes.iter().map(Axis::center).collect()
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct Observation {
149 pub value: f64,
150 pub feasible: bool,
151 pub cost: f64,
152 pub metrics: Vec<(String, f64)>,
153}
154
155impl Observation {
156 pub fn value(v: f64) -> Self {
157 Self {
158 value: v,
159 feasible: true,
160 cost: 0.0,
161 metrics: Vec::new(),
162 }
163 }
164 pub fn infeasible() -> Self {
165 Self {
166 value: f64::NEG_INFINITY,
167 feasible: false,
168 cost: 0.0,
169 metrics: Vec::new(),
170 }
171 }
172}
173
174pub trait Objective {
178 fn query(&mut self, x: &[AxisValue]) -> Observation;
179 fn query_fidelity(&mut self, x: &[AxisValue], _fidelity: f64) -> Observation {
182 self.query(x)
183 }
184}
185
186#[derive(Debug, Clone)]
188pub struct Budget {
189 pub max_evals: usize,
190 pub max_seconds: Option<f64>,
191 pub seed: u64,
192}
193
194impl Budget {
195 pub fn seeded(max_evals: usize, seed: u64) -> Self {
196 Self {
197 max_evals,
198 max_seconds: None,
199 seed,
200 }
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum StopReason {
207 Converged,
208 BudgetExhausted,
209 NoFeasiblePoint,
210 Aborted,
211}
212
213#[derive(Debug, Clone)]
215pub struct AxisImpact {
216 pub name: String,
217 pub main_effect: f64,
218 pub curvature: f64,
219}
220
221#[derive(Debug, Clone)]
223pub struct Report {
224 pub best: Coord,
225 pub best_value: f64,
226 pub evals: usize,
227 pub stop: StopReason,
228 pub ranked_axes: Vec<AxisImpact>,
229 pub history: Vec<(Coord, f64)>,
230}
231
232#[derive(Debug, Clone, Default)]
234pub struct OptimizerParams {
235 pub overrides: Vec<(String, f64)>,
236}
237
238impl OptimizerParams {
239 pub fn new() -> Self {
240 Self::default()
241 }
242 pub fn with(mut self, key: impl Into<String>, value: f64) -> Self {
243 self.overrides.push((key.into(), value));
244 self
245 }
246 pub fn get(&self, key: &str, default: f64) -> f64 {
247 self.overrides
248 .iter()
249 .rev()
250 .find(|(k, _)| k == key)
251 .map(|(_, v)| *v)
252 .unwrap_or(default)
253 }
254}
255
256pub trait CoordinateSource: Send {
260 fn as_feedback(&mut self) -> Option<&mut dyn FeedbackSource> {
263 None
264 }
265 fn as_pull(&mut self) -> Option<&mut dyn PullSource> {
268 None
269 }
270}
271
272pub trait PullSource: Send {
276 fn pull(&mut self) -> Option<Vec<Coord>>;
277}
278
279pub trait FeedbackSource {
286 fn step(&mut self, evaluated: &[(Coord, f64)]) -> Option<Vec<Coord>>;
287}
288
289pub struct LexSource {
294 lists: Vec<Vec<AxisValue>>,
295 idx: Vec<usize>,
296 done: bool,
297}
298
299impl LexSource {
300 pub fn new(space: &SearchSpace) -> Self {
301 let lists: Vec<Vec<AxisValue>> = space
302 .axes
303 .iter()
304 .map(|a| match &a.kind {
305 AxisKind::Discrete { detents } => detents.clone(),
306 AxisKind::Categorical { options } => options.clone(),
307 AxisKind::Continuous { lo, hi, .. } => {
308 vec![AxisValue::Num(*lo), AxisValue::Num(*hi)]
309 }
310 })
311 .collect();
312 let n = lists.len();
313 Self {
314 lists,
315 idx: vec![0; n],
316 done: false,
317 }
318 }
319}
320
321impl PullSource for LexSource {
322 fn pull(&mut self) -> Option<Vec<Coord>> {
323 if self.done {
324 return None;
325 }
326 let n = self.lists.len();
327 let point: Vec<AxisValue> = (0..n).map(|d| self.lists[d][self.idx[d]].clone()).collect();
328 let mut d = n;
330 loop {
331 if d == 0 {
332 self.done = true;
333 break;
334 }
335 d -= 1;
336 self.idx[d] += 1;
337 if self.idx[d] < self.lists[d].len() {
338 break;
339 }
340 self.idx[d] = 0;
341 }
342 Some(vec![point])
343 }
344}
345
346pub struct PullOnly(pub Box<dyn PullSource>);
348
349impl CoordinateSource for PullOnly {
350 fn as_pull(&mut self) -> Option<&mut dyn PullSource> {
351 Some(self.0.as_mut())
352 }
353}
354
355pub trait Optimizer: Send {
360 fn name(&self) -> &str;
362 fn doc_md(&self) -> &str;
365 fn coordinate_source(
370 &self,
371 space: &SearchSpace,
372 budget: &Budget,
373 lex: Box<dyn PullSource>,
374 ) -> Box<dyn CoordinateSource>;
375
376 fn optimize(&self, space: &SearchSpace, obj: &mut dyn Objective, budget: &Budget) -> Report {
381 drive_source(self, space, obj, budget)
382 }
383}
384
385fn drive_source(
389 opt: &(impl Optimizer + ?Sized),
390 space: &SearchSpace,
391 obj: &mut dyn Objective,
392 budget: &Budget,
393) -> Report {
394 let lex: Box<dyn PullSource> = Box::new(LexSource::new(space));
395 let mut src = opt.coordinate_source(space, budget, lex);
396
397 let mut best = space.center();
398 let mut best_value = f64::NEG_INFINITY;
399 let mut history: Vec<(Coord, f64)> = Vec::new();
400 let mut any_feasible = false;
401 let mut evals = 0usize;
402 let mut budget_hit = false;
403
404 fn next(src: &mut Box<dyn CoordinateSource>, evaluated: &[(Coord, f64)]) -> Option<Vec<Coord>> {
405 if let Some(f) = src.as_feedback() {
406 f.step(evaluated)
407 } else if let Some(p) = src.as_pull() {
408 p.pull()
409 } else {
410 None
411 }
412 }
413
414 let mut batch = next(&mut src, &[]);
415 while let Some(coords) = batch.take() {
416 let mut evaluated: Vec<(Coord, f64)> = Vec::with_capacity(coords.len());
417 for c in coords {
418 if evals >= budget.max_evals {
419 budget_hit = true;
420 break;
421 }
422 let obs = obj.query(&c);
423 evals += 1;
424 let v = if obs.feasible && obs.value.is_finite() {
425 any_feasible = true;
426 obs.value
427 } else {
428 -1.0e18
429 };
430 if v > best_value {
431 best_value = v;
432 best = c.clone();
433 }
434 history.push((c.clone(), v));
435 evaluated.push((c, v));
436 }
437 if budget_hit {
438 break;
439 }
440 batch = next(&mut src, &evaluated);
441 }
442
443 let stop = if !any_feasible {
444 StopReason::NoFeasiblePoint
445 } else if budget_hit {
446 StopReason::BudgetExhausted
447 } else {
448 StopReason::Converged
449 };
450 Report {
451 best,
452 best_value,
453 evals,
454 stop,
455 ranked_axes: Vec::new(),
456 history,
457 }
458}
459
460pub struct SweepOptimizer;
465
466const SWEEP_DOC: &str = "# sweep\n\nThe identity optimizer — the **default** when no `method:` is \
467set. Returns the default lexicographic coordinate stream unchanged — the full Cartesian product of \
468the discrete-axis detents (and the `{lo, hi}` corners of continuous axes), in lex order. So `sweep` \
469evaluates EVERY coordinate exhaustively and reports the best by the objective; it reproduces the \
470engine's ordinary parameter sweep, now with best-selection. (Use an adaptive method — `nelder_mead`, \
471`cmaes`, … — to search a continuous space without enumerating it.)\n";
472
473impl Optimizer for SweepOptimizer {
474 fn name(&self) -> &str {
475 "sweep"
476 }
477 fn doc_md(&self) -> &str {
478 SWEEP_DOC
479 }
480 fn coordinate_source(
481 &self,
482 _space: &SearchSpace,
483 _budget: &Budget,
484 lex: Box<dyn PullSource>,
485 ) -> Box<dyn CoordinateSource> {
486 Box::new(PullOnly(lex)) }
488}
489
490pub struct OptimizerRegistration {
500 pub name: fn() -> &'static str,
502 pub doc_md: fn() -> &'static str,
504 pub make: fn(&OptimizerParams) -> Box<dyn Optimizer>,
506}
507
508inventory::collect!(OptimizerRegistration);
509
510#[derive(Debug, Clone)]
512pub struct OptimizerInfo {
513 pub name: &'static str,
514 pub doc_md: &'static str,
515}
516
517pub fn by_name(name: &str, params: &OptimizerParams) -> Option<Box<dyn Optimizer>> {
520 if name == "sweep" {
521 return Some(Box::new(SweepOptimizer));
522 }
523 inventory::iter::<OptimizerRegistration>
524 .into_iter()
525 .find(|r| (r.name)() == name)
526 .map(|r| (r.make)(params))
527}
528
529pub fn describe() -> Vec<OptimizerInfo> {
532 let mut out = vec![OptimizerInfo {
533 name: "sweep",
534 doc_md: SWEEP_DOC,
535 }];
536 for r in inventory::iter::<OptimizerRegistration> {
537 out.push(OptimizerInfo {
538 name: (r.name)(),
539 doc_md: (r.doc_md)(),
540 });
541 }
542 out.sort_by(|a, b| a.name.cmp(b.name));
543 out.dedup_by(|a, b| a.name == b.name);
544 out
545}
546
547pub fn registered_names() -> Vec<&'static str> {
549 describe().into_iter().map(|i| i.name).collect()
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 struct Paraboloid {
558 target: Vec<f64>,
559 }
560 impl Objective for Paraboloid {
561 fn query(&mut self, x: &[AxisValue]) -> Observation {
562 let v: f64 = x
563 .iter()
564 .map(AxisValue::as_num)
565 .zip(&self.target)
566 .map(|(a, t)| -(a - t) * (a - t))
567 .sum();
568 Observation::value(v)
569 }
570 }
571
572 #[test]
573 fn sweep_is_always_available_and_sweeps_the_grid() {
574 let space = SearchSpace::new(vec![
575 Axis {
576 name: "x".into(),
577 kind: AxisKind::Discrete {
578 detents: vec![
579 AxisValue::Num(0.0),
580 AxisValue::Num(1.0),
581 AxisValue::Num(2.0),
582 ],
583 },
584 changeover: Changeover::Coordinate,
585 },
586 Axis {
587 name: "y".into(),
588 kind: AxisKind::Discrete {
589 detents: vec![
590 AxisValue::Num(0.0),
591 AxisValue::Num(1.0),
592 AxisValue::Num(2.0),
593 ],
594 },
595 changeover: Changeover::Coordinate,
596 },
597 ]);
598 let opt = by_name("sweep", &OptimizerParams::new()).expect("sweep is built in");
599 let mut obj = Paraboloid {
600 target: vec![1.0, 2.0],
601 };
602 let r = opt.optimize(&space, &mut obj, &Budget::seeded(100, 0));
603 assert_eq!(r.evals, 9);
604 assert_eq!(r.best, vec![AxisValue::Num(1.0), AxisValue::Num(2.0)]);
605 assert!(r.best_value.abs() < 1e-9);
606 assert_eq!(r.stop, StopReason::Converged);
607 }
608
609 #[test]
610 fn describe_includes_sweep_and_unknown_is_none() {
611 let infos = describe();
612 assert!(infos.iter().any(|i| i.name == "sweep"));
613 assert!(infos.iter().any(|i| i.doc_md.contains("# sweep")));
614 assert!(by_name("definitely_not_registered", &OptimizerParams::new()).is_none());
615 }
616}