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
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "heuropt"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -15,7 +15,7 @@ categories = ["algorithms", "science", "mathematics", "simulation"]
[features]
default = []
serde = ["dep:serde"]
serde = ["dep:serde", "dep:serde_json"]
parallel = ["dep:rayon"]
async = ["dep:futures"]
@@ -25,6 +25,7 @@ rand = "0.9"
rand_distr = "0.5"
rayon = { version = "1", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
[dev-dependencies]
gungraun = "0.18"
@@ -39,6 +40,10 @@ harness = false
name = "async_eval"
required-features = ["async"]
[[example]]
name = "pick_a_car"
required-features = ["serde"]
# Tighten release codegen for the compare harness and downstream binaries
# that build heuropt directly (i.e. when this crate is the workspace root).
# When heuropt is used as a dependency the consumer's profile wins.
+191
View File
@@ -0,0 +1,191 @@
# Explore your results in a webapp
Real Pareto fronts have 50200+ candidates spanning 27+ objectives.
Reading them as a wall of numbers in a terminal scales badly. Drop
the result into [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
to filter, brush, pin, and rank candidates interactively in the
browser — parallel coordinates, scatter plots, sortable table, range
filters, weighted ranking, knee-point detection.
This recipe shows the export side. The webapp is a static page; no
install needed beyond a browser.
## Enable the `serde` feature
```toml
[dependencies]
heuropt = { version = "0.9", features = ["serde"] }
```
The export uses `serde_json` under the hood, so the explorer module
is gated on the existing `serde` feature.
## Enrich your `Problem` (optional but worth it)
Two places to add display metadata that flows through to the
explorer's axis labels and tooltips:
```rust
use heuropt::prelude::*;
struct PickACar;
impl Problem for PickACar {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
// `name` is the canonical short ID; `label` and `unit`
// are display-only. The explorer renders axes as
// `Price ($k)` instead of just `price`.
Objective::minimize("price").with_label("Price").with_unit("$k"),
Objective::minimize("zero_to_sixty").with_label("0-60 mph").with_unit("s"),
Objective::minimize("fuel").with_label("Fuel").with_unit("gal/100mi"),
Objective::minimize("noise").with_label("Idle noise").with_unit("dB"),
])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
// Optional: provide name/label/unit/bounds per decision-variable
// slot. If you skip this, the exporter falls back to `x[0]`,
// `x[1]`, … with no units or bounds.
vec![
DecisionVariable::new("displacement")
.with_label("Engine size").with_unit("L").with_bounds(1.0, 6.0),
DecisionVariable::new("weight")
.with_label("Curb weight").with_unit("kg").with_bounds(1100.0, 2200.0),
DecisionVariable::new("drag")
.with_label("Drag coefficient").with_unit("Cd").with_bounds(0.20, 0.40),
]
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
// ... compute objectives ...
# Evaluation::new(vec![0.0, 0.0, 0.0, 0.0])
}
}
```
Both `Objective::with_label` / `with_unit` and `Problem::decision_schema`
are entirely optional — the rest of heuropt doesn't read them. They
exist so the exported JSON describes itself well enough for a
display tool to render readable axes.
## Run the optimizer and write the JSON
The simplest call (no algorithm metadata in the export):
```rust,ignore
use heuropt::prelude::*;
let result = optimizer.run(&problem);
heuropt::explorer::ExplorerExport::from_result(&problem, &result)
.to_file("results.json")
.unwrap();
```
The richer call — pulls algorithm name + seed automatically from
the `AlgorithmInfo` trait that every built-in algorithm implements:
```rust,ignore
use heuropt::prelude::*;
let started = std::time::Instant::now();
let result = optimizer.run(&problem);
let export = heuropt::explorer::ExplorerExport::from_result(&problem, &result)
.with_algorithm_info(&optimizer)
.with_problem_name("Pick a car")
.with_wall_clock(started.elapsed().as_secs_f64());
export.to_file("results.json").unwrap();
```
There's also a one-liner if you don't need to set extra metadata:
```rust,ignore
heuropt::explorer::to_file("results.json", &problem, &optimizer, &result).unwrap();
```
## Open it in the explorer
Visit <https://swaits.github.io/heuropt-explorer/> and drag the JSON
file onto the page. The explorer reads the units and labels you
attached and renders parallel-coordinates / scatter / table views
that respect them. Brushing on any axis filters the others; pinned
candidates stay highlighted; the weight sliders let you rank the
front by your priorities.
## What's in the file
The full schema is documented in
[`heuropt::explorer::ExplorerExport`](https://docs.rs/heuropt/latest/heuropt/explorer/struct.ExplorerExport.html).
The shape:
```json
{
"schema_version": 1,
"run": {
"problem_name": "Pick a car",
"algorithm": "Nsga3",
"seed": 42,
"wall_clock_seconds": 0.097,
"evaluations": 20100,
"generations": 200
},
"objectives": [
{ "name": "price", "direction": "Minimize", "label": "Price", "unit": "$k" },
...
],
"decision_variables": [
{ "name": "displacement", "label": "Engine size", "unit": "L", "min": 1.0, "max": 6.0 },
...
],
"candidates": [
{
"decision": [1.0, 1505.0, 0.35],
"objectives": [13.0, 7.0, 3.17, 63.0],
"constraint_violation": 0.0,
"feasible": true,
"front_rank": 0,
"in_pareto_front": true
},
...
]
}
```
`front_rank` is computed by `non_dominated_sort` once at export
time — `0` means on the Pareto front, higher numbers indicate
deeper layers.
## Custom decision types
Out of the box, `Vec<f64>`, `Vec<bool>`, `Vec<usize>`, and `Vec<i64>`
work as decisions. For a custom decision type, implement
`heuropt::explorer::ToDecisionValues`:
```rust,ignore
struct MyDecision { color: String, count: u32 }
impl heuropt::explorer::ToDecisionValues for MyDecision {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
vec![
serde_json::Value::String(self.color.clone()),
serde_json::Value::Number(self.count.into()),
]
}
}
```
The explorer renders strings as categorical axes and numbers as
continuous.
## Worked example
`examples/pick_a_car.rs` ships with the crate. It implements the
problem above, runs NSGA-III for 200 generations, and writes
`pick_a_car.json` ready to load:
```text
cargo run --release --example pick_a_car --features serde
```
+167
View File
@@ -0,0 +1,167 @@
//! `pick_a_car` — designing a car along four objectives at once.
//!
//! Three decision variables (engine displacement, curb weight,
//! aerodynamic drag) and four objectives (price, 0-60 acceleration,
//! fuel consumption, idle noise) coupled by non-linear cost
//! relationships, so the Pareto front is a real surface in 3D
//! decision space — not a 1D sweep that any human could enumerate.
//!
//! Run it:
//!
//! ```text
//! cargo run --release --example pick_a_car --features serde
//! ```
//!
//! It writes a `pick_a_car.json` file in the current directory that
//! you can drop into <https://swaits.github.io/heuropt-explorer/> to
//! filter, brush, pin, and rank the 100-car Pareto front
//! interactively.
use heuropt::prelude::*;
struct PickACar;
impl Problem for PickACar {
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("price")
.with_label("Price")
.with_unit("$k"),
Objective::minimize("zero_to_sixty")
.with_label("0-60 mph")
.with_unit("s"),
Objective::minimize("fuel")
.with_label("Fuel")
.with_unit("gal/100mi"),
Objective::minimize("noise")
.with_label("Idle noise")
.with_unit("dB"),
])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
vec![
DecisionVariable::new("displacement")
.with_label("Engine size")
.with_unit("L")
.with_bounds(1.0, 6.0),
DecisionVariable::new("weight")
.with_label("Curb weight")
.with_unit("kg")
.with_bounds(1100.0, 2200.0),
DecisionVariable::new("drag")
.with_label("Drag coefficient")
.with_unit("Cd")
.with_bounds(0.20, 0.40),
]
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let displacement = x[0];
let weight = x[1];
let drag = x[2];
// Price ($k): engine cost grows superlinearly; weight reduction
// below 1500 kg and drag reduction below 0.35 Cd both cost extra.
let engine_cost = 3.0 * displacement.powf(1.6);
let weight_cost = ((1500.0 - weight).max(0.0) / 100.0).powi(2) * 2.0;
let aero_cost = ((0.35 - drag).max(0.0) * 100.0).powf(1.5) * 0.4;
let price = 10.0 + engine_cost + weight_cost + aero_cost;
// 0-60 (s): heavier = slower; bigger engine = quicker but
// with diminishing returns.
let weight_factor = (weight - 1100.0) / 1000.0;
let engine_factor = ((displacement - 1.0) / 5.0).max(0.0).powf(0.7);
let zero_to_sixty = 5.0 + 5.0 * weight_factor - 4.0 * engine_factor;
// Fuel consumption (gal/100 mi): all three decision vars matter.
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
// Idle noise (dB): engine dominates, mildly non-linear.
let noise = 60.0 + 3.0 * displacement.powf(1.2);
Evaluation::new(vec![price, zero_to_sixty, fuel, noise])
}
}
fn main() {
let bounds = vec![
(1.0_f64, 6.0_f64), // engine
(1100.0_f64, 2200.0_f64), // weight
(0.20_f64, 0.40_f64), // drag
];
let started = std::time::Instant::now();
let mut optimizer = Nsga3::new(
Nsga3Config {
population_size: 100,
generations: 200,
reference_divisions: 5,
seed: 42,
},
RealBounds::new(bounds.clone()),
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.9),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 3.0),
},
);
let result = optimizer.run(&PickACar);
let elapsed = started.elapsed().as_secs_f64();
// Print a short summary across the front so the user can see what
// they got without leaving the terminal.
let mut front: Vec<_> = result.pareto_front.iter().collect();
front.sort_by(|a, b| {
a.evaluation.objectives[0]
.partial_cmp(&b.evaluation.objectives[0])
.unwrap()
});
println!(
"Pareto front: {} cars (took {:.3} s)\n",
front.len(),
elapsed,
);
println!(
"{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB"
);
let n = front.len();
let sample_indices = if n <= 6 {
(0..n).collect::<Vec<_>>()
} else {
// Six representative rows: first, ~20%, ~40%, ~60%, ~80%, last
vec![0, n / 5, (2 * n) / 5, (3 * n) / 5, (4 * n) / 5, n - 1]
};
for &i in &sample_indices {
let c = front[i];
let d = &c.decision;
let o = &c.evaluation.objectives;
println!(
"{:>5.2} {:>5.0} {:>4.2} {:>6.1} {:>5.1} {:>5.2} {:>5.1}",
d[0], d[1], d[2], o[0], o[1], o[2], o[3]
);
}
// Write the explorer JSON. With the metadata the Problem provides
// (objective labels + units + decision schema) plus the algorithm's
// own AlgorithmInfo, this is genuinely zero-config: one call.
let path = "pick_a_car.json";
let export = heuropt::explorer::ExplorerExport::from_result(&PickACar, &result)
.with_algorithm_info(&optimizer)
.with_problem_name("Pick a car")
.with_wall_clock(elapsed);
export.to_file(path).expect("failed to write JSON");
println!(
"\nWrote {} candidates to {} ({}/{} on the Pareto front).",
result.population.candidates.len(),
path,
result.pareto_front.len(),
result.population.candidates.len(),
);
println!("Drop it into https://swaits.github.io/heuropt-explorer/ to explore.");
}
+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::*;
+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()
}
}
+563
View File
@@ -0,0 +1,563 @@
//! Explorer JSON export — serialize an `OptimizationResult` to a
//! self-describing JSON file that the
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
//! webapp can load and explore interactively.
//!
//! ## Quick start
//!
//! ```ignore
//! use heuropt::prelude::*;
//!
//! let result = optimizer.run(&problem);
//!
//! // Zero-config — pulls metadata from `problem.objectives()`,
//! // `problem.decision_schema()`, and the algorithm's `AlgorithmInfo`.
//! heuropt::explorer::to_file("results.json", &problem, &optimizer, &result)?;
//! ```
//!
//! Drop the resulting `results.json` into the explorer at
//! <https://swaits.github.io/heuropt-explorer/> to filter, brush,
//! pin, and rank candidates.
//!
//! ## What's in the export
//!
//! The output contains:
//! - `schema_version` — an integer the explorer uses to detect
//! incompatible files. Bump on breaking schema changes.
//! - `run` — algorithm name, seed, evaluations, generations, and
//! optional problem name / wall-clock seconds.
//! - `objectives` — name, direction, and (if set) `label` and
//! `unit` so the explorer can render axes like `Price ($k)`.
//! - `decision_variables` — name, label, unit, and bounds for each
//! decision-variable slot. If `Problem::decision_schema()` returns
//! fewer entries than the decision length, the exporter pads with
//! fallback names like `x[0]`, `x[1]`.
//! - `candidates` — the full population, each tagged with its
//! front rank (from `non_dominated_sort`), feasibility, and
//! whether it sits on the Pareto front.
//!
//! Everything is gated on the `serde` feature, since the export
//! uses `serde_json`.
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::candidate::Candidate;
use crate::core::decision_variable::DecisionVariable;
use crate::core::objective::Objective;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
use crate::pareto::sort::non_dominated_sort;
use crate::traits::AlgorithmInfo;
/// JSON schema version embedded in every export. The explorer
/// webapp checks this on load and rejects files with an unknown
/// version. Bump on breaking schema changes.
pub const SCHEMA_VERSION: u32 = 1;
/// Serialized envelope describing one optimization run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplorerExport {
/// Schema version (always equal to [`SCHEMA_VERSION`] when written).
pub schema_version: u32,
/// Run metadata — algorithm, seed, eval/generation counts.
pub run: RunMeta,
/// Objective definitions, with optional `label` / `unit` if set.
pub objectives: Vec<Objective>,
/// Decision-variable schemas, padded with fallback `x[i]` names
/// when the user didn't override `Problem::decision_schema()`.
pub decision_variables: Vec<DecisionVariable>,
/// One row per candidate in the final population.
pub candidates: Vec<ExplorerCandidate>,
}
/// Per-candidate row in [`ExplorerExport`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplorerCandidate {
/// Decision values, one entry per decision variable. Numbers,
/// booleans, integers, or strings — whatever the
/// [`ToDecisionValues`] impl produces for the decision type.
pub decision: Vec<serde_json::Value>,
/// Objective values, parallel to the `objectives` array.
pub objectives: Vec<f64>,
/// Constraint violation magnitude (≤ 0 means feasible).
pub constraint_violation: f64,
/// Convenience: `true` iff `constraint_violation <= 0.0`.
pub feasible: bool,
/// Non-domination rank from `non_dominated_sort`. `0` means
/// on the first front (Pareto front).
pub front_rank: usize,
/// `true` iff this candidate is on the first front. (Same as
/// `front_rank == 0` for the rank-0 set, kept as an explicit
/// field so downstream tools don't have to re-derive it.)
pub in_pareto_front: bool,
}
/// Run-level metadata: algorithm name, seed, eval count, etc.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RunMeta {
/// Optional human-readable problem name (e.g. `"Pick a car"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem_name: Option<String>,
/// Canonical algorithm name (e.g. `"Nsga3"`). Pulled from
/// [`AlgorithmInfo::name`] when an algorithm is provided.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
/// Seed driving this run, if applicable. Pulled from
/// [`AlgorithmInfo::seed`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
/// Wall-clock duration of the run, in seconds. Optional —
/// the user provides this if they timed the run externally.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wall_clock_seconds: Option<f64>,
/// Total number of `Problem::evaluate` calls.
pub evaluations: usize,
/// Number of major optimizer iterations.
pub generations: usize,
/// Optional ISO-8601 timestamp recorded at export time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
/// Adapter trait that converts a decision value into a vector of
/// `serde_json::Value`s (one per element). Implemented for the
/// common decision types out of the box; users with custom
/// decision types implement it themselves.
pub trait ToDecisionValues {
/// Convert the decision into one JSON value per decision-variable
/// slot.
fn to_decision_values(&self) -> Vec<serde_json::Value>;
}
impl ToDecisionValues for Vec<f64> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|v| {
serde_json::Number::from_f64(*v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
})
.collect()
}
}
impl ToDecisionValues for Vec<bool> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter().map(|b| serde_json::Value::Bool(*b)).collect()
}
}
impl ToDecisionValues for Vec<usize> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i as u64)))
.collect()
}
}
impl ToDecisionValues for Vec<i64> {
fn to_decision_values(&self) -> Vec<serde_json::Value> {
self.iter()
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i)))
.collect()
}
}
impl ExplorerExport {
/// Build an `ExplorerExport` from a problem and its result.
/// The run metadata is initially empty (no algorithm / seed);
/// chain `with_algorithm_info` or the individual setters to
/// populate it.
pub fn from_result<P>(problem: &P, result: &OptimizationResult<P::Decision>) -> Self
where
P: Problem,
P::Decision: ToDecisionValues,
{
let objective_space = problem.objectives();
let n_obj = objective_space.objectives.len();
let user_schema = problem.decision_schema();
let decision_arity = result
.population
.candidates
.first()
.map(|c| c.decision.to_decision_values().len())
.unwrap_or(user_schema.len());
let decision_variables = pad_decision_schema(user_schema, decision_arity);
let pop_slice: &[Candidate<P::Decision>] = &result.population.candidates;
let fronts = non_dominated_sort(pop_slice, &objective_space);
let mut rank_of: Vec<usize> = vec![0; pop_slice.len()];
for (rank, front) in fronts.iter().enumerate() {
for &idx in front {
rank_of[idx] = rank;
}
}
let candidates = pop_slice
.iter()
.enumerate()
.map(|(i, c)| candidate_to_export(c, rank_of[i], n_obj))
.collect();
Self {
schema_version: SCHEMA_VERSION,
run: RunMeta {
evaluations: result.evaluations,
generations: result.generations,
..RunMeta::default()
},
objectives: objective_space.objectives,
decision_variables,
candidates,
}
}
/// Populate `algorithm` and `seed` from anything implementing
/// [`AlgorithmInfo`] — every built-in algorithm does.
pub fn with_algorithm_info<A: AlgorithmInfo>(mut self, algorithm: &A) -> Self {
self.run.algorithm = Some(algorithm.name().to_owned());
self.run.seed = algorithm.seed();
self
}
/// Override the problem name shown in the explorer header.
pub fn with_problem_name(mut self, name: impl Into<String>) -> Self {
self.run.problem_name = Some(name.into());
self
}
/// Attach a wall-clock duration in seconds.
pub fn with_wall_clock(mut self, seconds: f64) -> Self {
self.run.wall_clock_seconds = Some(seconds);
self
}
/// Attach an ISO-8601 timestamp string (the caller formats it).
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.run.timestamp = Some(timestamp.into());
self
}
/// Serialize to a pretty-printed JSON string.
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
/// Serialize to any `Write` sink as pretty-printed JSON.
pub fn to_writer<W: Write>(&self, writer: W) -> serde_json::Result<()> {
serde_json::to_writer_pretty(writer, self)
}
/// Write the export to a file as pretty-printed JSON. Creates
/// the file (truncating if it exists) and returns any I/O or
/// serialization error.
pub fn to_file<Q: AsRef<Path>>(&self, path: Q) -> std::io::Result<()> {
let file = std::fs::File::create(path)?;
let writer = std::io::BufWriter::new(file);
self.to_writer(writer)
.map_err(|e| std::io::Error::other(e.to_string()))
}
}
/// Convenience: build an [`ExplorerExport`] from problem +
/// algorithm + result, with `algorithm` and `seed` populated from
/// the [`AlgorithmInfo`] trait, then serialize to a pretty JSON
/// string.
pub fn to_json<P, A>(
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> serde_json::Result<String>
where
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_json()
}
/// Convenience: same as [`to_json`] but writes to any `Write`.
pub fn to_writer<W, P, A>(
writer: W,
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> serde_json::Result<()>
where
W: Write,
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_writer(writer)
}
/// Convenience: same as [`to_json`] but writes directly to a
/// file path.
pub fn to_file<Q, P, A>(
path: Q,
problem: &P,
algorithm: &A,
result: &OptimizationResult<P::Decision>,
) -> std::io::Result<()>
where
Q: AsRef<Path>,
P: Problem,
P::Decision: ToDecisionValues,
A: AlgorithmInfo,
{
ExplorerExport::from_result(problem, result)
.with_algorithm_info(algorithm)
.to_file(path)
}
fn candidate_to_export<D: ToDecisionValues>(
c: &Candidate<D>,
front_rank: usize,
n_obj: usize,
) -> ExplorerCandidate {
let objectives = if c.evaluation.objectives.len() == n_obj {
c.evaluation.objectives.clone()
} else {
// Defensive: shouldn't happen in practice, but pad/truncate so
// the export is well-formed even if a buggy algorithm produced
// a mismatched evaluation.
let mut v = c.evaluation.objectives.clone();
v.resize(n_obj, f64::NAN);
v
};
ExplorerCandidate {
decision: c.decision.to_decision_values(),
objectives,
constraint_violation: c.evaluation.constraint_violation,
feasible: c.evaluation.constraint_violation <= 0.0,
front_rank,
in_pareto_front: front_rank == 0,
}
}
fn pad_decision_schema(
mut schema: Vec<DecisionVariable>,
decision_arity: usize,
) -> Vec<DecisionVariable> {
if schema.len() < decision_arity {
let start = schema.len();
for i in start..decision_arity {
schema.push(DecisionVariable::new(format!("x[{i}]")));
}
}
schema
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
use crate::core::objective::{Direction, Objective, ObjectiveSpace};
use crate::core::population::Population;
use crate::core::problem::Problem;
use crate::core::result::OptimizationResult;
/// Two-objective minimize problem used for most explorer tests.
/// f1 = decision[0], f2 = decision[1] — both minimize, so
/// `(a, b)` dominates `(c, d)` iff `a ≤ c && b ≤ d` with at
/// least one strict.
struct TwoObjMin;
impl Problem for TwoObjMin {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::minimize("a")
.with_label("Apples")
.with_unit("count"),
Objective::maximize("b").with_unit("score"),
])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0], x[1]])
}
}
struct EnrichedProblem;
impl Problem for EnrichedProblem {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("a")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0]])
}
fn decision_schema(&self) -> Vec<DecisionVariable> {
vec![
DecisionVariable::new("alpha")
.with_label("Alpha")
.with_unit("u")
.with_bounds(0.0, 1.0),
DecisionVariable::new("beta"),
]
}
}
struct DummyAlgo;
impl AlgorithmInfo for DummyAlgo {
fn name(&self) -> &'static str {
"DummyAlgo"
}
fn seed(&self) -> Option<u64> {
Some(123)
}
}
/// Build a result whose evaluations match `objectives_per_candidate`.
/// Each candidate's objective vector is the closure applied to the
/// decision.
fn make_result(
decisions: Vec<Vec<f64>>,
eval: impl Fn(&[f64]) -> Vec<f64>,
) -> OptimizationResult<Vec<f64>> {
let cands: Vec<Candidate<Vec<f64>>> = decisions
.into_iter()
.map(|d| {
let objs = eval(&d);
Candidate::new(d, Evaluation::new(objs))
})
.collect();
let n = cands.len();
OptimizationResult::new(Population::new(cands.clone()), cands, None, n, 1)
}
#[test]
fn schema_version_is_one() {
assert_eq!(SCHEMA_VERSION, 1);
}
/// Single-objective minimize problem (used for tests where the
/// problem only declares one objective).
struct SingleObjMin;
impl Problem for SingleObjMin {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0]])
}
}
#[test]
fn zero_config_export_uses_fallback_decision_names() {
let problem = TwoObjMin;
// Two objectives — eval just maps decision to objective values.
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.schema_version, SCHEMA_VERSION);
assert_eq!(export.decision_variables.len(), 2);
assert_eq!(export.decision_variables[0].name, "x[0]");
assert_eq!(export.decision_variables[1].name, "x[1]");
assert!(export.decision_variables[0].label.is_none());
}
#[test]
fn objectives_carry_label_and_unit_through_export() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.objectives.len(), 2);
assert_eq!(export.objectives[0].label.as_deref(), Some("Apples"));
assert_eq!(export.objectives[0].unit.as_deref(), Some("count"));
assert_eq!(export.objectives[1].direction, Direction::Maximize);
}
#[test]
fn enriched_decision_schema_passes_through() {
let problem = EnrichedProblem; // 1 objective, 2-element decisions
let result = make_result(vec![vec![0.5, 0.5]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result);
assert_eq!(export.decision_variables.len(), 2);
assert_eq!(export.decision_variables[0].name, "alpha");
assert_eq!(export.decision_variables[0].label.as_deref(), Some("Alpha"));
assert_eq!(export.decision_variables[0].min, Some(0.0));
assert_eq!(export.decision_variables[1].name, "beta");
assert!(export.decision_variables[1].min.is_none());
}
#[test]
fn front_rank_zero_for_pareto_front_members() {
// Use SingleObjMin (1 objective) to make dominance trivial:
// among [3.0, 1.0, 2.0], only 1.0 is non-dominated.
let problem = SingleObjMin;
let result = make_result(vec![vec![3.0], vec![1.0], vec![2.0]], |d| vec![d[0]]);
let export = ExplorerExport::from_result(&problem, &result);
// Index 1 (decision = 1.0) is the unique minimum.
assert_eq!(export.candidates[1].front_rank, 0);
assert!(export.candidates[1].in_pareto_front);
assert_eq!(export.candidates[2].front_rank, 1);
assert!(!export.candidates[2].in_pareto_front);
assert_eq!(export.candidates[0].front_rank, 2);
assert!(!export.candidates[0].in_pareto_front);
}
#[test]
fn algorithm_info_populates_run_meta() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result).with_algorithm_info(&DummyAlgo);
assert_eq!(export.run.algorithm.as_deref(), Some("DummyAlgo"));
assert_eq!(export.run.seed, Some(123));
}
#[test]
fn round_trip_serde() {
let problem = TwoObjMin;
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
let export = ExplorerExport::from_result(&problem, &result)
.with_algorithm_info(&DummyAlgo)
.with_problem_name("Toy")
.with_wall_clock(0.001);
let json = export.to_json().unwrap();
let back: ExplorerExport = serde_json::from_str(&json).unwrap();
assert_eq!(back.schema_version, SCHEMA_VERSION);
assert_eq!(back.run.algorithm.as_deref(), Some("DummyAlgo"));
assert_eq!(back.candidates.len(), 2);
assert_eq!(back.objectives.len(), 2);
}
#[test]
fn vec_bool_decisions_serialize_as_bool_array() {
let v: Vec<bool> = vec![true, false, true];
let values = v.to_decision_values();
assert_eq!(values.len(), 3);
assert_eq!(values[0], serde_json::Value::Bool(true));
assert_eq!(values[1], serde_json::Value::Bool(false));
}
#[test]
fn vec_usize_decisions_serialize_as_int_array() {
let v: Vec<usize> = vec![3, 1, 4];
let values = v.to_decision_values();
assert_eq!(values.len(), 3);
assert_eq!(
values[0],
serde_json::Value::Number(serde_json::Number::from(3u64))
);
}
#[test]
fn nan_decision_renders_as_null() {
let v: Vec<f64> = vec![1.0, f64::NAN, 2.0];
let values = v.to_decision_values();
assert_eq!(values[0].as_f64(), Some(1.0));
assert_eq!(values[1], serde_json::Value::Null);
assert_eq!(values[2].as_f64(), Some(2.0));
}
}
+6 -1
View File
@@ -28,7 +28,10 @@
//! - `serde` — derives `Serialize` / `Deserialize` on the core data
//! types ([`Candidate`](crate::core::Candidate),
//! [`Population`](crate::core::Population),
//! [`Evaluation`](crate::core::Evaluation), …).
//! [`Evaluation`](crate::core::Evaluation), …) and enables the
//! [`heuropt::explorer`](crate::explorer) JSON export module for the
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
//! webapp.
//! - `parallel` — rayon-backed parallel population evaluation in
//! every population-based algorithm. Seeded runs stay bit-
//! identical to serial mode.
@@ -72,6 +75,8 @@
pub mod algorithms;
pub mod core;
#[cfg(feature = "serde")]
pub mod explorer;
pub(crate) mod internal;
pub mod metrics;
pub mod operators;
+3 -3
View File
@@ -7,11 +7,11 @@
#[cfg(feature = "async")]
pub use crate::core::async_problem::AsyncProblem;
pub use crate::core::{
Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult,
PartialProblem, Population, Problem, Rng, rng_from_seed,
Candidate, DecisionVariable, Direction, Evaluation, Objective, ObjectiveSpace,
OptimizationResult, PartialProblem, Population, Problem, Rng, rng_from_seed,
};
pub use crate::traits::{Initializer, Optimizer, Repair, Variation};
pub use crate::traits::{AlgorithmInfo, Initializer, Optimizer, Repair, Variation};
pub use crate::pareto::{
Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort,
+27
View File
@@ -0,0 +1,27 @@
//! Lightweight metadata about an algorithm — its short canonical name
//! and the seed driving the current run.
//!
//! `AlgorithmInfo` is separate from [`Optimizer<P>`](super::Optimizer)
//! so multi-fidelity algorithms (which use `PartialProblem` instead of
//! `Problem`) can implement it uniformly. Every built-in algorithm in
//! `heuropt` implements `AlgorithmInfo`; the explorer JSON export reads
//! these methods to populate `algorithm` and `seed` fields in the
//! exported run metadata.
/// Algorithm metadata used by tooling such as the explorer JSON export.
///
/// Implementors return a short canonical name like `"Nsga3"` or
/// `"DifferentialEvolution"`, and the seed driving their current run
/// when applicable.
pub trait AlgorithmInfo {
/// Short, canonical algorithm name — e.g. `"Nsga3"`,
/// `"DifferentialEvolution"`, `"BayesianOpt"`.
fn name(&self) -> &'static str;
/// The deterministic seed driving this run, if the algorithm uses
/// one. Default: `None`. Built-in algorithms return
/// `Some(self.config.seed)`.
fn seed(&self) -> Option<u64> {
None
}
}
+2
View File
@@ -1,10 +1,12 @@
//! The small set of traits that user code and built-in algorithms implement.
pub mod algorithm_info;
pub mod initializer;
pub mod optimizer;
pub mod repair;
pub mod variation;
pub use algorithm_info::*;
pub use initializer::*;
pub use optimizer::*;
pub use repair::*;