feat(explorer): JSON export module + supporting metadata + example

Adds a tiny additive surface that turns any OptimizationResult into
a self-describing JSON file the heuropt-explorer webapp can load.
Real Pareto fronts have 50–200+ candidates spanning 2–7+ objectives;
reading them as numbers in a terminal scales badly. This commit
ships the heuropt-side of the explorer — the schema and the export
API. The webapp itself lives in a separate repo on its own cadence.

Three trait/type extensions, all with working defaults so existing
impls compile untouched:

- Objective gains optional `label: Option<String>` and
  `unit: Option<String>` fields, plus fluent builders
  `.with_label("Price").with_unit(\"\$k\")`. Existing
  `Objective::minimize(name)` / `Objective::maximize(name)` are
  unchanged. Both fields are #[serde(default,
  skip_serializing_if = \"Option::is_none\")] so existing JSON
  round-trips cleanly.
- Problem trait gains an optional
  `fn decision_schema(&self) -> Vec<DecisionVariable>` with default
  empty impl. Override it to provide pretty names / labels / units /
  bounds for the explorer; the default produces fallback x[0],
  x[1], … names. New DecisionVariable type at
  `heuropt::core::DecisionVariable` with builder methods.
- New `heuropt::traits::AlgorithmInfo` trait with `name()`
  (required) and `seed()` (default None). Every built-in algorithm
  — all 33 — implements it. Separate from Optimizer<P> so
  multi-fidelity Hyperband (which uses PartialProblem) implements
  it uniformly.

The new explorer module:

- `heuropt::explorer::ExplorerExport` envelope with versioned
  schema (SCHEMA_VERSION = 1).
- ExplorerCandidate per row, with front_rank from
  non_dominated_sort attached at export time so downstream tools
  don't re-derive it.
- ToDecisionValues adapter trait with provided impls for Vec<f64>,
  Vec<bool>, Vec<usize>, Vec<i64>; custom decision types implement
  one method.
- Free functions to_json / to_writer / to_file plus a builder API
  (with_algorithm_info, with_problem_name, with_wall_clock,
  with_timestamp).
- Gated on the existing `serde` feature, which now also pulls in
  `serde_json` as a dep.

The example:

- `examples/pick_a_car.rs` — promotes the README's PickACar to a
  real example, fully enriched with Objective labels/units and a
  decision_schema. Runs NSGA-III for 200 generations, prints a
  sample slice, writes pick_a_car.json. Gated on `serde`.

10 new explorer unit tests cover round-trip serde, fallback
decision-variable names, enriched export, AlgorithmInfo flow,
front-rank correctness, and the ToDecisionValues impls. Lib test
count went from 229 to 242.
This commit is contained in:
2026-05-06 12:48:01 -06:00
parent 57a43c260e
commit 729842c260
45 changed files with 1447 additions and 7 deletions
+106
View File
@@ -0,0 +1,106 @@
//! Optional schema describing a decision variable — name, label, unit,
//! and bounds. Returned by [`Problem::decision_schema`](super::Problem::decision_schema)
//! and consumed by the explorer JSON export so that the webapp can
//! render decision-variable axes with the user's preferred labels and
//! units.
//!
//! The `Problem` trait's default `decision_schema()` returns an empty
//! `Vec`, in which case the exporter generates fallback names like
//! `x[0]`, `x[1]`. Override `decision_schema()` to provide pretty
//! names, units, and bounds.
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Schema for one decision variable. All fields except `name` are
/// optional; the explorer falls back to sensible defaults when
/// they're absent.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct DecisionVariable {
/// Canonical short identifier (e.g. `"displacement"`).
pub name: String,
/// Human-readable display label (e.g. `"Engine size"`).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub label: Option<String>,
/// Display unit (e.g. `"L"`, `"kg"`, `"Cd"`).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub unit: Option<String>,
/// Lower bound, if known.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub min: Option<f64>,
/// Upper bound, if known.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub max: Option<f64>,
}
impl DecisionVariable {
/// Construct a `DecisionVariable` with just a name.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
label: None,
unit: None,
min: None,
max: None,
}
}
/// Attach a human-readable display label. Builder-style.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
/// Attach a display unit string. Builder-style.
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
/// Attach lower / upper bounds. Builder-style.
pub fn with_bounds(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_starts_with_only_name() {
let v = DecisionVariable::new("displacement");
assert_eq!(v.name, "displacement");
assert!(v.label.is_none());
assert!(v.unit.is_none());
assert!(v.min.is_none());
assert!(v.max.is_none());
}
#[test]
fn builder_methods_chain() {
let v = DecisionVariable::new("displacement")
.with_label("Engine size")
.with_unit("L")
.with_bounds(1.0, 6.0);
assert_eq!(v.label.as_deref(), Some("Engine size"));
assert_eq!(v.unit.as_deref(), Some("L"));
assert_eq!(v.min, Some(1.0));
assert_eq!(v.max, Some(6.0));
}
}
+2
View File
@@ -3,6 +3,7 @@
#[cfg(feature = "async")]
pub mod async_problem;
pub mod candidate;
pub mod decision_variable;
pub mod evaluation;
pub mod objective;
pub mod partial_problem;
@@ -14,6 +15,7 @@ pub mod rng;
#[cfg(feature = "async")]
pub use async_problem::AsyncProblem;
pub use candidate::*;
pub use decision_variable::*;
pub use evaluation::*;
pub use objective::*;
pub use partial_problem::*;
+55 -1
View File
@@ -14,13 +14,32 @@ pub enum Direction {
}
/// A named objective and its optimization direction.
///
/// `name` is the canonical short identifier (used as a key). The
/// optional `label` is a human-readable display name (e.g. "Price"
/// vs the technical name `"price_thousand_dollars"`). The optional
/// `unit` is a display unit string (e.g. `"$k"`, `"s"`, `"dB"`).
/// Both flow through to the explorer JSON export so the webapp can
/// render axes with the user's preferred labels and units.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Objective {
/// Human-readable name of the objective.
/// Canonical short identifier, used as a key.
pub name: String,
/// Whether to minimize or maximize.
pub direction: Direction,
/// Human-readable display name (defaults to `name` if not set).
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub label: Option<String>,
/// Display unit, e.g. `"$k"`, `"s"`, `"dB"`.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub unit: Option<String>,
}
impl Objective {
@@ -29,6 +48,8 @@ impl Objective {
Self {
name: name.into(),
direction: Direction::Minimize,
label: None,
unit: None,
}
}
@@ -37,8 +58,26 @@ impl Objective {
Self {
name: name.into(),
direction: Direction::Maximize,
label: None,
unit: None,
}
}
/// Attach a human-readable display label.
///
/// Builder-style; consumes and returns `self`.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
/// Attach a display unit string (e.g. `"$k"`, `"seconds"`, `"dB"`).
///
/// Builder-style; consumes and returns `self`.
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
}
/// The collection of objectives that define a problem's objective space.
@@ -114,6 +153,21 @@ mod tests {
assert_eq!(o.direction, Direction::Maximize);
}
#[test]
fn label_and_unit_default_to_none_and_round_trip_through_builders() {
let o = Objective::minimize("price");
assert!(o.label.is_none());
assert!(o.unit.is_none());
let o = Objective::minimize("price")
.with_label("Price")
.with_unit("$k");
assert_eq!(o.label.as_deref(), Some("Price"));
assert_eq!(o.unit.as_deref(), Some("$k"));
assert_eq!(o.direction, Direction::Minimize);
assert_eq!(o.name, "price");
}
#[test]
fn as_minimization_negates_maximize_only() {
let space = ObjectiveSpace::new(vec![
+15
View File
@@ -1,5 +1,6 @@
//! The user-implemented `Problem` trait.
use crate::core::decision_variable::DecisionVariable;
use crate::core::evaluation::Evaluation;
use crate::core::objective::ObjectiveSpace;
@@ -24,4 +25,18 @@ pub trait Problem {
/// Evaluate a decision. Must not mutate `self`.
fn evaluate(&self, decision: &Self::Decision) -> Evaluation;
/// Optional schema describing each decision variable — names,
/// labels, units, and bounds. Used by the explorer JSON export
/// to label decision-variable axes with the user's preferred
/// names and units. Default: empty (the exporter generates
/// fallback names like `x[0]`, `x[1]`).
///
/// Override this on your `Problem` impl to provide pretty
/// metadata. The returned vector should have one entry per
/// element of the decision; if its length doesn't match, the
/// exporter fills the remainder with `x[i]` defaults.
fn decision_schema(&self) -> Vec<DecisionVariable> {
Vec::new()
}
}