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/bfw under Budget(iterations=50), α=1e-4, on sp_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 at sp_calls=5 is 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_calls 2,3,4) carries forward to the realized budget end 51 (D5).

  • Strict-< at a knot (D3). On x=(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. The censoring-robust opt-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_curves and the bootstrap band over curves that carry +inf yield +inf, never NaN; a both-censored difference is the zero curve, a one-censored difference is ±inf; a NaN reaching write_profiles raises.

  • Full-cross-design refusal (D10). Two scenarios where model B is run only on the first ⇒ cdf_solvability raises naming B missing from the second (else B, tested on one easy scenario, would top the honest A tested on both).

  • Overshoot censoring (M7). With a manifest sp_calls budget 10 and a crossing at sp_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, terminal d_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 < 2 refuses.

  • Artifact round-trip. In-memory rows and load_run of the written CSV+manifest pair yield identical profiles; the artifact is strict RFC-8259 (censored +inf as 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 estimated f(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 writing profiles.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 (not model) and censor on od_feasible (not feasible), and rank on heldout_count_rmse — so a T2 pair needs a column mapping and od_feasible censoring, not just the shared budget-coordinate shape. Until that ships, load_run and the profile functions detect the T2 schema and raise a clear, named limitation rather than dying with KeyError('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).