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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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![
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user