Release 4.0.0: SmileCalibrator, VolSurface::forward, spline calibration - #138
Merged
Conversation
…bration Adds two VolSurface methods, opens the builder to out-of-crate models, and decomposes SVI's calibration. Both trait additions have defaults, so external implementors are unaffected; two public inherent methods are removed. Layer 5 — forward lookups DupireLocalVol read three forwards per query by materializing whole smile sections and discarding everything but .forward(). On a PiecewiseSurface each of those sampled 51 strikes and solved a cubic spline. VolSurface::forward() returns it directly: dupire_piecewise_single_query drops 6.75 us -> 180 ns. Layer 3 — smile identity PiecewiseSurface::smile_at() resampled onto a spline even on an exact tenor match, so model_name() reported "CubicSpline" for a calibrated SVI, density became spline-derived, and the section disagreed with black_variance() in the wings. Smiles are stored behind Arc and handed back as-is on a match. Calendar checks reachable through the trait VolSurface::calendar_violations() has a grid-scanning default; SsviSurface overrides it with the exact dw/dtheta test that was previously only an inherent method. eSSVI's structural check stays as model-specific reporting. Calibration contract SmileCalibrator captures the pipeline the four models shared informally. SmileModel implements it, and SurfaceBuilder::calibrator() accepts any implementation, so a model defined elsewhere builds into a surface on the same footing as the built-ins. SVI's 350-line fit splits into named stages — weighting, vol-cliff filter, ATM interpolation, multi-start search, ATM sanity check — now unit-tested directly. The fit itself is unchanged. Also: validate_in_range/validate_open_unit_interval behind the rho, beta, and gamma checks; ArbitrageScanConfig re-exported at the crate root; the inherent tenors() shadows and the strike_grid forwarder deleted; implied/ module docs scope the Bachelier and displaced branches as standalone utilities. Removed (breaking, replacements on VolSurface): - SsviSurface::calendar_arb_analytical() -> calendar_violations() - inherent SsviSurface::tenors() / EssviSurface::tenors()
Three loose ends from the calibration refactor. eSSVI's per-tenor fit still carried the inline tenor/forward loops that SSVI now routes through validate_positive_slice, so the same failure reported two different strings depending on which surface you calibrated. Both go through the shared validator. SurfaceBuilder::build() lost its eager SABR beta check when min_strikes moved onto SmileCalibrator, pushing a bad beta behind the per-tenor expiry and strike-count checks. SmileCalibrator gains a defaulted validate() for parameters fixed at construction; SmileModel overrides it for Sabr and build() calls it before reading any tenor data. SharedSmile's two arbitrage methods had no coverage. Added a test that scans a butterfly-violating SVI through smile_at() and matches it against the model's own scan, where a spline resampling would disagree. CHANGELOG records the three message changes; the error variants are unchanged.
The bindings each carried a private copy of the beta range check with pre-refactor wording, so Python and WASM callers never saw the message the CHANGELOG documents. Both now call SmileCalibrator::validate() and surface the core error through to_py_err/to_js_err. Inside the crate, SabrSmile::new and calibrate_with_config said "beta" while the builder said "SABR beta" for the same rejection; both now use "SABR beta". Add a fit_per_tenor test for zero/NaN tenors and NaN forwards — without the validate_positive_slice calls those fall through to SviSmile and return CalibrationError instead of InvalidInput, a variant swap nothing caught. Document on build() that the calibrator's own parameters are checked before any tenor data.
to_js_err now maps InvalidInput to the bare message like to_py_err does, so both bindings surface the same string and JS callers stop seeing the "invalid input: " prefix the Display fallback added. The serde path for SmileModel::Sabr delegates to SmileCalibrator::validate() instead of its own bounds check, removing the last private copy of the beta range, and a new test asserts SabrSmile::new, calibrate_with_config, validate, and deserialization all produce identical wording.
to_js_err now matches to_py_err on all three concrete variants; NumericalError was still falling through to Display while InvalidInput did not. The WASM SABR beta test asserts the exact JS-visible string so the mapping arm is covered at the FFI boundary, and CHANGELOG records that the prefix drop applies to every message on those two variants, not just beta.
…riant tag The `NumericalError → bare message` arm added in 5f5a33c had no test — the pinned assertion in smoke.rs exercises `InvalidInput`, which was already mapped that way, so deleting the new arm left the suite green. Assert on `log_moneyness(MIN_POSITIVE, MAX)`, whose ln underflow is the reachable `NumericalError` path through the bindings. With all three concrete variants now reaching JS as plain strings, a JS caller can no longer tell an invalid-input rejection from a numerical failure. Python still separates them by exception class; note the gap in the CHANGELOG and wasm/README.md rather than leaving it implicit.
… variants
CalibrationError still reaches JS with its "{model}: {message}" prefix, so
only InvalidInput and NumericalError are indistinguishable. State that these
bindings throw plain strings rather than that JS lacks a typed alternative.
The seven validated models each restated their field list three times — in the `*Raw` shadow struct, in `TryFrom<Raw>`, and in `From<Model>`. A field added to a model but missed in the hand-written `From` would have dropped silently out of the serialized form. `validated_serde!` emits all three from one list, so that can no longer happen. `EssviSlice` keeps a hand-written `From` (it wraps `SsviSlice`, whose fields are private to `ssvi.rs`) and uses the macro's `@shadow` arm for the rest. `displaced.rs` was the only implied-vol model not routed through `validate_implied_inputs` / `validate_pricing_inputs`; it inlined the same calls in the same order. Its two `beta` range checks now use `validate_in_range`, which produces the identical message. No behavior change: error types, messages, and ordering are unchanged.
`scan_density` and `scan_g` each carried their own copy of the grid index arithmetic and the density-evaluation error wrapper. Both now walk `k_grid` and evaluate through `violation_at`, so the grid formula and the failure message exist once. `SsviSlice::vol`/`variance` and `SplineSmile::vol`/`variance` were each the same eight-line preamble twice, differing only in the return wrapper; both pairs now go through a `checked_variance` helper. `SsviSlice::density` keeps its own guard — it rejects `w == 0` as well, where the density's `1/(K·√w)` factor is undefined, and reports that with its own message. No behavior change: every error message and tolerance is preserved. Benchmarked: ssvi_slice_vol_query 12.3ns, unchanged against the <100ns target.
Both surfaces ran the same outer procedure and had drifted into three byte-identical blocks differing only in the model tag: the tenor/forward length-and-positivity check, the θ-monotonicity check, and the grid-search → Nelder-Mead → clamp → RMS pass over (η, γ). All three now live in `surface::calib`, which also owns the shared `GRID_N`. Only the scaffolding moves. The objectives stay in their own files, and the query paths are untouched — eSSVI threads a maturity-dependent ρ(θ) through every evaluation, so unifying those would put a virtual call in `black_variance`. No behavior change: messages, bounds, step sizes, and clamps are unchanged.
`PiecewiseSurface::locate_tenor` and `interp::interpolate_theta_forward` each carried their own copy of the exact-match scan, the before/after bounds checks, and the `partition_point` bracket. `TenorPosition` and a free `locate_tenor` now live in `interp`, so the two extrapolation policies are verifiably driven by the same locator. `diagnostics` and `diagnostics_with` differed only in which `is_arbitrage_free` variant each smile was asked for; both now go through `diagnostics_via`. They stay separate methods rather than delegating, since per-smile defaults differ (SABR scans [-2, 2]). No behavior change. Benchmarked: piecewise_vol_query 20.3ns, no regression.
`SplineSmile` overrode `is_arbitrage_free`, which ran a hand-rolled scan over the knot range with a hardcoded 200 points and a hardcoded `-1e-8` — a literal restatement of `DENSITY_NEG_TOL`, which it did not import — and ignored `ArbitrageScanConfig` entirely. `is_arbitrage_free_with(config)` meanwhile fell through to the trait default and scanned log-moneyness `[k_min, k_max]`. The same model answered the same question two different ways. Now it overrides `is_arbitrage_free_with`, so both entry points share one code path and one tolerance, and the configured range is honoured. The range is clipped to the knot span rather than taken as given. Deleting the override outright — the obvious consolidation — is wrong: outside `[K₀, Kₙ]` the spline flat-extrapolates, so the finite-difference density there is cancellation noise, and on a convex 5-knot smile the unclipped scan reports four spurious violations at strikes 5.6-7.4 against a knot range of 80-120. The clip also steps in from the boundary knots, where `w` is only C⁰ and a stencil straddling the kink reads ~-13. Interior knots need no inset; a natural cubic spline is C² there. A config disjoint from the knot range now errors rather than reporting a clean scan it never performed. Tests: both entry points agree, a narrow config is not widened, a disjoint config errors, a sharp variance dip is still detected, and a spline-built surface's `diagnostics()` stays clean end-to-end.
`synthetic_ssvi_data` and `synthetic_essvi_data` were character-identical apart from the surface type, retyped because each `mod tests` is private. They collapse into one `synthetic_surface_data` generic over `VolSurface` in a new `test_support` module. The uniform strike ladder that feeds it was spelled out as a four-line iterator chain at 25 sites across the two files, varying only in count, floor, and step; `strike_ladder` takes those as arguments. Kept as a ladder builder rather than folding it into the sampler because 11 of the 20 eSSVI sites go on to use `strikes` for their assertions. Test count is unchanged at 937 — this is a transcription of the same fixtures, not a re-scoping of what they cover.
…g it `SplineSmile::is_arbitrage_free_with` narrows the caller's config to its usable knot range, which the trait doc promised it would not do: a caller passing `svi_default()` to a spline built by `PiecewiseSurface::smile_at` got `is_free()` for ~23% of the requested range with nothing to say so. The clipping is right; the promise was wrong, so amend the trait doc and name `SplineSmile` as the case in point. Test the non-overlap case explicitly rather than letting it fall out of `ArbitrageScanConfig::validate` on the clipped config, which reported a `k_max` the caller never supplied and blamed the config even when a knot span narrower than `2 · KNOT_EDGE_INSET` was the real cause. The new error names both ranges; a tight-ATM-ladder test covers the second case. Also make the narrow-config test discriminating (it asserted a property the fixture had regardless), assert the three unverified `SsviSlice` fields off-ATM, and correct the doc drift left by the recent extractions: `checked_variance` carried `eval_variance`'s docs, `optimize_eta_gamma` stated its seed range as a return guarantee, and a deleted fixture left its doc comment on the following test. Add the preconditions the three extracted helpers lost with their locality.
A knot span narrower than 2·KNOT_EDGE_INSET shared the "does not overlap" message with the genuinely disjoint case, printing an inverted usable range that the scan window did in fact contain. Split the branch so the empty span names its own cause, and assert on that wording in the test that claimed to check it. Switch both_arbitrage_entry_points_agree to the dip fixture so the compared counts are nonzero, and point diagnostics_with at the per-smile narrowing caveat.
The empty-span error printed strike endpoints against a log-moneyness threshold named only by a crate-private constant, so a caller could not check the claim from the text. Print the span itself alongside the threshold as plain numbers, as the sibling disjoint-range message already does.
…error The empty-knot-span message said "knot range" like the disjoint-config message but printed strikes, not log-moneyness; it said "less than" a guard that fires on equality; and it re-derived the span from the strikes instead of the lo/hi the guard tested.
The vol-cliff filter guarded on `!has_rise || !has_drop`, but `cliff_idx` is only ever set inside the branch that sets `has_drop`, so `!has_drop` was always false where it was read and the guard reduced to `!has_rise`. Removing the disjunct lets `has_drop` go too, which folds its branch into the `cliff_idx.is_none()` test. `vol_cliff_filter_leaves_v_shaped_smiles_alone` already pins the drop-and-rise input this could have affected, and it passes unchanged. The 0.999 correlation bound and the 1e-14 flat-rho epsilon each appeared at several sites across ssvi and essvi with the reason for the value written down nowhere. Both now live in the calib module that the two surfaces already share, so the SSVI radical's degeneracy at |rho| = 1 and the Eq. 5.7 bound's division by rho_m - rho_0 are stated once. The values are unchanged, so calibration output is bit-identical. Also bind the builder's tenor iterator once per cfg instead of duplicating the whole map/collect under each arm.
rho()'s doc hardcoded (-0.999, 0.999) while the body clamps to RHO_CLAMP, so changing the constant would silently make the public doc wrong. Name the constant instead of the number; RHO_CLAMP is pub(crate), so it is plain text rather than an intra-doc link.
…urns black_price, normal_price and displaced_price took a bare f64 vol, so a Bachelier vol of 20.0 and a Black vol of 0.20 were interchangeable at the one call site where the two units actually meet. Each now takes the newtype its inverse produces, and DisplacedImpliedVol::compute returns a new DisplacedVol rather than smuggling a third unit through Vol. types.rs no longer claims newtypes cover every input — it says which two jobs they do and that model parameters stay bare f64.
…ts own
svi_default() was the trait fallback and the SSVI default as well as SVI's, so
its name described the wrong thing. It is now wide(), sabr_default() is
narrow(), and Default returns wide() — so `ArbitrageScanConfig { n_points: 500,
..Default::default() }` compiles.
is_arbitrage_free() now scans default_scan_config(), a new trait method. A
model whose approximation stops short of the wings says so once, in one place,
instead of overriding is_arbitrage_free to pass its own grid: SABR returns
narrow(), and SVI and SSVI drop overrides that only repeated the default. An
implementor outside the crate can do the same rather than silently inheriting
SVI's grid.
Every EssviSurface accessor was undocumented, against the crate's own rule, while SSVI's equivalents all carry docs. Two also read wrong: rho(theta) evaluated a function where rho() elsewhere in the crate reads a stored parameter, and a() gave no clue it is the exponent in rho(theta) = rho_0 + (rho_m - rho_0)(theta/theta_max)^a. They are now rho_at() and rho_exponent(). calendar_check_structural() becomes calendar_violations_structural(), matching the VolSurface::calendar_violations() it sits beside.
SviSmile handed back rho() and nothing else, so reading a fit's a, b, m or sigma meant a serde round-trip; SplineSmile exposed nothing at all. Both now have accessors, matching SABR and SSVI. SplineSmile also gains calibrate/calibrate_with_config taking (strike, vol) quotes like SVI and SABR do. new() still wants sorted strikes and total variances, which every caller had to produce for itself — SurfaceBuilder's CubicSpline arm now calls the new entry point instead of doing the conversion inline.
DataFilter, WeightingScheme and ArbitrageScanConfig are all Copy, and SurfaceBuilder::data_filter already took one by value, but every calibrate_with_config, is_arbitrage_free_with and diagnostics_with wanted a reference — so 51 call sites, most of them inside the crate, wrote &DataFilter::default(). They now take the configs by value.
Smile calibration reads (forward, expiry, market_vols) — coordinates, then quotes. Surface calibration read (market_data, tenors, forwards), the reverse, so the two layers could not be scanned together. SsviSurface::calibrate, EssviSurface::calibrate and fit_per_tenor (both with_config variants included) now take (tenors, forwards, market_data), and the Python and WASM bindings follow.
Docs that drifted from the reordered/renamed signatures: - `python/README.md` passed `fit_per_tenor` the old `(market_data, tenors, forwards)` order one line under prose stating the new one, so the snippet raised `TypeError` on the `Vec<f64>` extraction. - `EssviSurface::fit_per_tenor` and `::calibrate` still listed `market_data` first in their `# Arguments` blocks, contradicting the signatures beneath. - `wasm/README.md` was camelCase throughout — wasm-bindgen keeps the Rust snake_case names and `wasm/src/` sets no `js_name`, so every member in the file was `undefined` in JS. Renamed against the generated `.d.ts` rather than only the three flagged lines; leaving the rest would have made the file more self-contradictory, not less. Behaviour: - `SharedSmile` did not forward `default_scan_config`, so a SABR smile pulled off a `PiecewiseSurface` at an exact tenor fell through to the trait default `wide()` and scanned the Hagan expansion's own wing breakdown as arbitrage. This was also the one composition point silently dropping an out-of-crate implementor's override. Changelog omissions: - The `rho_exponent` rename also lands in both bindings, where it fails at runtime rather than at compile time. - Routing the builder's `CubicSpline` arm through `calibrate_with_config` validates each quote *before* the `DataFilter`, so quotes that were previously dropped and fitted around now abort the tenor. Coverage and the git-ignored crate: - The three surface-level `*_with_config` entry points had no direct test; their smile-level equivalents did. - `SplineSmile::calibrate` rejects duplicate strikes — a call and a put on one strike — from inside `new`, naming an index the caller never built. Now documented and tested. - `PySplineSmile` gained `calibrate`, so Python callers stop converting to total variance and sorting by hand. - `tools/chain-prep` no longer compiled against the new argument order and by-value configs.
The Rust addition landed without its binding, changelog note, or example, and its duplicate-strike error pointed at an index the caller cannot map back to their input. - `PySplineSmile::calibrate_with_config` takes an optional `DataFilter`, matching `SviSmile` and `SabrSmile`. A `min_vol` or `max_log_moneyness` band on a spline no longer has to be applied by hand. - `calibrate_with_config` detects a shared strike after its own sort and names the strike, rather than letting `new` report a post-sort position. `new` keeps its index message for callers who supplied an ordered vector. - The changelog entry states the binding impact, as the entries around it do; the Python README calibrates the `SplineSmile` it was importing and never using.
`test_defaults_match_calibrate` compared `vol(100.0)`, a quoted strike. A spline interpolates every surviving knot exactly, so that assertion held for any filter that kept the 100.0 quote — including one that silently dropped 60/170. Probe 150.0 instead, where the value depends on the wings surviving. Also correct the builder's stale duplicate-strike comment (the check now lives in `calibrate_with_config`, before `new` sees the knots) and document `SplineSmile.calibrate_with_config`'s filter-only signature in the Python README.
Report strike/vol length mismatches per tenor instead of packing a truncated or NaN-filled buffer and surfacing a bare "calibration failed". Plotly v2 dropped `titlefont`, so every axis title was rendering with the default font; move the config under `title.font` and merge it into the per-chart title overrides. The empty-term-structure branch handed Plotly the shared `LAYOUT_BASE`, which it mutates with computed ranges — pass a clone. Derive each mini-chart's x range from that tenor's own strikes; the fixed 85–115 window clipped the wider long-dated quotes while they still drove the y range. Fix the skew readout: the 25d span is 0.50, so vol points per delta point is `* 100 / 50`, not `/ 50 * 1000` — the old number was 10x. Label the unit. Also add an SRI hash to the Plotly CDN tag, drop the Google Fonts import in favour of the local stacks already listed, delete the unused `WasmSurfaceBuilder` import and `strikeAtDelta`, and document the demo's build-and-serve prerequisite in the README.
The tenor guard only compared list lengths, so a blank or non-numeric
field passed whenever the two lists happened to match. apply_filter then
dropped the quote for a non-finite vol or ln(K/F) and the page still read
"ready". Number('') is 0 rather than NaN, so parsing has to reject blank
fields before the finiteness test can see them.
Plotly assigns the layout it is handed to gd.layout and a GUI relayout
writes through it, so the four charts sharing LAYOUT_BASE's legend, font,
margin and shapes leaked state between each other -- dragging the density
legend moved the delta-smile legend on the next build. Clone the base per
plot instead of spreading it.
Collapse the repeated three-level title spread into an axis() helper so a
future addition to AXIS_COMMON.title survives without a manual merge at
each call site.
calibrate validates raw strike/vol pairs before any filtering, so a non-finite quote errors rather than being silently dropped.
cargo doc --all-features fails under -D warnings. SmileCalibrator::calibrate linked crate::calibration::prepare_market_vols, which is pub(crate), so an out-of-crate implementor cannot reach the helper the sentence told it to route through. State the filtering contract as behaviour instead. Drop the two redundant explicit link targets rustdoc flagged alongside it.
The calibrate contract told implementors to treat filter starvation as an error but its # Errors block only mapped CalibrationError to a failed fit, so an out-of-crate model could reasonably return InvalidInput instead and callers matching on the variant would see starvation classified per-model. In-crate models all return CalibrationError via prepare_market_vols; say so. Reflow the two paragraphs left short-wrapped by the previous commit.
Promote [Unreleased] to a dated 4.0.0. The accumulated breaking surface — seven Changed entries plus the removed SsviSurface::calendar_arb_analytical and the inherent tenors() methods — makes this a major, not a 3.1. Drop Python 3.9 alongside it. It reached end of life in October 2025 and held the test matrix on pytest 8.x, which predates the CVE-2025-71176 tmpdir fix; that fix needs 3.10+. requires-python is now >=3.10, CI tests 3.10 and 3.14, and relocking drops pytest 8.4.2 from python/uv.lock. The Rust crate is unaffected.
The release commit bumped the manifests but left both READMEs advertising the previous version: the crate install snippets said 3.0 while the examples below them use 4.0-only API, the recent-releases table had no v4.0 row, and the Python README still promised 3.9 support that requires-python and the CI matrix had just dropped.
The 4.0 README pass fixed the version pins and the release table but left the prose at 3.0: DisplacedVol was missing from the newtype bullet and the module tree, SmileCalibrator was absent everywhere except the release-table row that advertises it, and the Python section said pip install for a crate that is not on PyPI.
VolSurface::forward and SplineSmile::calibrate appeared only in the v4.0 release row; both are now named where a reader looks for them. Give the surface module tree node its trait so it reads like its siblings.
VolSurface::forward's default implementation goes through smile_at(), so the no-smile claim is a property of the built-in surfaces that override it, not of the trait method.
Both accessors read as wing measurements, and on a real corpus they often are not. Where m falls outside the quoted log-moneyness range the hyperbola degenerates to a line over the observed strikes, only the product b(1 − ρ) stays pinned, and b and ρ slide along a ray — b moving an order of magnitude while the curve shifts less than the fit's own residual. ρ's sign goes with it: a slice with a visibly steeper put wing can fit ρ > 0 because the whole ladder sits on one branch. Neither is a fitter bug, and RMSE cannot distinguish the cases, so the docs have to. b is newly public in this release; ρ was not, but reads the same way.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All post-3.0.0 work, which had accumulated locally on
mainand was blockedfrom a direct push by the pre-push hook.
Scope
33 commits. The
[Unreleased]changelog section covers them in full; theheadlines:
Added
smile::SmileCalibrator— the per-tenor calibration contract the modelsalready shared informally.
SurfaceBuilder::calibrator()accepts anyimplementation, so a model defined outside this crate builds into a surface
on the same footing as the
SmileModelvariants.VolSurface::forward(expiry)— reads the forward without building a smilesection. The
local_vol/dupire_piecewise_single_querybenchmark goes from6.75 µs to 180 ns (37×).
VolSurface::calendar_violations(), reachable through&dyn VolSurface,with
SsviSurfaceoverriding it with the exact∂w/∂θtest.SplineSmile::calibrate/calibrate_with_config, plus their Pythonbindings.
SmileSection::default_scan_config(),ArbitrageScanConfig::default(),types::DisplacedVol, and parameter accessors onSviSmile/SplineSmile.Breaking (7 in
Changed, 2 inRemoved)(tenors, forwards, market_data).DataFilter,WeightingSchemeandArbitrageScanConfigpass by value.black_price/normal_price/displaced_pricetake the vol unit theirextractor returns;
DisplacedImpliedVol::computereturnsDisplacedVol.EssviSurface::rho(theta)isrho_at(theta);calendar_check_structural()renamed.ArbitrageScanConfig::svi_default()/sabr_default()reworked.SsviSurface::calendar_arb_analytical()and the inherenttenors()onSsviSurface/EssviSurface, which shadowed the identicaltrait method. Each has a drop-in replacement on the
VolSurfacetrait.Verification
cargo test— 1065 tests pass across the seven suites, doctests included.cargo clippy --all-targets -- -D warnings— clean.cargo doc --all-features --no-depsunder-D warnings— clean.cargo fmt --check— clean.numeraire(path dependency on this crate) still builds.CI covers the binding test suites: the WASM job runs
wasm-pack test --node wasm/, and the Python jobs runuv run pytest tests/ -qon 3.9 and 3.14.Release
Version bumped to
4.0.0across the root,python/, andwasm/manifests,with
[Unreleased]promoted to a dated[4.0.0]header. The breaking surfaceabove makes this a major, not a 3.1.
Python 3.9 is dropped in the same commit: it reached end of life in October
2025 and held the test matrix on pytest 8.x, which predates the
CVE-2025-71176 tmpdir fix (that fix needs 3.10+).
requires-pythonis now>=3.10, CI tests 3.10 and 3.14, and relocking removes pytest 8.4.2 frompython/uv.lock, closing the last open Dependabot alert. The Rust crate isunaffected.
Remaining after merge: tag
v4.0.0and publish.