Skip to main content

nmbrs_runtime/optimize/
contract.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-86 — the **core optimizer contract**.
5//!
6//! This is the service boundary the phase-execution driver uses to drive
7//! an optimizer. It is defined *here, in the core runtime, with no
8//! dependency on any optimizer-algorithm crate* (SRD-86 §Contract): the
9//! engine owns the contract; algorithm crates (e.g. `nmbrs-optimizers`)
10//! register implementations against it via `inventory` and are discovered
11//! at link time — the core never names them.
12//!
13//! The contract has two halves:
14//! - what the optimizer needs from the driver: an [`Objective`] it can
15//!   query at a coordinate (the runtime's `PhaseFeed` implements it);
16//! - what the driver needs from the optimizer: an [`Optimizer`] that, over
17//!   a [`SearchSpace`] within a [`Budget`], maximizes the objective and
18//!   returns a [`Report`].
19//!
20//! [`SweepOptimizer`] (`sweep`) is the built-in default (the full Cartesian
21//! sweep + best-selection) — the identity until an adaptive method is named.
22//! Algorithm crates submit an [`OptimizerRegistration`]; [`by_name`] and
23//! [`describe`] discover them.
24
25/// How an axis's value is changed at runtime (cost prior; the runtime
26/// owns the actual realization — SRD-86 A5).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Changeover {
29    Control,
30    Coordinate,
31    Fixture,
32}
33
34/// The realized value an axis takes at a coordinate — the comprehension's
35/// actual value (SRD-86 A4). The model carries labels/detents as-is; numeric
36/// casting for metric-space solvers is a solver-internal stub (the runtime
37/// bridge), never in this model.
38#[derive(Debug, Clone, PartialEq)]
39pub enum AxisValue {
40    Num(f64),
41    Label(String),
42    Bool(bool),
43}
44
45impl AxisValue {
46    /// Project to the `f64` a numeric solver / manifold objective reads. A
47    /// `Label` has no intrinsic number (categorical axes are handled by index,
48    /// not this), so it yields `0.0` defensively.
49    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
80/// A point in the search space — one [`AxisValue`] per axis. The model carries
81/// the comprehension's actual values, so labels survive end-to-end.
82pub type Coord = Vec<AxisValue>;
83
84/// The domain of one axis.
85#[derive(Debug, Clone)]
86pub enum AxisKind {
87    /// A real interval `[lo, hi]` (optionally quantized to `min_step`).
88    Continuous { lo: f64, hi: f64, min_step: f64 },
89    /// Ordered numeric detents — ordinal, so distance/order are meaningful.
90    Discrete { detents: Vec<AxisValue> },
91    /// Nominal choices (labels / bools) — a **stable, positionally-ordered**
92    /// list (position is meaningful: "the first option" is well-defined and
93    /// stable), but with **no metric/distance** between values. Handled by
94    /// index, never sorted.
95    Categorical { options: Vec<AxisValue> },
96}
97
98/// One factor of the search space.
99#[derive(Debug, Clone)]
100pub struct Axis {
101    pub name: String,
102    pub kind: AxisKind,
103    pub changeover: Changeover,
104}
105
106impl Axis {
107    /// The centre value of this axis — continuous midpoint (`Num`), the median
108    /// detent, or the first categorical option. The default best.
109    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/// The search space the optimizer explores.
129#[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/// One objective evaluation result.
147#[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
174/// The thing an optimizer queries. The runtime's `PhaseFeed` implements it
175/// by running a probe phase at the coordinate and reading the objective
176/// metric. The optimizer **maximizes** [`Observation::value`].
177pub trait Objective {
178    fn query(&mut self, x: &[AxisValue]) -> Observation;
179    /// Evaluate at a reduced *fidelity* in `(0, 1]` (1.0 = full). Default
180    /// ignores fidelity (used by multi-fidelity methods).
181    fn query_fidelity(&mut self, x: &[AxisValue], _fidelity: f64) -> Observation {
182        self.query(x)
183    }
184}
185
186/// Evaluation budget + reproducibility seed.
187#[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/// Why a search ended (maps to a two-axis Outcome at the seam — SRD-86 A8).
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum StopReason {
207    Converged,
208    BudgetExhausted,
209    NoFeasiblePoint,
210    Aborted,
211}
212
213/// Per-axis sensitivity, populated by screening optimizers.
214#[derive(Debug, Clone)]
215pub struct AxisImpact {
216    pub name: String,
217    pub main_effect: f64,
218    pub curvature: f64,
219}
220
221/// The result of a search.
222#[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/// Optimizer-specific tuning knobs (resolved by name with a default).
233#[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
256/// A coordinate source the graph executor pulls through (SRD-86). The source
257/// advertises *capability decorators*; the executor probes them, **favoring
258/// the most capable** ([`FeedbackSource`]) and requiring at least one.
259pub trait CoordinateSource: Send {
260    /// The feedback-driven decorator (adaptive optimizers), or `None` if this
261    /// source ignores objective feedback.
262    fn as_feedback(&mut self) -> Option<&mut dyn FeedbackSource> {
263        None
264    }
265    /// The pull-only decorator (sweep / lex / discrete traversal), or `None` if
266    /// this source is feedback-only.
267    fn as_pull(&mut self) -> Option<&mut dyn PullSource> {
268        None
269    }
270}
271
272/// Least-capable decorator: yield the next batch of coordinates without
273/// feedback. The default lexicographic stream and discrete reorderings are
274/// pull sources.
275pub trait PullSource: Send {
276    fn pull(&mut self) -> Option<Vec<Coord>>;
277}
278
279/// Most-capable decorator — *the optimizer primitive*. Given the
280/// just-evaluated `(coord, objective)` pairs, yield the next batch of
281/// coordinates to evaluate, or `None` when the search is done. The first call
282/// receives an empty slice (it produces the initial batch). Batch methods
283/// (CMA-ES generation, Hyperband bracket) return several coordinates at once
284/// and buffer the matching values across calls.
285pub trait FeedbackSource {
286    fn step(&mut self, evaluated: &[(Coord, f64)]) -> Option<Vec<Coord>>;
287}
288
289/// The default lexicographic coordinate stream over a search space: the full
290/// Cartesian product of the discrete-axis detents and the `{lo, hi}` corners
291/// of continuous axes, in lex order, one coordinate per `pull`. The `sweep`
292/// functor returns it unchanged (identity).
293pub 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        // Advance the mixed-radix counter (last axis fastest).
329        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
346/// Wraps a boxed [`PullSource`] as a pull-only [`CoordinateSource`].
347pub 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
355/// A non-derivative, multi-factor optimizer — a **stateless functor** that
356/// transforms the default lexicographic coordinate stream into a (possibly
357/// adaptive) [`CoordinateSource`] (SRD-86). The search state lives in the
358/// produced source, not in the optimizer.
359pub trait Optimizer: Send {
360    /// The registered name (matches the registry key).
361    fn name(&self) -> &str;
362    /// User-level documentation, in **markdown** (SRD-86 §5). Surfaced by
363    /// `nmbrs describe optimizers`.
364    fn doc_md(&self) -> &str;
365    /// Build the coordinate source from the param space, the budget, and the
366    /// default lex stream. `sweep` returns `lex` unchanged (identity). The
367    /// source owns its own termination (budget / convergence); the driver
368    /// pulls until it yields `None`.
369    fn coordinate_source(
370        &self,
371        space: &SearchSpace,
372        budget: &Budget,
373        lex: Box<dyn PullSource>,
374    ) -> Box<dyn CoordinateSource>;
375
376    /// Standalone driver: run the search by pulling this optimizer's source
377    /// and querying `obj`, returning a [`Report`]. Used for unit testing and
378    /// the non-runtime path; the graph executor drives the source itself
379    /// (pull-through, observing each phase's objective wire).
380    fn optimize(&self, space: &SearchSpace, obj: &mut dyn Objective, budget: &Budget) -> Report {
381        drive_source(self, space, obj, budget)
382    }
383}
384
385/// The shared default-driver loop for [`Optimizer::optimize`]: pull a batch
386/// from the source (via its most-capable decorator), evaluate each coordinate
387/// against `obj`, feed the values back, and track the best.
388fn 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
460/// The built-in default optimizer: the **identity functor**. It returns the
461/// default lexicographic stream unchanged — a full Cartesian sweep, the
462/// engine's ordinary parameter enumeration. Always available, no plugin
463/// required (SRD-86 A1).
464pub 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)) // identity
487    }
488}
489
490// ===========================================================================
491// Registry — inventory-based, link-time collection (mirrors the adapter
492// registration pattern in `adapter.rs`). Algorithm crates submit one
493// `OptimizerRegistration` per optimizer; the core discovers them with no
494// dependency on the algorithm crate.
495// ===========================================================================
496
497/// An optimizer implementation's registration, submitted at link time via
498/// `inventory` by an algorithm crate (e.g. `nmbrs-optimizers`).
499pub struct OptimizerRegistration {
500    /// The optimizer's registry name (`"nelder_mead"`, `"cmaes"`, …).
501    pub name: fn() -> &'static str,
502    /// User-level markdown documentation (SRD-86 §5).
503    pub doc_md: fn() -> &'static str,
504    /// Factory: build the optimizer from its params.
505    pub make: fn(&OptimizerParams) -> Box<dyn Optimizer>,
506}
507
508inventory::collect!(OptimizerRegistration);
509
510/// Name + markdown doc for one optimizer, for `nmbrs describe optimizers`.
511#[derive(Debug, Clone)]
512pub struct OptimizerInfo {
513    pub name: &'static str,
514    pub doc_md: &'static str,
515}
516
517/// Resolve an optimizer by name: the built-in `sweep`, or a link-time
518/// registration. Returns `None` for an unknown name.
519pub 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
529/// Every available optimizer (the built-in `sweep` + all registrations),
530/// sorted by name, for `nmbrs describe optimizers`.
531pub 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
547/// Every registered optimizer name (built-in `sweep` + registrations).
548pub 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    /// A synthetic objective: a negated paraboloid maximized at `target`.
557    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}