From 729842c260ff327c2a0d0befe76ee593bcb944f6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 6 May 2026 12:24:26 -0600 Subject: [PATCH] feat(explorer): JSON export module + supporting metadata + example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` and `unit: Option` 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` 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

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, Vec, Vec, Vec; 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. --- Cargo.toml | 9 +- docs/book/src/cookbook/explorer.md | 191 ++++++++ examples/pick_a_car.rs | 167 +++++++ src/algorithms/age_moea.rs | 9 + src/algorithms/ant_colony_tsp.rs | 9 + src/algorithms/bayesian_opt.rs | 9 + src/algorithms/cma_es.rs | 9 + src/algorithms/differential_evolution.rs | 9 + src/algorithms/epsilon_moea.rs | 9 + src/algorithms/genetic_algorithm.rs | 9 + src/algorithms/grea.rs | 9 + src/algorithms/hill_climber.rs | 9 + src/algorithms/hype.rs | 9 + src/algorithms/hyperband.rs | 13 + src/algorithms/ibea.rs | 9 + src/algorithms/ipop_cma_es.rs | 9 + src/algorithms/knea.rs | 9 + src/algorithms/moead.rs | 9 + src/algorithms/mopso.rs | 9 + src/algorithms/nelder_mead.rs | 6 + src/algorithms/nsga2.rs | 9 + src/algorithms/nsga3.rs | 9 + src/algorithms/one_plus_one_es.rs | 9 + src/algorithms/paes.rs | 9 + src/algorithms/particle_swarm.rs | 9 + src/algorithms/pesa2.rs | 9 + src/algorithms/random_search.rs | 9 + src/algorithms/rvea.rs | 9 + src/algorithms/simulated_annealing.rs | 9 + src/algorithms/sms_emoa.rs | 9 + src/algorithms/snes.rs | 9 + src/algorithms/spea2.rs | 9 + src/algorithms/tabu_search.rs | 14 + src/algorithms/tlbo.rs | 9 + src/algorithms/tpe.rs | 9 + src/algorithms/umda.rs | 9 + src/core/decision_variable.rs | 106 +++++ src/core/mod.rs | 2 + src/core/objective.rs | 56 ++- src/core/problem.rs | 15 + src/explorer/mod.rs | 563 +++++++++++++++++++++++ src/lib.rs | 7 +- src/prelude.rs | 6 +- src/traits/algorithm_info.rs | 27 ++ src/traits/mod.rs | 2 + 45 files changed, 1447 insertions(+), 7 deletions(-) create mode 100644 docs/book/src/cookbook/explorer.md create mode 100644 examples/pick_a_car.rs create mode 100644 src/core/decision_variable.rs create mode 100644 src/explorer/mod.rs create mode 100644 src/traits/algorithm_info.rs diff --git a/Cargo.toml b/Cargo.toml index 721ddae..7e2429b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heuropt" -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.85" authors = ["Stephen Waits "] @@ -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. diff --git a/docs/book/src/cookbook/explorer.md b/docs/book/src/cookbook/explorer.md new file mode 100644 index 0000000..94b2123 --- /dev/null +++ b/docs/book/src/cookbook/explorer.md @@ -0,0 +1,191 @@ +# Explore your results in a webapp + +Real Pareto fronts have 50–200+ candidates spanning 2–7+ 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; + + 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 { + // 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) -> 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 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`, `Vec`, `Vec`, and `Vec` +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 { + 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 +``` diff --git a/examples/pick_a_car.rs b/examples/pick_a_car.rs new file mode 100644 index 0000000..9ac2ca1 --- /dev/null +++ b/examples/pick_a_car.rs @@ -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 to +//! filter, brush, pin, and rank the 100-car Pareto front +//! interactively. + +use heuropt::prelude::*; + +struct PickACar; + +impl Problem for PickACar { + type Decision = Vec; // [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 { + 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) -> 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::>() + } 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."); +} diff --git a/src/algorithms/age_moea.rs b/src/algorithms/age_moea.rs index bc9f1da..6713afb 100644 --- a/src/algorithms/age_moea.rs +++ b/src/algorithms/age_moea.rs @@ -424,6 +424,15 @@ fn estimate_p(front_indices: &[usize], translated: &[Vec], m: usize) -> f64 best_p } +impl crate::traits::AlgorithmInfo for AgeMoea { + fn name(&self) -> &'static str { + "AgeMoea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/ant_colony_tsp.rs b/src/algorithms/ant_colony_tsp.rs index 5bd8522..ea3b419 100644 --- a/src/algorithms/ant_colony_tsp.rs +++ b/src/algorithms/ant_colony_tsp.rs @@ -429,6 +429,15 @@ fn better_than_so( } } +impl crate::traits::AlgorithmInfo for AntColonyTsp { + fn name(&self) -> &'static str { + "AntColonyTsp" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/bayesian_opt.rs b/src/algorithms/bayesian_opt.rs index d7609d7..f382f4f 100644 --- a/src/algorithms/bayesian_opt.rs +++ b/src/algorithms/bayesian_opt.rs @@ -540,6 +540,15 @@ impl BayesianOpt { } } +impl crate::traits::AlgorithmInfo for BayesianOpt { + fn name(&self) -> &'static str { + "BayesianOpt" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/cma_es.rs b/src/algorithms/cma_es.rs index 2723692..f1c05c8 100644 --- a/src/algorithms/cma_es.rs +++ b/src/algorithms/cma_es.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/differential_evolution.rs b/src/algorithms/differential_evolution.rs index 58d9351..003374d 100644 --- a/src/algorithms/differential_evolution.rs +++ b/src/algorithms/differential_evolution.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/epsilon_moea.rs b/src/algorithms/epsilon_moea.rs index 2f3361a..576531e 100644 --- a/src/algorithms/epsilon_moea.rs +++ b/src/algorithms/epsilon_moea.rs @@ -396,6 +396,15 @@ fn box_dominates(a: &[i64], b: &[i64]) -> bool { strictly_less } +impl crate::traits::AlgorithmInfo for EpsilonMoea { + fn name(&self) -> &'static str { + "EpsilonMoea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/genetic_algorithm.rs b/src/algorithms/genetic_algorithm.rs index 486ba1c..8f78814 100644 --- a/src/algorithms/genetic_algorithm.rs +++ b/src/algorithms/genetic_algorithm.rs @@ -317,6 +317,15 @@ fn compare_for_fitness( } } +impl crate::traits::AlgorithmInfo for GeneticAlgorithm { + fn name(&self) -> &'static str { + "GeneticAlgorithm" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/grea.rs b/src/algorithms/grea.rs index a522409..cad99f2 100644 --- a/src/algorithms/grea.rs +++ b/src/algorithms/grea.rs @@ -334,6 +334,15 @@ fn environmental_selection( selected.into_iter().map(|i| combined[i].clone()).collect() } +impl crate::traits::AlgorithmInfo for Grea { + fn name(&self) -> &'static str { + "Grea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/hill_climber.rs b/src/algorithms/hill_climber.rs index d00aa97..0e30b99 100644 --- a/src/algorithms/hill_climber.rs +++ b/src/algorithms/hill_climber.rs @@ -224,6 +224,15 @@ impl HillClimber { } } +impl crate::traits::AlgorithmInfo for HillClimber { + fn name(&self) -> &'static str { + "HillClimber" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/hype.rs b/src/algorithms/hype.rs index 2dfe3aa..1f45a39 100644 --- a/src/algorithms/hype.rs +++ b/src/algorithms/hype.rs @@ -459,6 +459,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize { } } +impl crate::traits::AlgorithmInfo for Hype { + fn name(&self) -> &'static str { + "Hype" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/hyperband.rs b/src/algorithms/hyperband.rs index 046e6bf..94098d7 100644 --- a/src/algorithms/hyperband.rs +++ b/src/algorithms/hyperband.rs @@ -329,6 +329,19 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool { compare(a, b, direction) == std::cmp::Ordering::Less } +impl crate::traits::AlgorithmInfo for Hyperband +where + D: Clone, + I: Initializer, +{ + fn name(&self) -> &'static str { + "Hyperband" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/ibea.rs b/src/algorithms/ibea.rs index ff9d072..2916370 100644 --- a/src/algorithms/ibea.rs +++ b/src/algorithms/ibea.rs @@ -385,6 +385,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize { } } +impl crate::traits::AlgorithmInfo for Ibea { + fn name(&self) -> &'static str { + "Ibea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/ipop_cma_es.rs b/src/algorithms/ipop_cma_es.rs index 5ee8842..3bfc89c 100644 --- a/src/algorithms/ipop_cma_es.rs +++ b/src/algorithms/ipop_cma_es.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/knea.rs b/src/algorithms/knea.rs index 0905d03..59bf0ad 100644 --- a/src/algorithms/knea.rs +++ b/src/algorithms/knea.rs @@ -328,6 +328,15 @@ fn perpendicular_distance(point: &[f64], extremes: &[usize], oriented: &[Vec crate::traits::AlgorithmInfo for Knea { + fn name(&self) -> &'static str { + "Knea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/moead.rs b/src/algorithms/moead.rs index fd2b68c..78d6e96 100644 --- a/src/algorithms/moead.rs +++ b/src/algorithms/moead.rs @@ -363,6 +363,15 @@ fn weight_distance(a: &[f64], b: &[f64]) -> f64 { .sqrt() } +impl crate::traits::AlgorithmInfo for Moead { + fn name(&self) -> &'static str { + "Moead" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/mopso.rs b/src/algorithms/mopso.rs index d046317..0b07133 100644 --- a/src/algorithms/mopso.rs +++ b/src/algorithms/mopso.rs @@ -330,6 +330,15 @@ impl Mopso { } } +impl crate::traits::AlgorithmInfo for Mopso { + fn name(&self) -> &'static str { + "Mopso" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/nelder_mead.rs b/src/algorithms/nelder_mead.rs index 9224ada..66f8853 100644 --- a/src/algorithms/nelder_mead.rs +++ b/src/algorithms/nelder_mead.rs @@ -450,6 +450,12 @@ impl NelderMead { } } +impl crate::traits::AlgorithmInfo for NelderMead { + fn name(&self) -> &'static str { + "NelderMead" + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/nsga2.rs b/src/algorithms/nsga2.rs index 71df459..bb27cef 100644 --- a/src/algorithms/nsga2.rs +++ b/src/algorithms/nsga2.rs @@ -364,6 +364,15 @@ fn binary_tournament(entries: &[Nsga2Entry], rng: &mut Rng) -> usize { } } +impl crate::traits::AlgorithmInfo for Nsga2 { + fn name(&self) -> &'static str { + "Nsga2" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/nsga3.rs b/src/algorithms/nsga3.rs index c6f23e7..bdfeaaf 100644 --- a/src/algorithms/nsga3.rs +++ b/src/algorithms/nsga3.rs @@ -542,6 +542,15 @@ fn associate( (assoc, dist) } +impl crate::traits::AlgorithmInfo for Nsga3 { + fn name(&self) -> &'static str { + "Nsga3" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/one_plus_one_es.rs b/src/algorithms/one_plus_one_es.rs index 420bc45..f133305 100644 --- a/src/algorithms/one_plus_one_es.rs +++ b/src/algorithms/one_plus_one_es.rs @@ -285,6 +285,15 @@ impl OnePlusOneEs { } } +impl crate::traits::AlgorithmInfo for OnePlusOneEs { + fn name(&self) -> &'static str { + "OnePlusOneEs" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/paes.rs b/src/algorithms/paes.rs index b77ee36..fa09dbc 100644 --- a/src/algorithms/paes.rs +++ b/src/algorithms/paes.rs @@ -242,6 +242,15 @@ impl Paes { } } +impl crate::traits::AlgorithmInfo for Paes { + fn name(&self) -> &'static str { + "Paes" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/particle_swarm.rs b/src/algorithms/particle_swarm.rs index a35ecbf..103bf28 100644 --- a/src/algorithms/particle_swarm.rs +++ b/src/algorithms/particle_swarm.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/pesa2.rs b/src/algorithms/pesa2.rs index 51357b9..83531e4 100644 --- a/src/algorithms/pesa2.rs +++ b/src/algorithms/pesa2.rs @@ -409,6 +409,15 @@ fn truncate_by_grid(archive: &mut ParetoArchive, max_size: usize, d } } +impl crate::traits::AlgorithmInfo for PesaII { + fn name(&self) -> &'static str { + "PesaII" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/random_search.rs b/src/algorithms/random_search.rs index 863477d..53f7b01 100644 --- a/src/algorithms/random_search.rs +++ b/src/algorithms/random_search.rs @@ -158,6 +158,15 @@ impl RandomSearch { } } +impl crate::traits::AlgorithmInfo for RandomSearch { + fn name(&self) -> &'static str { + "RandomSearch" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/rvea.rs b/src/algorithms/rvea.rs index 354618c..29ed538 100644 --- a/src/algorithms/rvea.rs +++ b/src/algorithms/rvea.rs @@ -466,6 +466,15 @@ fn smallest_neighbor_angle(references: &[Vec]) -> f64 { } } +impl crate::traits::AlgorithmInfo for Rvea { + fn name(&self) -> &'static str { + "Rvea" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/simulated_annealing.rs b/src/algorithms/simulated_annealing.rs index b4ba88f..929609a 100644 --- a/src/algorithms/simulated_annealing.rs +++ b/src/algorithms/simulated_annealing.rs @@ -333,6 +333,15 @@ impl SimulatedAnnealing { } } +impl crate::traits::AlgorithmInfo for SimulatedAnnealing { + fn name(&self) -> &'static str { + "SimulatedAnnealing" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/sms_emoa.rs b/src/algorithms/sms_emoa.rs index d0f0ef9..b17ddbf 100644 --- a/src/algorithms/sms_emoa.rs +++ b/src/algorithms/sms_emoa.rs @@ -289,6 +289,15 @@ fn pick_drop_index( worst_front[worst_idx_in_front] } +impl crate::traits::AlgorithmInfo for SmsEmoa { + fn name(&self) -> &'static str { + "SmsEmoa" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/snes.rs b/src/algorithms/snes.rs index 22c9ec7..f87cd4c 100644 --- a/src/algorithms/snes.rs +++ b/src/algorithms/snes.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/spea2.rs b/src/algorithms/spea2.rs index 0aff699..2c867c8 100644 --- a/src/algorithms/spea2.rs +++ b/src/algorithms/spea2.rs @@ -503,6 +503,15 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize { } } +impl crate::traits::AlgorithmInfo for Spea2 { + fn name(&self) -> &'static str { + "Spea2" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/tabu_search.rs b/src/algorithms/tabu_search.rs index 41460ce..21d8754 100644 --- a/src/algorithms/tabu_search.rs +++ b/src/algorithms/tabu_search.rs @@ -331,6 +331,20 @@ where } } +impl crate::traits::AlgorithmInfo for TabuSearch +where + D: Clone + Hash + Eq, + I: Initializer, + N: FnMut(&D, &mut Rng) -> Vec, +{ + fn name(&self) -> &'static str { + "TabuSearch" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/tlbo.rs b/src/algorithms/tlbo.rs index 8d0f397..2c818d7 100644 --- a/src/algorithms/tlbo.rs +++ b/src/algorithms/tlbo.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/tpe.rs b/src/algorithms/tpe.rs index 5978feb..2ebaa57 100644 --- a/src/algorithms/tpe.rs +++ b/src/algorithms/tpe.rs @@ -479,6 +479,15 @@ impl Tpe { } } +impl crate::traits::AlgorithmInfo for Tpe { + fn name(&self) -> &'static str { + "Tpe" + } + fn seed(&self) -> Option { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/algorithms/umda.rs b/src/algorithms/umda.rs index 979312b..71ce958 100644 --- a/src/algorithms/umda.rs +++ b/src/algorithms/umda.rs @@ -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 { + Some(self.config.seed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/decision_variable.rs b/src/core/decision_variable.rs new file mode 100644 index 0000000..1fbaf16 --- /dev/null +++ b/src/core/decision_variable.rs @@ -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, + /// Display unit (e.g. `"L"`, `"kg"`, `"Cd"`). + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub unit: Option, + /// Lower bound, if known. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub min: Option, + /// Upper bound, if known. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub max: Option, +} + +impl DecisionVariable { + /// Construct a `DecisionVariable` with just a name. + pub fn new(name: impl Into) -> 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) -> Self { + self.label = Some(label.into()); + self + } + + /// Attach a display unit string. Builder-style. + pub fn with_unit(mut self, unit: impl Into) -> 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)); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 244015d..23da6e7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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::*; diff --git a/src/core/objective.rs b/src/core/objective.rs index 631e4b2..1296dee 100644 --- a/src/core/objective.rs +++ b/src/core/objective.rs @@ -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, + /// Display unit, e.g. `"$k"`, `"s"`, `"dB"`. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub unit: Option, } 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) -> 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) -> 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![ diff --git a/src/core/problem.rs b/src/core/problem.rs index 0388ef3..ae869e6 100644 --- a/src/core/problem.rs +++ b/src/core/problem.rs @@ -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 { + Vec::new() + } } diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs new file mode 100644 index 0000000..6cbbb1a --- /dev/null +++ b/src/explorer/mod.rs @@ -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 +//! 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, + /// Decision-variable schemas, padded with fallback `x[i]` names + /// when the user didn't override `Problem::decision_schema()`. + pub decision_variables: Vec, + /// One row per candidate in the final population. + pub candidates: Vec, +} + +/// 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, + /// Objective values, parallel to the `objectives` array. + pub objectives: Vec, + /// 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, + /// 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, + /// Seed driving this run, if applicable. Pulled from + /// [`AlgorithmInfo::seed`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + /// 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, + /// 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, +} + +/// 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; +} + +impl ToDecisionValues for Vec { + fn to_decision_values(&self) -> Vec { + 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 { + fn to_decision_values(&self) -> Vec { + self.iter().map(|b| serde_json::Value::Bool(*b)).collect() + } +} + +impl ToDecisionValues for Vec { + fn to_decision_values(&self) -> Vec { + self.iter() + .map(|i| serde_json::Value::Number(serde_json::Number::from(*i as u64))) + .collect() + } +} + +impl ToDecisionValues for Vec { + fn to_decision_values(&self) -> Vec { + 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

(problem: &P, result: &OptimizationResult) -> 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] = &result.population.candidates; + let fronts = non_dominated_sort(pop_slice, &objective_space); + let mut rank_of: Vec = 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(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) -> 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) -> Self { + self.run.timestamp = Some(timestamp.into()); + self + } + + /// Serialize to a pretty-printed JSON string. + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string_pretty(self) + } + + /// Serialize to any `Write` sink as pretty-printed JSON. + pub fn to_writer(&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>(&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( + problem: &P, + algorithm: &A, + result: &OptimizationResult, +) -> serde_json::Result +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( + writer: W, + problem: &P, + algorithm: &A, + result: &OptimizationResult, +) -> 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( + path: Q, + problem: &P, + algorithm: &A, + result: &OptimizationResult, +) -> std::io::Result<()> +where + Q: AsRef, + P: Problem, + P::Decision: ToDecisionValues, + A: AlgorithmInfo, +{ + ExplorerExport::from_result(problem, result) + .with_algorithm_info(algorithm) + .to_file(path) +} + +fn candidate_to_export( + c: &Candidate, + 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, + decision_arity: usize, +) -> Vec { + 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; + 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) -> Evaluation { + Evaluation::new(vec![x[0], x[1]]) + } + } + + struct EnrichedProblem; + impl Problem for EnrichedProblem { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("a")]) + } + fn evaluate(&self, x: &Vec) -> Evaluation { + Evaluation::new(vec![x[0]]) + } + fn decision_schema(&self) -> Vec { + 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 { + 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>, + eval: impl Fn(&[f64]) -> Vec, + ) -> OptimizationResult> { + let cands: Vec>> = 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; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f")]) + } + fn evaluate(&self, x: &Vec) -> 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 = 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 = 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 = 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)); + } +} diff --git a/src/lib.rs b/src/lib.rs index ab5bea7..89c9997 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/prelude.rs b/src/prelude.rs index 843a768..9b54d69 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -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, diff --git a/src/traits/algorithm_info.rs b/src/traits/algorithm_info.rs new file mode 100644 index 0000000..19b2925 --- /dev/null +++ b/src/traits/algorithm_info.rs @@ -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

`](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 { + None + } +} diff --git a/src/traits/mod.rs b/src/traits/mod.rs index 0829e19..7ee6087 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -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::*;