Skip to content

refactor: switch from argmin to basin, pin MSRV - #32

Merged
dancixx merged 5 commits into
rust-dd:mainfrom
jolars:refactor/adopt-basin
Sep 10, 2026
Merged

dancixx merged 5 commits into
rust-dd:mainfrom
jolars:refactor/adopt-basin

Conversation

@jolars

@jolars jolars commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace argmin and argmin-math with Basin 1.9.0 across the statistics and
    quantitative-finance crates.
  • Migrate L-BFGS workloads to Basin's L-BFGS-B implementation, including
    explicit box constraints and More-Thuente line searches where appropriate.
  • Migrate calibration and portfolio simplex workloads to Basin's Nelder-Mead
    implementation while preserving the existing sample-standard-deviation
    stopping rule.
  • Preserve best-point fallbacks and distinguish successful convergence from
    iteration limits or solver failures.
  • Update optimizer references in the Rust and website documentation.
  • Establish Rust 1.89 as the workspace MSRV and check it in CI.

Performance

I compared the existing mle_fit Criterion benchmarks before and after the
optimizer migration. Both revisions used the same benchmark code, deterministic
inputs, ten samples, and a ten-second measurement window per case. Times are
Criterion slope point estimates; lower is better.

Workload Argmin 0.11 Basin 1.9 Speedup
OU Euler, 1k observations 10.66 ms 4.36 ms 2.45x
OU Kessler, 1k observations 22.88 ms 13.57 ms 1.69x
CIR Kessler, 1k observations 34.03 ms 10.64 ms 3.20x
OU Euler, 5k observations 52.40 ms 21.53 ms 2.43x
OU Kessler, 5k observations 207.07 ms 55.04 ms 3.76x
CIR Kessler, 5k observations 146.36 ms 65.30 ms 2.24x

I also ran Basin with and without its parallel feature in an A/B/A order.
These workloads provide crate-owned gradients and therefore do not invoke
Basin's Rayon-backed finite-difference or batch-cost paths. The measurements did
not establish a causal benefit from the feature, so this PR leaves it disabled.

I used Codex to prepare the migration and to draft this PR description.

Closes #31

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

@jolars is attempting to deploy a commit to the GymPiper Team on Vercel.

A member of the Team first needs to authorize it.

@dancixx

dancixx commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

@jolars would you also migrate the levenberg opt?

@jolars

jolars commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

I'd be happy to. Would you like it in this PR or as a separate one?

@dancixx

dancixx commented Sep 8, 2026

Copy link
Copy Markdown
Member

Let's do it separately. One thing that can be interesting: the current crate is using MINPACK-lmder, which a pivoted QR on the Jacobian. In basin, you have a normal equation, afaik, so just migrating maybe won't be enough and can cause convergence issues in the different SVI models.

@jolars

jolars commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Yes, you are right. Hm, I'll update Basin's implementation to either make this configurable or just use QR instead.

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Box constraints are not actually enforced by basin 1.9.0's L-BFGS-B

Thanks for the migration — the benchmark numbers are a real win. Before merging, though, there is one issue I think is blocking, and it lands specifically on the fitting paths.

This PR removes the defensive clamp() from cost()/gradient() in three places (mle/fit.rs, hurst/whittle.rs, vol_surface/sabr_smile/objective.rs) on the assumption that L-BFGS-B keeps its iterates inside the box. basin 1.9.0 does not honour that assumption.

Root cause

basin/src/solver/lbfgs.rs:608-644 computes the Fortran lnsrlb feasibility cap and then throws it away:

// Maximum feasible step (Fortran lnsrlb `stpmx`).
let stpmx = if cnstnd { ... feasible_step_cap(...) } else { ... };
...
// `LineSearch` has no generic hooks for the initial step or the
// feasibility cap used by Fortran's `lnsrlb`.
let _ = (alpha_init, stpmx);

There is no projection of state.param back onto the box after the line search either — only the initial iterate is projected, in Solver::init. So whenever Moré–Thuente extrapolates past stp = 1 (i.e. whenever the objective is still descending at the bound, which is exactly the active-constraint case), the iterate leaves the box.

Reproduction

Minimal program against basin = "1.9.0", minimising (x - c)^2 over the box [0, 1]^2 from [0.5, 0.5]:

c returned param() / best_param() box violation
5 [1.0, 1.0] 0
8 [3.0, 3.0] 2.0
50 [3.0, 3.0] 2.0

This is not caused by the line-search settings introduced here: Lbfgsb::default() (basin's own MoreThuente::new(), stpmax = 1e10) produces the identical [3.0, 3.0]. basin's own shifted_quadratic_in_box_converges_to_clamp test passes only because there the unconstrained optimum sits just barely outside the bound.

What this breaks here

  1. hurst/whittle.rs can return an out-of-box Hurst exponent. Measured on this branch: 4 of 24 synthetic log-RV series returned H outside the declared [0.005, 0.495], the worst being H = 0.5507. Inline comment below.
  2. converged can be reported true at a non-stationary, infeasible point, because the crate's own bound-guarded finite difference returns a zero gradient outside the box. Inline comment below.
  3. sabr_smile/objective.rs has a finite-difference scaling bug introduced by the same change. Inline comment below.

Suggested fix

The cheapest fix that keeps the migration is to restore the clamp() in cost()/gradient() at all three sites, plus the problem.clamp(&best_p) at the end of whittle.rs::run_lbfgs. It is redundant if the solver honours the box — which is precisely why the pre-existing code had it. Filing an upstream issue against basin for the discarded stpmx would be worth doing in parallel.

Minor, non-blocking

  • The Rust workflow has not run on this PR — gh pr checks 32 shows only a Vercel check — so test, lint and the new msrv job are all unverified. Worth a maintainer approval run: stochastic-rs-quant/src/calibration/tree_swaption/tests.rs:63 asserts result.converged, and the converged semantics tightened here (previously any non-Err executor outcome counted as converged; now only SimplexTolerance does).
  • .claude/skills/dev-rules/SKILL.md:53 and .claude/skills/calibration-pattern/SKILL.md:183,204 still recommend argmin. Easy to miss, since grep -r skips hidden directories.
  • SimplexStandardDeviation is now defined twice — in calibration.rs and in portfolio/optimizers/mod.rs — with slightly different validation (Option vs debug_assert!).

Things I checked that are fine

The .expect("… is infallible") calls are sound (Solver::Error = P::Error = Infallible, so run()'s error arm is uninhabited); basin's Nelder-Mead coefficients (α=1, β=2, γ=0.5, δ=0.5) match argmin's; the portfolio fallback is behaviour-preserving because softmax(0) = 1/n equals the old vec![1.0 / n; n]; more_thuente() faithfully reproduces argmin's line-search defaults; and CostTolerance is a cost-change test rather than a cost-level one, so it does not fire immediately on negative log-likelihoods.

.run()
.expect("Whittle objective is infallible");
let best = result.best_param();
(best[0], best[1], problem.eval(best))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_lbfgs no longer clamps its result. It used to be:

let best_p = res.state.get_best_param().cloned().unwrap_or(init);
let clamped = problem.clamp(&best_p);
let cost = problem.eval(&clamped);
(clamped[0], clamped[1], cost)

Since basin's L-BFGS-B does not keep iterates inside the box (details in the review comment), best can lie outside self.lower / self.upper. run_estimate at line 428 then assigns it straight into best_h, and neither FukasawaResult nor the HurstEstimator wrapper at line 111 validates the range — so estimate() can return H >= 0.5, which this parameterisation does not admit, and eta = best_v / delta.powf(best_h) is corrupted along with it.

Measured on this branch over 24 synthetic log-RV series: 4 returned an out-of-box H, the worst being

seed 7: OUT OF BOX  H = 0.5506779013063017  eta = 0.27242376415994

Restoring the clamp here — and in WhittleProblem::eval / WhittleProblem::gradient — fixes it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Control run, to confirm this is a regression rather than something that was already possible: the identical probe, same 24 seeds, same code, built against main (b8c20fa, still on argmin):

main    (argmin):  out-of-box estimates: 0 / 24,  max H seen: 0.495
this PR (basin):   out-of-box estimates: 4 / 24,  max H seen: 0.5506779013063017

On main the maximum is exactly 0.495 — the declared upper bound — because run_lbfgs clamped its result before returning it. On this branch the same series produce H > 0.5.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about this!

I'm tracking the L-BFGS-B problem in jolars/basin#90 and will update basin after solving it.

Comment thread stochastic-rs-stats/src/mle/fit.rs
Comment thread stochastic-rs-quant/src/vol_surface/sabr_smile/objective.rs Outdated

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two additions after a deeper pass. The first is more serious than anything in my earlier review: the removed clamp_params in the SABR smile objective opens a reachable panic that does not depend on the box-escape issue at all — the calibrator's own hard-coded x0 is outside the bounds it declares, and clamp_params was what made that survivable. The second is a note about the MSRV pin's effect on dependency resolution. Both inline.

Comment thread stochastic-rs-quant/src/vol_surface/sabr_smile/objective.rs
Comment thread Cargo.toml

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swept the parts my earlier passes had not touched — the README/website doc edits and all nine Cargo.toml files. Those are clean: every workspace member picked up rust-version.workspace = true, argmin/argmin-math are correctly dropped rather than swapped in stochastic-rs-copulas and stochastic-rs-stochastic (they carried it as an unused dependency), basin is added only to the two crates that actually optimize, default-features = false is right since basin's default problems feature is only its test problem set, and the doc wording matches the new code. One small thing found, inline.

Comment thread stochastic-rs-quant/src/portfolio/optimizers/mod.rs Outdated

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final pass — this closes out my review. Four more inline comments, then two items that fall outside the diff so they have nowhere to hang, and finally the list of things I checked and cleared, which is the more useful half if you are working through this.

Outside the diff, but caused by it

website/content/docs/quant.mdx:204-208 documents the Python return tuples for the three rate calibrators:

print(bk.calibrate(initial_guess=(0.1, 0.2)))   # (a, sigma, rmse, converged)
print(hw.calibrate())                            # (a, sigma, rmse, converged) via Jamshidian
print(g2.calibrate())                            # (a, b, sigma, eta, rho, rmse, converged)

That last element changes meaning in this PR — from "the run completed" to "the simplex SD fell below sd_tolerance". A Python user branching on converged will start getting False on grids that previously returned True. This PR already edits lines 21, 22, 28 and 56 of that same file, so it is in touched territory; it needs a note here, in the PR description, or in a CHANGELOG.

stochastic-rs-quant/src/python/calibration_basic.rs:220 is a fourth Python surface leaking the same flag — PySabrCapletCalibrator.calibrate() returns (alpha, beta, nu, rho, rmse, converged). It matters more than the other three because SabrCapletCalibrator carries the largest budget (max_iters: 600, sd_tolerance: 1e-10), so it is the most likely of the four to exhaust its iterations.

Checked and cleared — no action needed

  • .unbounded() is a faithful swap for the old plain LBFGS. H₀ scaling is identical: basin/src/core/state/lbfgs.rs:289 is self.theta = yy_dot / sy_dot, so H₀ = (1/θ)·I equals argmin's gamma = sk·yk / yk·yk. History size m = 10 is unchanged, and line-search-failure handling is equivalent (SolverFailed still yields best_param(), as argmin's SolverExit did). One real divergence, in your favour: basin applies the Fortran curvature skip (dr > epsilon * |ddum|) where argmin pushed every (s, y) pair unconditionally. Polished estimates will shift slightly from the released version — a release note, not a defect.
  • distfit.rs / garch.rs converged never comes from the basin polish. minimise returns converged || converged_restart from nelder_mead_vec (distfit.rs:97, garch.rs:318); polish returns only (theta, iters). So tests/doctest_stats_garch.rs:31 and doctest_stats_distfit.rs:20,26 are safe, and python/distfit.rs:72,81,90 / python/garch.rs:48 are unaffected by the converged change.
  • The iters semantics change is unobservable. Only the deleted Err(_) => (theta, 0) arm differs, and iters_polish is summed with two simplex runs, so garch.rs:227 and :229 both remain accurate.
  • from_simplex's assert!(len >= 2) replaces no graceful path. argmin's NelderMead::next_iter indexes params[num_param_vecs - 2], so a 1-vertex simplex panicked there too — basin's assert is simply the better message. All call sites build 3-6 vertices from literals, and n == 0 is guarded in all five portfolio entry points.
  • SabrSmileCalibrator is Rust-only — no SabrSmile symbol under any src/python/, so the panic findings do not reach the Python surface.
  • The doc and manifest edits are correct and complete. Every workspace member picked up rust-version.workspace = true; argmin/argmin-math are rightly dropped rather than swapped in stochastic-rs-copulas and stochastic-rs-stochastic, which carried them as unused dependencies; basin is added only to the two crates that optimize; default-features = false is correct, since basin's default problems feature is only its own test problem set.
  • The long-short portfolio fallback is behaviour-preserving. Deleting let x0 = vec![0.0; n] looks like it changes the fallback, but tanh_weights(zeros) takes the abs_sum < 1e-15 branch at helpers.rs:59 and returns vec![1.0/n; n] — byte-identical to the old value.

Comment thread stochastic-rs-stats/src/mle/fit.rs Outdated
.with_c(1e-4, 0.9)
.expect("Wolfe params (1e-4, 0.9) satisfy 0 < c1 < c2 < 1 by construction");
let solver = LBFGS::new(linesearch, 10);
let linesearch = MoreThuente::new()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things worth reconsidering about this loop, now that basin is the dependency.

It hand-rolls what basin already exports. basin::BasinHopping, basin::RandomDisplacement and basin::Metropolis are all re-exported at the crate root (basin/src/lib.rs:417-424). Line 164's uniform jitter is RandomDisplacement, and lines 193-199 are Metropolis. .claude/skills/dev-rules/SKILL.md:53 is explicit about this: "Do not rewrite algorithms that already exist in well-maintained crates ... only write custom code when no suitable crate exists." Migrating to the crate's own basin-hopping would have been the natural shape of this PR; keeping the hand-rolled loop is a defensible choice, but it should be a stated one.

The per-hop rebuild is new cost. Lines 168-185 sit inside for _ in 0..niter, so the line search, the solver and both termination criteria are constructed once per hop — and because this PR widened bounds_lo/bounds_hi from [f64; NVARS] to Vec<f64>, problem.bounds_lo.clone() / bounds_hi.clone() now heap-allocate on every iteration. With the default short_tenor_iters that is a few thousand allocations per smile that the pre-PR fixed-size arrays never made. Hoisting the solver construction out of the loop, or using ProjectedGradientTolerance::from_problem, removes it.

Related: the registered ProjectedGradientTolerance duplicates a check Lbfgsb already runs internally each iteration (basin/src/solver/lbfgs.rs:405-420, its own tol_pg), and clones state.param() on every check. Lbfgsb::with_tol_pg(f64::EPSILON.sqrt()) expresses the same intent allocation-free. Same pattern at whittle.rs:368 and mle/fit.rs:225.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept the outer loop to preserve the RNG stream and failed-hop behavior, and documented why. The local solver is now reused through InnerExecutor, and all three fits use with_tol_pg.

Comment thread stochastic-rs-stats/src/optim.rs
Comment thread stochastic-rs-quant/src/calibration.rs Outdated
@jolars

jolars commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. The fixes are in commit 28d34dc. I've also documented the changed converged meaning for all four Python calibrators.

All 70 affected tests pass, including the tree-swaption assertions. The Rust 1.89 check, Clippy, formatting, and docs builds pass locally too. The GitHub Actions run still needs maintainer approval.

@jolars
jolars force-pushed the refactor/adopt-basin branch from 9c251ee to dd720e0 Compare September 10, 2026 11:23
@jolars

jolars commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Okay, I've solved the bounds problem in Basin and updated the dependency here. It's ready for another review!

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at dd720e01. Every finding from my earlier reviews is addressed, and the root cause is now fixed upstream as well. I verified the fixes rather than taking them on faith — details below, including the version bump, which I treated as new surface since my whole earlier review was against basin 1.9.0.

The root cause is gone, and the regression with it

Bumping to basin 1.10.0 fixes the discarded feasibility cap I reported. solver/lbfgs.rs now actually threads it through:

let stpmx = safeguard_step_cap(&state.param, ..., stpmx);
self.line_search.next_with_bounds(..., LineSearchBounds::new(alpha_init, stpmx))?

Re-running my original standalone reproduction ((x-c)² over [0,1]² from [0.5, 0.5]) against 1.10.0 — every case that escaped before is now feasible:

c 1.9.0 1.10.0
8 [3.0, 3.0] — escaped [1.0, 1.0]
50 [3.0, 3.0] — escaped [1.0, 1.0]
100000 [3.0, 3.0] — escaped [1.0, 1.0]

And the end-to-end Whittle regression I measured earlier is closed. Same probe, same 24 seeds:

main    (argmin):        0 / 24 out of box,  max H = 0.495
PR @1560c6cf (basin 1.9): 4 / 24 out of box,  max H = 0.5507
PR @dd720e01 (fixed):     0 / 24 out of box,  max H = 0.495

Exactly main's behaviour, with 0.495 — the declared bound — as the maximum.

What I checked about the version bump

Since 1.10.0 is a substantial release (new LineSearch::next_with_bounds, rewritten COBYLA, reworked math backends), I checked the surfaces this PR actually uses:

  • MoreThuente::search is byte-identical between 1.9.0 and 1.10.0, and next_with_evaluation just calls it. So the .unbounded() polish paths in garch.rs and distfit.rs are unaffected by the bump.
  • The Lbfgs<Unbounded> solver impl is byte-identical too (diff of the whole impl block: no output).
  • .stpmax(f64::INFINITY) is now inert where it mattered. next_with_bounds does let max = self.stpmax.min(bounds.max), so the per-iteration box cap always dominates. On the unbounded path it behaves exactly as argmin's default did. That fully retires my earlier concern about this setting.
  • basin's MSRV is still 1.87, so the 1.89 pin continues to cover it.

Verified rather than assumed

  • InnerExecutor reuse across hops is safe. This was my main suspicion about hoisting the solver out of the loop, since CostTolerance holds a last: Option<F>. run_loop (core/executor.rs:493) calls criterion.reset() at the start of every run, and the InnerExecutor docs make that a documented guarantee. No state bleeds between hops.
  • The None => SolverFailed arm cannot misfire at iteration 0. I was concerned the criterion might see from_simplex's initial vec![inf; n] costs. It cannot: solver.init() runs before the loop and before any criterion check, so the simplex is populated with real costs first.
  • The scaling in sample_standard_deviation solves the right problem. SabrCapletCost and HullWhiteCost return f64::MAX as an out-of-domain sentinel — finite, but its squared deviation would overflow to infinity and produce exactly the NaN I flagged. Dividing by scale first closes that, and sample_standard_deviation(&[f64::MAX, f64::MAX]) == Some(0.0) pins it.
  • Dropping rho.clamp(-0.99, 0.99) / nu.max(0.01) in calibrate.rs is correct, not a lost guard. I traced every assignment to best_x — the initial clamp_params(&x0), and current_x = param where param = clamp_params(result.param()) — so it is clamped on every path including niter = 0, and bounds_lo[4..6] are exactly 0.01 / -0.99. The four strikes are now in-box too, which they were not before.
  • Recomputing the cost at the clamped param in the hop loop, instead of reusing result.cost(), is more correct than what I asked for — result.cost() was the cost at the unclamped iterate.
  • The SABR gradient fix goes past the report. I only flagged the eps denominator; this moves to a central difference with the realised step and a step > 0.0 guard, matching whittle.rs and mle/fit.rs.
  • The extra converged gate in fit_mle — invalidating convergence when best_params is infeasible — is a good addition I had not asked for.

The one thing rejected, correctly

Keeping the hand-rolled basin-hopping instead of basin::BasinHopping is the right call, and the new doc comment gives the reason: BasinHopping owns a ChaCha RNG, which would break the existing SimdRng stream and therefore reproducibility. That is a stronger argument than my dev-rules §7 point. The part that was a real cost — rebuilding the solver and heap-allocating both bounds vectors on every hop — is fixed by hoisting into InnerExecutor.

Test results

Run against dd720e01 on my machine:

Command Result
cargo test -p stochastic-rs-stats --lib 277 passed, 0 failed
cargo test -p stochastic-rs-quant --lib 1013 passed, 0 failed, 3 ignored
cargo clippy -p stochastic-rs-stats -p stochastic-rs-quant --all-targets -- -D warnings clean — 0 warnings
cargo test -p stochastic-rs --test doctest_quant_tree_swaption 1 passed, 0 failed

The 3 ignored are pre-existing #[ignore]d slow HSCM tests in files this PR does not touch. black_karasinski_recovers_synthetic_parameters — the test carrying assert!(result.converged), the tripwire I was most worried about for the tightened converged semantics — passes. I ran the umbrella-crate tree-swaption test separately because -p stochastic-rs-quant --lib does not build it; it passes too.

Two nits, neither blocking

.claude/skills/calibration-pattern/SKILL.md:211 now reads "Basin 1.9.0 can evaluate outside the box during its line search", but the workspace pulls 1.10.0, where that is fixed. Keeping the clamps is still right as defence in depth — the sentence just cites a version the project no longer uses. Something like "clamp regardless; the solver's box handling is not a guarantee you should depend on" would age better.

mle/fit.rs: the TerminationReason::ProjectedGradientTolerance arm in resolve_fit_outcome is now unreachable, since with_tol_pg reports SolverConverged instead. Harmless, and arguably worth keeping if the external criterion ever comes back.

Nothing else from my side. Nice work on the migration docs — the converged semantics change is now documented in three places a user might actually look.

@dancixx dancixx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two nits from my last review, as applicable suggestions. The second one grew slightly on closer inspection: it is three unreachable arms rather than one, and the convergence test is feeding resolve_fit_outcome a reason production can never produce — that last part is the only bit with any real substance. Neither is blocking; happy for you to take or leave them.

Maintainer edits are enabled on this branch, so say the word if you would rather I push these myself than have you click through them.

under `portfolio/optimizers/`. Reuse `calibration::run_nelder_mead`
to preserve sample-standard-deviation stopping and convergence reporting.
Bounded objectives and finite differences must clamp their inputs:
Basin 1.9.0 can evaluate outside the box during its line search.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clamps are the right call, but this sentence pins the reason to a version the PR no longer uses — the workspace now resolves basin 1.10.0, where next_with_bounds applies the feasibility cap and the box is respected (I verified this: my 1.9.0 escape reproduction returns feasible points on 1.10.0).

Since the advice should outlive any particular basin release, it reads better without the version claim:

Suggested change
Basin 1.9.0 can evaluate outside the box during its line search.
do not rely on the solver's box handling to keep iterates feasible.

Comment thread stochastic-rs-stats/src/mle/fit.rs Outdated
Comment on lines +155 to +159
TerminationReason::ProjectedGradientTolerance
| TerminationReason::GradientTolerance
| TerminationReason::CostTolerance
| TerminationReason::TargetCost
| TerminationReason::SolverConverged

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on my earlier note about the unreachable ProjectedGradientTolerance arm — having checked it properly, it is three arms, not one.

fit_mle registers exactly one criterion (CostTolerance, line 235) plus max_iter (234) and the solver's internal tol_pg (231). basin 1.10.0 reports internal projected-gradient convergence as SolverConverged (solver/lbfgs.rs:425, unchanged from 1.9.0), so the reachable set is:

Reason Source Reachable
CostTolerance registered criterion yes
SolverConverged solver tol_pg yes
MaxIter max_iter(200) yes (not matched — correct)
SolverFailed line-search failure yes (not matched — correct)
ProjectedGradientTolerance — no criterion registered
GradientTolerance — no criterion registered
TargetCost — no criterion registered

Narrowing to what can actually arrive also fails safe: if a criterion is added later without updating this list, the result is an under-reported converged, not a fabricated one.

Suggested change
TerminationReason::ProjectedGradientTolerance
| TerminationReason::GradientTolerance
| TerminationReason::CostTolerance
| TerminationReason::TargetCost
| TerminationReason::SolverConverged
TerminationReason::CostTolerance | TerminationReason::SolverConverged

Comment thread stochastic-rs-stats/src/mle/fit.rs Outdated
let (params, converged, iterations) = resolve_fit_outcome(
fitted.clone(),
TerminationStatus::Terminated(TerminationReason::SolverConverged),
TerminationReason::ProjectedGradientTolerance,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth changing alongside the matches! narrowing above, and it is the part that actually matters: this is the only test of the happy path, and it feeds in a reason fit_mle can never receive. So today it asserts that a state which cannot occur maps to converged = true, and it would keep passing even if the reachable reasons stopped mapping correctly.

SolverConverged is what basin reports when the solver's own tol_pg fires, which is the ordinary way this fit converges:

Suggested change
TerminationReason::ProjectedGradientTolerance,
TerminationReason::SolverConverged,

CostTolerance would work equally well if you would rather cover the registered criterion; mle_result_signals_non_convergence_without_error and ..._retains_best_point_on_solver_failure already pin MaxIter and SolverFailed, so between them the four reachable reasons would all be covered.

@dancixx

dancixx commented Sep 10, 2026

Copy link
Copy Markdown
Member

Pushed both nits to the branch rather than leaving you to click through the suggestions — maintainer edits were enabled, and neither change is one you should have to think about:

  • 2f14ae3e narrows resolve_fit_outcome's matches! to CostTolerance | SolverConverged and repoints mle_result_signals_genuine_convergence at SolverConverged, so the happy-path test now exercises a reason fit_mle can actually receive.
  • 0b5f3f96 drops the basin version from the clamp guidance in the skill file.

Verified locally before pushing: cargo fmt --check clean (it reflowed the test's call onto one line, which is included), cargo clippy -p stochastic-rs-stats --all-targets -- -D warnings clean, and cargo test -p stochastic-rs-stats --lib mle:: at 39 passed / 0 failed — including all five mle::fit::tests, which I re-ran by exact name to be sure the four resolve_fit_outcome cases were not filtered out.

Revert either one freely if you disagree with the call; I have no further findings.

@jolars

jolars commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Great, no, I have no problem with the edits. Thanks for making them!

@dancixx
dancixx merged commit 93966af into rust-dd:main Sep 10, 2026
1 check failed
@jolars
jolars deleted the refactor/adopt-basin branch September 10, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider adopting Basin

2 participants