ADR-032: simopt-profiles — SimOpt-style progress curves and solvability profiles¶
Status: accepted (implemented)
Date: 2026-07-15
Deciders: non-solver roadmap — redeeming the P5/P6 progress-curve / solvability-profile promise
File: docs/design/adr-032-simopt-profiles.md
Context¶
SimOpt (Eckman, Henderson & Shashaani 2023, SimOpt: A testbed for
simulation-optimization experiments, INFORMS J. on Computing 35(2):495-508,
eckman2023simopt, tier-1 metric/protocol in docs/references.json) is one of
the three named design sources in docs/ARCHITECTURE.md. Its diagnostics —
progress curves, α-solve-time solvability profiles, Moré-Wild data profiles — are
defined not in the testbed paper but in its companion, Eckman, Henderson &
Shashaani (2023), “Diagnostic Tools for Evaluating and Comparing
Simulation-Optimization Algorithms”, INFORMS J. on Computing 35(2):350-367, DOI
10.1287/ijoc.2022.1261 and, for data
profiles, Moré & Wild (2009), “Benchmarking Derivative-Free Optimization
Algorithms”, SIAM J. Optim. 20(1):172-191, DOI
10.1137/080724083. Neither is in the
verified canon (which holds only the testbed paper, eckman2023simopt); this ADR
cites both freely in text — ADRs cite non-canon works — and does not grow the
canon (it stays at 246 references).
The architecture already promises these deliverables in normative language: P5 (lines 98-114) says the deterministic track is “reported as progress curves and Moré-Wild-style data profiles”, the stochastic track carries “mean/quantile progress curves and α-solve-time solvability profiles”, and no-certificate models appear “as censored entries in solvability profiles”; the v0.x roadmap lists “progress-curve/solvability-profile plotting” as still open. This is the last unredeemed piece of that text.
The experiment half of SimOpt already ships and was verified live against the
working tree: macroreplications with the fixed (macrorep, source, replication)
stream schema (P8, core/rng.py), hardware-free budget coordinates on every
checkpoint (BudgetCoords, P6), one certified CSV row per checkpoint (the
post-replication-equivalent P1 certificate — deterministic metrics recomputed
exactly, the probit residual certified through a pinned common Monte-Carlo sample,
adr-003), row-level censoring of infeasible flows, and the terminal percentile
bootstrap across macroreps (experiments/bootstrap.py). What was missing is the
diagnostics half: curve objects, α-solve times, solvability/data profiles,
difference profiles, functional bootstrap bands, and a profile artifact. That
missing piece is pure post-hoc arithmetic over already-certified rows, so it
needs no new certifier and no change to any solver, certifier, or the runner.
Decision¶
Ship a new module src/tabench/experiments/profiles.py — at the same altitude as
experiments/bootstrap.py (aggregation over certified outputs), not in
metrics/ (those are P1 certifiers over (scenario, flows); profiles consume
already-certified rows). It reads either an in-memory ExperimentResult or the
runner’s on-disk {stem}.csv + {stem}.manifest.json pair (load_run, which
owns the typed parse and the total-budget/scenario-hash the rows lack). Curves are
immutable StepCurve objects; profiles are plain data; the certified artifact is
profiles.json. No file in models/, metrics/, core/, or runner.py
changes; the runner keeps writing the same CSV+manifest, so the golden Braess hash
cf00f411cdccec88019979e8cfbf3d8014ba590688b285a1db667315ac96762d is byte-identical
(re-asserted in tests/test_profiles.py).
Ten protocol decisions are deliberate, disclosed deviations from a literal SimOpt port:
D1 — the curve y-axis is the certified ranking metric, not SimOpt’s
(f-f*)/(f(x0)-f*). SimOpt normalizes because raw objectives are incomparable
across problems and f is noisy; TABenchmark’s certified relative gap (UE), SUE
fixed-point residual, or SO relative gap is already an absolute, scale-free,
harness-computed optimality measure with a true zero. Adopting it makes α-solve
mean “certified gap < α” — the repo’s existing convergence-target language
(Boyce et al. 2004, Budget.target_relative_gap) — and avoids estimating
f(x0)/f(x*) entirely. The metric defaults to the track’s ranking column read
from the manifest: sue_fixed_point_residual when sue_theta is set;
so_relative_gap only when every model in the grid is system-optimum — a
single static_so model in a mixed grid must NOT flip the UE solvers beside it
onto a column they do not populate (that would score their transient crossings as
SO convergence); mixed grids keep relative_gap and the SO column is requested
explicitly. A SimOpt-faithful Beckmann-normalized variant stays possible later (the
beckmann_objective column + best-known anchors exist); it is not v1 scope.
D2 — the budget axis is a declared hardware-free coordinate (P6): default
sp_calls, iterations accepted; wall_ms is allowed for descriptive progress
curves but refused by every ranked profile (cdf/quantile solvability, data
profiles). The α-solve time is reported in the axis’s own work units, not hidden
behind a budget fraction. An axis that is 0 at every checkpoint of a model that
nonetheless produced a genuine (feasible, finite-metric) checkpoint is refused
(a learned model / spsa-sumo discloses sp_calls=0 while doing real work, so
ranking it on sp_calls would be a degenerate curve, not a fast one) — but an
already-censored model at sp_calls=0 is exempt, since it is +inf regardless of
the axis. The zero-axis guard is judged against the caller’s metric, not the
default: an alternate metric can make an otherwise-censored model finite (and vice
versa), so the guard must see the same metric the curve will. wall_ms reaches
progress_curves/solve_times for descriptive curves and raw α-solve times only
— every ranked profile refuses it (P6: wall-clock is recorded, never ranked).
When the manifest budget does not constrain the chosen axis (an iterations-only
budget leaves sp_calls=None), the normalizer is the realized budget envelope (the
maximum observed axis value). This differs from SimOpt, whose
problem.factors['budget'] is a declared factor fixed up front: the realized-max
fallback means adding a longer-running model to a grid rescales the normalized
solvability x-axis, so cdf/quantile profiles are comparable only within a fixed
grid composition — the raw-work-unit α-solve times (solve_times) are not
rescaled and stay directly comparable.
D3 — strict < crossing. StepCurve.crossing_time(α) is the first knot whose
value is strictly below α (SimOpt Curve.compute_crossing_time parity), +inf
when never crossed. Pinned by a knot-exact test.
D4 — censoring stays in the denominator. A checkpoint with feasible=0 or a
non-finite metric becomes y=+inf (censored); a never-solved run has an +inf
solve time; censored entries stay in every cdf denominator so they never
inflate a profile (SimOpt parity; P5’s “censored entries in solvability profiles”
made literal). A black box that emits infeasible flows is thus a first-class
censored row, never an error and never a leaderboard-topper.
D5 — step semantics. The last recommendation is carried to the budget end
(curve_to_full_curve); before a model’s first checkpoint the curve is +inf
(censored), so there is no fictitious t=0 solve. An early-converged trace (bfw
reaches its target in 3 checkpoints on Braess) carries its final value forward to
the realized budget end.
D6 — the data-profile work unit is one all-or-nothing pass. Moré-Wild’s
d_s(κ) is the fraction of problems solved within κ work units, where one unit
is n_p+1 function evaluations (a simplex gradient). The TA analog is one AON
pass = n_origins shortest-path trees, so κ = sp_calls / n_origins (Braess 1,
Sioux Falls 24). Because n_origins is a scenario property not carried by the
certified row schema (and the runner is unchanged), it is supplied by the caller
(default 1 ⇒ κ = raw work); a work_unit dict with a scenario key missing is
refused, never silently defaulted to 1 (that would mix raw sp_calls with
per-pass units in one profile). The convergence test is the certified metric
<= τ (a gap-based MW variant; MW’s f-based test f(x) <= f_L + τ(f(x0)-f_L)
needs a common x0, which non-iterative paradigms do not share — disclosed). Each
(scenario, macrorep) pair is one equally-weighted problem: a deterministic model
contributes a single rep per scenario while a stochastic model contributes M, so a
mixed deterministic/stochastic grid weights the stochastic model’s scenarios more
heavily in the pooled data profile — disclosed, not corrected (collapsing macroreps
to a per-scenario summary would discard the sampling spread the stochastic track
exists to show).
D7 — one-level functional bootstrap (macroreps only). SimOpt’s second
resampling level draws post-replications; here the deterministic certificate has
zero estimation noise and the probit certificate is a pinned protocol constant
(adr-003: a fixed r_cert sample, CRN across everything in the run), so there is
no post-replication noise to resample. bootstrap_curve_band extends
bootstrap.py’s discipline — percentile, never parametric (P5), on the reserved
SOURCE_BOOTSTRAP stream, byte-reproducible from root_seed alone (P8).
D8 — the certified artifact is profiles.json. It carries the profile curves,
the protocol constants (metric, axis, α/τ/β, crossing rule, censoring rule, mesh),
and full provenance (scenario hashes, manifest budget blocks, seeds, tabench
version, git commit). Plots are rendering, never the artifact. A censored +inf
curve value is written as the JSON string "Infinity" (not a bare Infinity
token), so the artifact is strict RFC-8259 that any conformant consumer (jq, a
browser) parses; the reader restores it. A NaN never reaches the file — the curve
encoder refuses it and json.dumps(allow_nan=False) is the belt-and-suspenders.
The P1 story: profiles are deterministic pure functions of already-certified rows,
so there is no new trust surface and therefore no new certifier — correctness is
pinned by the closed-form tests below, provenance by the artifact schema.
D9 — the β-quantile is SimOpt’s exact estimator, made inf-aware. The default
(quantile_method="simopt") is byte-parity with SimOpt’s quantile_cross_jump /
quantile_of_curves: statistics.quantiles(values, n=100)[int(β·99)]
(exclusive-interpolated). Because that estimator interpolates, a window that
reaches a censored +inf returns a non-finite quantile — which for a solvability
jump is the flat-zero (unsolvable) curve and for a progress-curve quantile is a
censored +inf; it is made inf-aware so it never poisons to NaN. Note this means
{0.2, 0.5, ∞} at β=0.5 is flat-zero (the median lands in the censored tail under
the exclusive interpolation), not a jump at 0.5 — the earlier draft’s “parity”
claim while shipping the type-1 estimator was false and always flattered the solver.
The type-1 lower inverted-cdf sorted[⌈β·n⌉−1], which never interpolates a censored
+inf, is retained as an opt-in quantile_method="censoring-robust" — the honest
alternative when interpolating into the censored tail is undesirable.
D10 — solvability and data profiles require the full cross design. SimOpt’s
profiles assume every solver is run on every problem. A model absent from a
scenario would shrink only its own per-model denominator, so a solver that skipped
the hard scenarios could top the profile a solver run everywhere honestly trails.
cdf_solvability / quantile_solvability / data_profile therefore refuse
(naming the offending models and the scenarios they are missing from) when the
per-run model sets are incongruent; a single run is trivially congruent.
Closed-form anchors¶
Every profile value is derivable by hand and recomputed in tests/test_profiles.py
(no BLAS-sensitive assertions):
α-solve regression (integration). Braess
msa/fw/bfwunderBudget(iterations=50), α=1e-4, onsp_calls: solve times {msa: 5, fw: 24, bfw: 4} (measured directly from the certified rows). All three share the AON start (iteration 1,sp_calls=2, gap 1.9118e-1, P7).msa’s crossing atsp_calls=5is a genuine first crossing even though its trace ends unconverged (6.5e-3) — the strict-<first-crossing semantics, not sustained convergence.bfw’s 3-checkpoint trace (sp_calls2,3,4) carries forward to the realized budget end 51 (D5).Strict-
<at a knot (D3). Onx=(1,2,3),y=(0.5,0.2,0.1):crossing_time(0.2)strict = 3 (the knot valued exactly 0.2 does not count), non-strict = 2.Censored denominator (D4). cdf of solve times
{0.2, 0.5, ∞}: terminal value 2/3, the∞counted in the denominator.Quantile parity (D9), checked against
statistics.quantiles. Under the default SimOpt estimator:{0.2, 0.5, ∞}at β=0.5 ⇒ flat-zero (the exclusive interpolation reaches the censored tail);{0.2, 0.5, ∞, ∞}at β=0.5 ⇒ flat-zero;{0.1, 0.2, 0.3, ∞}at β=0.5 ⇒ jump at 0.25;{0.2, 0.5, 0.9, ∞}at β=0.5 ⇒ jump at 0.7. Thecensoring-robustopt-in gives jump at 0.5 / 0.2 for the first and third — the divergence the shipped “parity” claim had hidden.Inf-honesty (M3).
quantile_of_curvesand the bootstrap band over curves that carry+infyield+inf, neverNaN; a both-censored difference is the zero curve, a one-censored difference is±inf; aNaNreachingwrite_profilesraises.Full-cross-design refusal (D10). Two scenarios where model
Bis run only on the first ⇒cdf_solvabilityraises namingBmissing from the second (elseB, tested on one easy scenario, would top the honestAtested on both).Overshoot censoring (M7). With a manifest
sp_callsbudget 10 and a crossing atsp_calls=11, the cdf and the quantile jump BOTH read it as censored (terminal 0), never one counting and the other dropping it.Moré-Wild staircase (D6). Two scenarios × two solvers with solve work
A=(2,6),B=(4,∞), unit 1:d_A(2)=1/2,d_A(6)=1,d_B(4)=1/2, terminald_B=1/2(the never-solved problem stays in the denominator).Difference profile. A solver differenced against itself is the identically zero curve.
Bootstrap band. Byte-deterministic in
root_seed; identical macroreps give a zero-width band; the band brackets the mean curve;M < 2refuses.Artifact round-trip. In-memory rows and
load_runof the written CSV+manifest pair yield identical profiles; the artifact is strict RFC-8259 (censored+infas the string"Infinity", no bare token) and carries the protocol constants and the scenario hash. A T2 estimation CSV raises a clear, named limitation.
Alternatives considered¶
Put profiles in
metrics/: rejected —metrics/*are P1 certifiers over(scenario, flows); profiles consume already-certified rows and add no trust surface.experiments/(the bootstrap altitude) is the honest home.SimOpt-faithful Beckmann-normalized progress curves: the
(f-f*)/(f(x0)-f*)normalization needs estimatedf(x0)/f(x*); the certified gap is already the scale-free measure with a true zero (D1). A Beckmann variant is a clean later addition, not v1 scope.A two-level bootstrap resampling post-replications: the certificate has no post-replication noise to resample (D7); the single macrorep level is the whole sampling story here.
Normalizing every solve time to a budget fraction: rejected as the primary report — α-solve times are reported in hardware-free work units (P6/D2); the budget-fraction normalization is used only inside the cross-scenario cdf mean, where different scenarios have different budgets.
A
tabench profiles <run.csv>...CLI subcommand writingprofiles.json: deferred to a follow-up; the demo exercises the module end-to-end and the artifact writer is public.Profiling the estimation (T2/T2d) track: a named follow-up, not free. The T2 CSVs key rows on
estimator(notmodel) and censor onod_feasible(notfeasible), and rank onheldout_count_rmse— so a T2 pair needs a column mapping andod_feasiblecensoring, not just the shared budget-coordinate shape. Until that ships,load_runand the profile functions detect the T2 schema and raise a clear, named limitation rather than dying withKeyError('model').
Consequences¶
The benchmark redeems the last P5/P6 promise: certified progress curves,
α-solve-time cdf/quantile solvability profiles, Moré-Wild data profiles,
difference profiles, and functional bootstrap bands, all as a profiles.json
artifact — with zero changes to any solver, certifier, or the runner, no new
runtime dependency (numpy/scipy core; the demo’s matplotlib is import-guarded),
and no external data or spend (P9 untouched). The golden Braess hash is
byte-identical. Follow-ups: the tabench profiles CLI, an estimation-track demo,
and the Beckmann-normalized progress-curve variant.
Adversarial review¶
Three independent lenses (semantics-fidelity vs the fetched SimOpt master source, closed-form hand-derivations + real-data behavior, numerics/API/ integration), each executing repros; every finding CONFIRMED and fixed with a per-finding regression (streak: 20/20 sprints with at least one material defect; 48 tests after the fixes, from 31). For pure reporting code the false-certify analog is STATISTICAL DISHONESTY, and the review found four material ways this module could flatter models:
MAJORs, all fixed + pinned: (a) — found by ALL THREE lenses — missing
(model, scenario) cells silently shrank per-model denominators, so a model
that skipped a hard scenario topped the profile (measured: honest 0.5 vs
selective 1.0, no warning; SimOpt’s full cross design cannot express the
state) → cross-scenario profiles now REFUSE incongruent model sets (D10);
(b) the β-quantile estimator was NOT SimOpt’s despite explicit parity claims,
diverging always in the flattering direction (type-1 lower inverted-CDF vs
statistics.quantiles(n=100)[int(β·99)] exclusive-interpolated with the
NaN→flat-zero censored branch — the draft’s own closed-form anchor
contradicted measured SimOpt) → SimOpt-exact is now the default, pinned
in-test against statistics.quantiles itself, with the censoring-robust
type-1 variant as an explicit opt-in (D9); (c) censored macroreps
NaN-poisoned quantile curves and bootstrap bands (np’s lerp computes
inf−inf) where the honest value is +inf → inf-aware percentiles throughout,
and a NaN reaching write_profiles raises; (d) the degenerate-axis guard
checked the DEFAULT metric rather than the REQUESTED one, so a zero-work
model topped a ranked profile on an alternate metric — the exact flattery D2
promises to refuse → the metric is threaded through the guard, both
directions pinned.
MEDIUMs/MINORs, fixed: one static_so model in a mixed grid flipped the
default metric for every model (now: all-static_so only); T2 estimation runs
crashed with a bare KeyError under a “generalizes for free” claim (now a
clear named refusal; T2 profiles are a named follow-up); cdf and quantile
disagreed on crossings past the realized budget envelope (both now censor);
M=1 bootstrap “confidence” bands refused; profiles.json was not RFC-8259
under censoring (+inf now the string "Infinity", allow_nan=False, schema
tabench-profiles-v1); work_unit key misses, duplicate-x row-order
dependence, NaN x-knots, blank axis cells, and unknown metric names all
refuse loudly instead of degrading silently; the ADR’s D-decisions were
corrected where the code or SimOpt source contradicted them.
Survived (highlights): the strict-< first-crossing rule verified
identical to the fetched Curve.compute_crossing_time; the re-crossing
honesty of first-crossing solve times demonstrated on a REAL oscillating
trace (braess msa: 3.6e-9 at sp=5 → 0.104 at sp=6 → terminal 6.5e-3) and
disclosed; the cdf estimator arithmetic-equivalent to SimOpt’s
bisect-over-sorted-crossings; the braess anchor {msa:5, fw:24, bfw:4}
reproduced through the new code path against the scoping dossier’s
measurement; left-endpoint step AUC, union-mesh means, carry-to-budget-end
and the +inf-before-first-checkpoint convention all as disclosed;
byte-determinism of the bootstrap band in root_seed; additive-only changes
to bootstrap.py and the __init__ exports; the golden Braess hash
byte-identical.
Sourcing¶
The SimOpt diagnostics semantics were measured against the library source
(github.com/simopt-admin/simopt, master: simopt/curve.py,
simopt/curve_utils.py, simopt/experiment/post_normalize.py,
simopt/experiment/single.py, simopt/plots/solvability_profile.py) and the two
definition papers above (Diagnostic Tools, DOI 10.1287/ijoc.2022.1261;
Moré & Wild 2009, DOI 10.1137/080724083). The strict-< crossing, the
censored-in-denominator cdf (bisect_right(sorted_crossings, t) / n_curves), the
flat-zero quantile, the union-mesh mean_of_curves, and the left-endpoint step
AUC are direct ports of that source, pinned by the closed-form anchors. The eight
deviations (D1-D8) are TABenchmark’s, disclosed here; no SimOpt number is
reproduced (their curves are over their own simulation-optimization problems).