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
+9
View File
@@ -424,6 +424,15 @@ fn estimate_p(front_indices: &[usize], translated: &[Vec<f64>], m: usize) -> f64
best_p
}
impl<I, V> crate::traits::AlgorithmInfo for AgeMoea<I, V> {
fn name(&self) -> &'static str {
"AgeMoea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -429,6 +429,15 @@ fn better_than_so(
}
}
impl crate::traits::AlgorithmInfo for AntColonyTsp {
fn name(&self) -> &'static str {
"AntColonyTsp"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -540,6 +540,15 @@ impl BayesianOpt {
}
}
impl crate::traits::AlgorithmInfo for BayesianOpt {
fn name(&self) -> &'static str {
"BayesianOpt"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -628,6 +628,15 @@ fn better_than_so(
compare_so(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for CmaEs {
fn name(&self) -> &'static str {
"CmaEs"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -303,6 +303,15 @@ fn pick_three_distinct(
(a, b, c)
}
impl crate::traits::AlgorithmInfo for DifferentialEvolution {
fn name(&self) -> &'static str {
"DifferentialEvolution"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -396,6 +396,15 @@ fn box_dominates(a: &[i64], b: &[i64]) -> bool {
strictly_less
}
impl<I, V> crate::traits::AlgorithmInfo for EpsilonMoea<I, V> {
fn name(&self) -> &'static str {
"EpsilonMoea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -317,6 +317,15 @@ fn compare_for_fitness<D>(
}
}
impl<I, V> crate::traits::AlgorithmInfo for GeneticAlgorithm<I, V> {
fn name(&self) -> &'static str {
"GeneticAlgorithm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -334,6 +334,15 @@ fn environmental_selection<D: Clone>(
selected.into_iter().map(|i| combined[i].clone()).collect()
}
impl<I, V> crate::traits::AlgorithmInfo for Grea<I, V> {
fn name(&self) -> &'static str {
"Grea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -224,6 +224,15 @@ impl<I, V> HillClimber<I, V> {
}
}
impl<I, V> crate::traits::AlgorithmInfo for HillClimber<I, V> {
fn name(&self) -> &'static str {
"HillClimber"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -459,6 +459,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Hype<I, V> {
fn name(&self) -> &'static str {
"Hype"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+13
View File
@@ -329,6 +329,19 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
compare(a, b, direction) == std::cmp::Ordering::Less
}
impl<I, D> crate::traits::AlgorithmInfo for Hyperband<I, D>
where
D: Clone,
I: Initializer<D>,
{
fn name(&self) -> &'static str {
"Hyperband"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -385,6 +385,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Ibea<I, V> {
fn name(&self) -> &'static str {
"Ibea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -287,6 +287,15 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
impl crate::traits::AlgorithmInfo for IpopCmaEs {
fn name(&self) -> &'static str {
"IpopCmaEs"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -328,6 +328,15 @@ fn perpendicular_distance(point: &[f64], extremes: &[usize], oriented: &[Vec<f64
(dot - b).abs() / norm
}
impl<I, V> crate::traits::AlgorithmInfo for Knea<I, V> {
fn name(&self) -> &'static str {
"Knea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -363,6 +363,15 @@ fn weight_distance(a: &[f64], b: &[f64]) -> f64 {
.sqrt()
}
impl<I, V> crate::traits::AlgorithmInfo for Moead<I, V> {
fn name(&self) -> &'static str {
"Moead"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -330,6 +330,15 @@ impl Mopso {
}
}
impl crate::traits::AlgorithmInfo for Mopso {
fn name(&self) -> &'static str {
"Mopso"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -450,6 +450,12 @@ impl NelderMead {
}
}
impl crate::traits::AlgorithmInfo for NelderMead {
fn name(&self) -> &'static str {
"NelderMead"
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -364,6 +364,15 @@ fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Nsga2<I, V> {
fn name(&self) -> &'static str {
"Nsga2"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -542,6 +542,15 @@ fn associate(
(assoc, dist)
}
impl<I, V> crate::traits::AlgorithmInfo for Nsga3<I, V> {
fn name(&self) -> &'static str {
"Nsga3"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -285,6 +285,15 @@ impl OnePlusOneEs {
}
}
impl crate::traits::AlgorithmInfo for OnePlusOneEs {
fn name(&self) -> &'static str {
"OnePlusOneEs"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -242,6 +242,15 @@ impl<I, V> Paes<I, V> {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Paes<I, V> {
fn name(&self) -> &'static str {
"Paes"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -357,6 +357,15 @@ fn best_index(values: &[f64], direction: Direction) -> usize {
idx
}
impl crate::traits::AlgorithmInfo for ParticleSwarm {
fn name(&self) -> &'static str {
"ParticleSwarm"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -409,6 +409,15 @@ fn truncate_by_grid<D: Clone>(archive: &mut ParetoArchive<D>, max_size: usize, d
}
}
impl<I, V> crate::traits::AlgorithmInfo for PesaII<I, V> {
fn name(&self) -> &'static str {
"PesaII"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -158,6 +158,15 @@ impl<I> RandomSearch<I> {
}
}
impl<I> crate::traits::AlgorithmInfo for RandomSearch<I> {
fn name(&self) -> &'static str {
"RandomSearch"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -466,6 +466,15 @@ fn smallest_neighbor_angle(references: &[Vec<f64>]) -> f64 {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Rvea<I, V> {
fn name(&self) -> &'static str {
"Rvea"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -333,6 +333,15 @@ impl<I, V> SimulatedAnnealing<I, V> {
}
}
impl<I, V> crate::traits::AlgorithmInfo for SimulatedAnnealing<I, V> {
fn name(&self) -> &'static str {
"SimulatedAnnealing"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -289,6 +289,15 @@ fn pick_drop_index<D>(
worst_front[worst_idx_in_front]
}
impl<I, V> crate::traits::AlgorithmInfo for SmsEmoa<I, V> {
fn name(&self) -> &'static str {
"SmsEmoa"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -389,6 +389,15 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
compare(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for SeparableNes {
fn name(&self) -> &'static str {
"SeparableNes"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -503,6 +503,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
}
}
impl<I, V> crate::traits::AlgorithmInfo for Spea2<I, V> {
fn name(&self) -> &'static str {
"Spea2"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+14
View File
@@ -331,6 +331,20 @@ where
}
}
impl<D, I, N> crate::traits::AlgorithmInfo for TabuSearch<D, I, N>
where
D: Clone + Hash + Eq,
I: Initializer<D>,
N: FnMut(&D, &mut Rng) -> Vec<D>,
{
fn name(&self) -> &'static str {
"TabuSearch"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -325,6 +325,15 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
}
}
impl crate::traits::AlgorithmInfo for Tlbo {
fn name(&self) -> &'static str {
"Tlbo"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -479,6 +479,15 @@ impl Tpe {
}
}
impl crate::traits::AlgorithmInfo for Tpe {
fn name(&self) -> &'static str {
"Tpe"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -352,6 +352,15 @@ fn better_than_so(
compare_so(a, b, direction) == std::cmp::Ordering::Less
}
impl crate::traits::AlgorithmInfo for Umda {
fn name(&self) -> &'static str {
"Umda"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)]
mod tests {
use super::*;