feat(explorer): JSON export module + supporting metadata + example
Adds a tiny additive surface that turns any OptimizationResult into
a self-describing JSON file the heuropt-explorer webapp can load.
Real Pareto fronts have 50–200+ candidates spanning 2–7+ objectives;
reading them as numbers in a terminal scales badly. This commit
ships the heuropt-side of the explorer — the schema and the export
API. The webapp itself lives in a separate repo on its own cadence.
Three trait/type extensions, all with working defaults so existing
impls compile untouched:
- Objective gains optional `label: Option<String>` and
`unit: Option<String>` fields, plus fluent builders
`.with_label("Price").with_unit(\"\$k\")`. Existing
`Objective::minimize(name)` / `Objective::maximize(name)` are
unchanged. Both fields are #[serde(default,
skip_serializing_if = \"Option::is_none\")] so existing JSON
round-trips cleanly.
- Problem trait gains an optional
`fn decision_schema(&self) -> Vec<DecisionVariable>` with default
empty impl. Override it to provide pretty names / labels / units /
bounds for the explorer; the default produces fallback x[0],
x[1], … names. New DecisionVariable type at
`heuropt::core::DecisionVariable` with builder methods.
- New `heuropt::traits::AlgorithmInfo` trait with `name()`
(required) and `seed()` (default None). Every built-in algorithm
— all 33 — implements it. Separate from Optimizer<P> so
multi-fidelity Hyperband (which uses PartialProblem) implements
it uniformly.
The new explorer module:
- `heuropt::explorer::ExplorerExport` envelope with versioned
schema (SCHEMA_VERSION = 1).
- ExplorerCandidate per row, with front_rank from
non_dominated_sort attached at export time so downstream tools
don't re-derive it.
- ToDecisionValues adapter trait with provided impls for Vec<f64>,
Vec<bool>, Vec<usize>, Vec<i64>; custom decision types implement
one method.
- Free functions to_json / to_writer / to_file plus a builder API
(with_algorithm_info, with_problem_name, with_wall_clock,
with_timestamp).
- Gated on the existing `serde` feature, which now also pulls in
`serde_json` as a dep.
The example:
- `examples/pick_a_car.rs` — promotes the README's PickACar to a
real example, fully enriched with Objective labels/units and a
decision_schema. Runs NSGA-III for 200 generations, prints a
sample slice, writes pick_a_car.json. Gated on `serde`.
10 new explorer unit tests cover round-trip serde, fallback
decision-variable names, enriched export, AlgorithmInfo flow,
front-rank correctness, and the ToDecisionValues impls. Lib test
count went from 229 to 242.
This commit is contained in:
@@ -0,0 +1,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<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
|
||||
```
|
||||
Reference in New Issue
Block a user