regit-svi 2.0.0

Arbitrage-free SVI volatility surfaces in pure Rust. Raw, Jump-Wings and SSVI parametrisations, calibration, and static-arbitrage checks. Zero dependencies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
<!-- Copyright 2026 Regit.io — Nicolas Koenig -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Migrating from 1.x to 2.x

regit-svi 2.0 is a deliberate breaking release. The mathematical families
remain Raw SVI, Jump-Wings, and SSVI, but the API now prevents invalid public
state and makes the strength of every arbitrage conclusion inspectable.

This guide uses 2.0.0 names. Read [MATH.md](MATH.md) when migrating code
that treats an arbitrage flag as a control or risk decision.

## Migration checklist

1. Update imports to the domain modules or use the retained top-level
   re-exports.
2. Replace field reads with accessors and remove every unchecked constructor.
3. Replace boolean arbitrage logic with status and evidence matching.
4. Update scan code for the fixed bounded diagnostic API.
5. Update calibration result access and inspect termination/report metadata.
6. Choose Constrained or BestEffort explicitly when the policy matters.
7. Rename the modified-power smoothing constructor.
8. Preserve maturity order before constructing term structures or surfaces.
9. Revisit Raw/JW inverse error handling for newly explicit degeneracies.
10. Revalidate density mass windows and tolerances; no integration error is
    inferred by the API.

## 1. Module map

| 1.x path | 2.x path |
| --- | --- |
| regit_svi::raw::RawSvi | regit_svi::smile::raw::RawSvi |
| regit_svi::jw::SviJw | regit_svi::smile::jump_wings::SviJw |
| regit_svi::convert::* | regit_svi::smile::conversion::* |
| regit_svi::ssvi::* | regit_svi::surface::ssvi::* |
| regit_svi::surface::Surface | regit_svi::surface::interpolation::Surface |
| regit_svi::arbitrage::* | regit_svi::no_arb::{butterfly,calendar,evidence}::* |
| regit_svi::types::* | regit_svi::market::{quote,units,conventions}::* |
| regit_svi::errors::* | regit_svi::error::* |
| regit_svi::calibration::ssvi::* | regit_svi::calibration::surface::* |
| regit_svi::math::* | private implementation; no public replacement |

Frequently used data, model, configuration, and report types remain available
at the crate root: RawSvi, SviJw, Phi, Ssvi, Surface, TermStructure, Quote,
surface-calibration inputs and results, market units, report/config types,
errors, and arbitrage evidence types. Pure coordinate and parametrisation
conversions are also re-exported; calibration, assessment, scan, density, and
other algorithmic entry points remain under their domain modules. Prefer
domain paths in reusable libraries when the source of an item is useful to
readers.

Before:

~~~rust,compile_fail
use regit_svi::{
    arbitrage,
    raw::RawSvi,
    types::{Quote, log_moneyness},
};
~~~

After:

~~~rust
use regit_svi::{
    Quote, RawSvi,
    market::conventions::log_moneyness,
    no_arb::butterfly,
};
~~~

There is no 2.x public access to Brent, Cholesky, Nelder–Mead, LM, or integer
conversion helpers. Those are implementation details, not stable numerical
utility APIs.

## 2. Validated, immutable model values

### 2.1 Raw SVI

RawSvi fields are private. Construction validates finite values, b>=0,
|rho|<1, sigma>0, and non-negative global minimum variance.

Before:

~~~rust,compile_fail
use regit_svi::raw::RawSvi;

let raw = RawSvi {
    a: 0.04,
    b: 0.30,
    rho: -0.20,
    m: 0.0,
    sigma: 0.10,
};
let slope = raw.b;
~~~

After:

~~~rust
use regit_svi::smile::raw::RawSvi;

let raw = RawSvi::new(0.04, 0.30, -0.20, 0.0, 0.10)?;
let slope = raw.b();
let (a, b, rho, m, sigma) = raw.parameters();
# let _ = (slope, a, b, rho, m, sigma);
# Ok::<(), regit_svi::ParamError>(())
~~~

Named accessors are a, b, rho, m, sigma, parameters, total_variance,
w_prime, w_double_prime, implied_vol, k_min, w_min, atm_total_variance,
atm_skew, atm_curvature, left_wing_slope, and right_wing_slope.

RawSvi::new_unchecked is no longer public. If an input is trusted, validation
is still required at the API boundary; propagate ParamError or validate it
once when loading the data.

### 2.2 Jump-Wings

SviJw follows the same pattern. Use SviJw::new and the accessors
atm_variance, atm_skew, put_wing, call_wing, min_variance, maturity,
atm_total_variance, min_total_variance, and atm_vol. Its fields and unchecked
constructor are no longer public.

### 2.3 SSVI

Phi is now an opaque validated value rather than a publicly constructible enum.

Before:

~~~rust,compile_fail
use regit_svi::ssvi::{Phi, Ssvi};

let phi = Phi::PowerLaw { eta: 0.5, gamma: 0.5 };
let ssvi = Ssvi { rho: -0.3, phi };
~~~

After:

~~~rust
use regit_svi::surface::ssvi::{Phi, Ssvi};

let phi = Phi::modified_power_law(0.5, 0.5)?;
let ssvi = Ssvi::new(-0.3, phi)?;
# let _ = ssvi;
# Ok::<(), regit_svi::ParamError>(())
~~~

The family name changed from power_law to modified_power_law because the
implemented formula is the modified Gatheral–Jacquier family:

~~~text
eta/[theta^gamma(1+theta)^(1-gamma)]
~~~

not a bare eta/theta^gamma power law. Use heston(lambda) for the Heston-like
family. Family parameter access is through heston_lambda or
modified_power_law_parameters.

## 3. Validated market inputs

Quote::new still accepts raw f64 coordinates and remains the convenient
calibration input:

~~~rust
use regit_svi::Quote;

let quote = Quote::new(-0.10, 0.04, 1.0)?;
# let _ = quote;
# Ok::<(), regit_svi::ParamError>(())
~~~

The new unit types allow validation earlier in an ingestion pipeline:

~~~rust
use regit_svi::market::{LogMoneyness, Maturity, TotalVariance};

let k = LogMoneyness::new(-0.10)?;
let t = Maturity::new(1.0)?;
let w = TotalVariance::new(0.04)?;
# let _ = (k, t, w);
# Ok::<(), regit_svi::ParamError>(())
~~~

`log_moneyness` and `total_variance_from_vol` are intentionally infallible
raw-`f64` formula helpers; they do not validate. The former requires finite,
strictly positive strike and forward, while the latter requires finite
non-negative volatility and finite non-negative maturity. Violating those
preconditions follows ordinary IEEE-754 propagation. There are no `Strike` or `Forward`
newtypes in 2.x: validate the result with `LogMoneyness`, and use `Maturity`
and `TotalVariance` for the corresponding model inputs. `SliceQuotes`
validates a non-empty quote collection. `quotes_from_triples` remains
available.

Quote fields are private in 2.x. Use log_moneyness(), total_variance(), and
weight(). The implied_vol(t) helper still returns a typed error for invalid
maturity.

## 4. Arbitrage results: status plus evidence

### 4.1 Replace boolean control flow

The most important migration is conceptual. A boolean could not distinguish:

- an exact necessary-and-sufficient theorem;
- a sufficient condition whose failure proves nothing;
- a finite grid scan;
- a theorem-guided floating search.

Before:

~~~rust,compile_fail
let ok = regit_svi::arbitrage::is_butterfly_free(&raw, -1.0, 1.0);
if !ok {
    reject_surface();
}
# fn reject_surface() {}
~~~

After:

~~~rust
use regit_svi::{ArbitrageEvidence, ArbitrageStatus};
use regit_svi::no_arb::butterfly::assess_raw;
# use regit_svi::RawSvi;
# let raw = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).expect("valid fixture");

let assessment = assess_raw(&raw, 1e-10);
match (assessment.status(), assessment.evidence()) {
    (ArbitrageStatus::ViolationDetected, _) => {
        // A failed necessary condition or a concrete witness.
    }
    (
        ArbitrageStatus::NoViolationDetected,
        ArbitrageEvidence::NumericalSearch(search),
    ) if search.terminated() => {
        // Apply the application's policy to the recorded uncertainty.
    }
    _ => {
        // Indeterminate or weaker evidence: escalate, widen, or reject.
    }
}
~~~

Do not translate NoViolationDetected mechanically to “proved arbitrage-free.”
Always retain or inspect the evidence variant and its declared support.

### 4.2 Raw assessment

Use the no_arb::butterfly::assess_raw function with a validated Raw slice and
an explicit boundary tolerance.

The result includes status, evidence, signed margin, and optional witness.
Regular Raw success is NumericalSearch evidence. Analytic classifications are
used for the positive-flat case, the isolated-zero violation, and failed
necessary conditions.

The 2.x tail contract is more precise than the old symmetric-looking helper:

~~~text
b(1+rho) < 2     right call boundary, strict
b(1-rho) <= 2    left density-factor bound
~~~

Unit continuous positive-strike mass needs the separate strict left condition.

### 4.3 Bounded scans

butterfly_scan and calendar_scan now take only the model(s) and two requested
ordered f64 bounds. They reject k_lo>=k_hi and evaluate 401 points over the
requested interval expanded by one log-moneyness unit on each side.

~~~rust
use regit_svi::no_arb::butterfly::butterfly_scan;
# use regit_svi::RawSvi;
# let raw = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).expect("valid fixture");

let scan = butterfly_scan(&raw, -0.5, 0.5)?;
if scan.violation_observed() {
    eprintln!(
        "sampled g minimum {} at {}",
        scan.min_g(),
        scan.worst_k(),
    );
}
let effective = scan.evidence().config();
# let _ = effective;
# Ok::<(), regit_svi::DiagnosticError>(())
~~~

The diagnostics expose minimum/worst location, a violation flag, an optional
nearby refined crossing/boundary, and `ScanEvidence`. Its accessors preserve
the caller's `requested_domain`, the pre-expansion `requested_points`, the
expanded effective `config`, `samples_evaluated`, whether
`refinement_attempted`, and optional `RootEvidence` with final bracket,
returned root, residual at that root, evaluation count, and `RootTermination`.
Aggregated surface evidence additionally reports `scan_count`,
`total_samples_evaluated`, and `selected_scan`, whose per-scan evidence is
retained. Selection prioritizes violation over unresolved boundary over clean,
then the smallest `min_difference/tolerance` within that class; the aggregate
margin and witness come from the same selected adjacent pair. The diagnostics
do not expose all violation intervals or prove anything outside the effective
domain.
`ScanEvidence::config().tolerance()` records a scale-aware evaluation band. A
violation flag requires the minimum to lie below the negative tolerance; a
minimum inside the band must not be treated as a clean mathematical pass.
Slice-backed `Surface::calendar_assessment` returns Indeterminate for such an
unresolved adjacent minimum.

Old ButterflyReport and CalendarReport names are replaced by
ButterflyDiagnostic and CalendarDiagnostic.

### 4.4 SSVI assessments

Replace is_butterfly_free_at and is_butterfly_free with:

- butterfly_assessment_at(theta);
- butterfly_assessment(&thetas);
- global_butterfly_assessment().

Replace is_calendar_free_at/is_calendar_free with calendar_assessment on the
declared theta support. The butterfly theorem is sufficient: failure returns
Indeterminate rather than a claimed violation. Calendar Theorem 4.1 evidence
is necessary-and-sufficient on valid declared support. Empty/invalid support is
Indeterminate. A negative adjacent theta difference smaller in magnitude than
the 10^-12 boundary tolerance is also Indeterminate; a decrease beyond that
band is ViolationDetected.

## 5. Calibration

### 5.1 Result access

CalibrationResult fields are private.

Before:

~~~rust,compile_fail
let fit = regit_svi::calibration::quasi_explicit::calibrate(&quotes)?;
let raw = fit.slice;
let rmse = fit.rmse;
let accepted = fit.butterfly_free;
~~~

After:

~~~rust
use regit_svi::calibration::quasi_explicit;
# use regit_svi::Quote;
# let quotes = [
#     Quote::new(-0.20, 0.0512, 1.0)?,
#     Quote::new(-0.10, 0.0432, 1.0)?,
#     Quote::new(0.00, 0.0400, 1.0)?,
#     Quote::new(0.10, 0.0420, 1.0)?,
#     Quote::new(0.20, 0.0480, 1.0)?,
# ];

let fit = quasi_explicit::calibrate(&quotes)?;
let raw = fit.slice();
let rmse = fit.rmse();
let assessment = fit.arbitrage_assessment();
let report = fit.report();
# let _ = (raw, rmse, assessment, report);
# Ok::<(), regit_svi::CalibrationError>(())
~~~

CalibrationReport carries algorithm, starts, selected_start, tolerance,
condition_estimate, parameterization, recomputed Raw margins, iterations,
evaluations, termination, ResidualDiagnostics, and the independent Raw
assessment. ResidualDiagnostics includes weighted objective, RMSE, maximum
absolute residual, effective quote count, distinct strike count, omitted
quotes, and total weight. condition_estimate is populated for a selected
quasi-explicit normalized design and is None when that diagnostic does not
apply.

An optimizer iteration limit remains an iteration limit. A low RMSE does not
override feasibility evidence.

### 5.2 Default pipeline and explicit configuration

calibrate_slice remains the default quasi-explicit-plus-LM pipeline. Use
calibrate_slice_with_config when numerical and feasibility policy must be
stable and reviewable:

~~~rust
use regit_svi::{
    ConstraintMode, InitializationPolicy, SliceCalibrationConfig,
    calibration::calibrate_slice_with_config,
};
# use regit_svi::Quote;
# let quotes = [
#     Quote::new(-0.20, 0.0512, 1.0)?,
#     Quote::new(-0.10, 0.0432, 1.0)?,
#     Quote::new(0.00, 0.0400, 1.0)?,
#     Quote::new(0.10, 0.0420, 1.0)?,
#     Quote::new(0.20, 0.0480, 1.0)?,
# ];

let config = SliceCalibrationConfig::new(
    2_000,                                  // outer iterations per start
    500,                                    // LM polish iterations
    1e-12,                                  // optimizer tolerance
    1e-10,                                  // arbitrage boundary tolerance
    1e-12,                                  // distinct-strike tolerance
    ConstraintMode::Constrained,
    InitializationPolicy::DeterministicMultiStart,
)?;
let fit = calibrate_slice_with_config(&quotes, config)?;
# let _ = fit;
# Ok::<(), regit_svi::CalibrationError>(())
~~~

Constrained requires the configured optimizer/feasibility gates. BestEffort
can return a finite candidate with non-success termination or inconclusive
assessment, fully recorded in the report.

The built-in start grids are deterministic and data-scaled. selected_start in
the report identifies the zero-based attempted start that produced the
returned model; it is None for a fallback with no optimizer start.

### 5.3 Effective quote validation

2.x requires at least five positive-weight quotes at sufficiently distinct
strikes for a five-parameter Raw fit. An input vector can have five elements
and still fail with AllWeightsZero or InsufficientEffectiveQuotes. The latter
reports usable, distinct, and required counts. Zero-weight quotes are counted
as omitted in diagnostics. The older TooFewQuotes variant was removed; 2.x
calibrators use the more informative effective-count error.

### 5.4 Quasi-explicit recovery

The inner affine problem remains the de Marco–Martini/Zeliade compact
polytope, and the outer (m,log sigma) problem remains local multi-start
Nelder–Mead. The closed inner polytope can yield |d|=c while Raw requires
|rho|<1. The calibrator does not clip that boundary ratio into a different
model. It discards a non-representable candidate and compares recomputed
objectives from successfully constructed Raw values. BestEffort has an
explicit valid flat fallback at the weighted mean quote variance. Do not
compare an old inner objective to the new report expecting identity near a
rejected boundary or fallback selection.

### 5.5 Direct refinement

least_squares::refine and refine_with_config accept a validated Raw seed.
least_squares::calibrate remains available, but the high-level default pipeline
is calibration::calibrate_slice. Direct LM now preserves singular,
stagnation, non-finite, step, objective, and iteration-limit termination.

## 6. SSVI surface calibration

The module moved from calibration::ssvi to calibration::surface.

SsviMaturity is constructed with a positive maturity, positive theta, and
quotes. SurfaceCalibrationConfig controls iteration limit, tolerance,
constraint mode, and initialization policy.

Heston fitting requires at least two positive-weight observations at distinct
strikes. Power-law fitting requires at least three and at least two distinct
theta levels; otherwise it returns InsufficientEffectiveQuotes or
InsufficientThetaLevels, respectively.

~~~rust,no_run
use regit_svi::{
    ConstraintMode, InitializationPolicy, Quote, SurfaceCalibrationConfig,
};
use regit_svi::calibration::surface::{
    PhiFamily, SsviMaturity, calibrate_with_config,
};
# let quotes_short: Vec<Quote> = Vec::new();
# let quotes_long: Vec<Quote> = Vec::new();

let maturities = vec![
    SsviMaturity::new(0.5, 0.02, quotes_short)?,
    SsviMaturity::new(1.0, 0.04, quotes_long)?,
];
let config = SurfaceCalibrationConfig::new(
    3_000,
    1e-12,
    ConstraintMode::Constrained,
    InitializationPolicy::DeterministicMultiStart,
)?;
let fit = calibrate_with_config(&maturities, PhiFamily::Heston, config)?;
let report = fit.report();
# let _ = report;
# Ok::<(), Box<dyn std::error::Error>>(())
~~~

In 1.x, the no-arbitrage inequalities were described as penalty-enforced.
2.x maps candidates into the supported family's global sufficient butterfly
envelope and independently reports butterfly and calendar assessments. This
is still a local least-squares optimization, not a globally optimal fit.
SurfaceCalibrationReport additionally exposes selected_start,
parameterization, and recomputed correlation, family-scale, family-shape,
butterfly, and calendar margins.

## 7. Surface construction and calendar semantics

Surface::from_slices no longer sorts input. Supply strictly increasing,
positive finite maturities. It also requires non-decreasing ATM total
variance.

~~~rust
use regit_svi::Surface;
# use regit_svi::RawSvi;
# let short_slice = RawSvi::new(0.02, 0.1, -0.2, 0.0, 0.3)?;
# let long_slice = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3)?;

let surface = Surface::from_slices(vec![
    (0.5, short_slice),
    (1.0, long_slice),
])?;
# let _ = surface;
# Ok::<(), regit_svi::ParamError>(())
~~~

This constructor check is not a global Raw calendar proof: two slices can be
ordered at k=0 and cross elsewhere. Use calendar_assessment(k_lo,k_hi) and
inspect both status and evidence:

~~~rust
use regit_svi::{ArbitrageEvidence, ArbitrageStatus};
# use regit_svi::{RawSvi, Surface};
# let short = RawSvi::new(0.02, 0.1, -0.2, 0.0, 0.3)?;
# let long = RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3)?;
# let surface = Surface::from_slices(vec![(0.5, short), (1.0, long)])?;

let assessment = surface.calendar_assessment(-0.5, 0.5)?;
match (assessment.status(), assessment.evidence()) {
    (ArbitrageStatus::ViolationDetected, _) => {
        // At least one adjacent pair has a concrete bounded witness.
    }
    (
        ArbitrageStatus::NoViolationDetected,
        ArbitrageEvidence::NumericalScan(_),
    ) => {
        // Raw-backed surface: clean only on the recorded aggregate domain.
    }
    (ArbitrageStatus::NoViolationDetected, _) => {
        // SSVI-backed surface: inspect the analytic theorem evidence.
    }
    _ => {
        // Indeterminate.
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())
~~~

For Raw backing, the aggregate assessment preserves bounded numerical-scan
evidence, effective domain, and sample count across adjacent pairs. For SSVI
backing it carries the analytic Theorem 4.1 evidence.

Surface::from_ssvi accepts a value convertible to TermStructure. That type
also preserves order and rejects duplicate/decreasing knots; it never silently
sorts or repairs.

Inside the knot range, slice-backed surfaces interpolate linearly in total
variance at fixed k. Outside the range, total variance is scaled to keep
implied volatility flat. SSVI-backed surfaces interpolate theta before
evaluating the SSVI formula. Surface::total_variance now returns
Result<f64,ParamError> and rejects non-finite k and non-finite/non-positive
maturity; propagate the result instead of assuming every f64 coordinate is
valid.

## 8. Raw and Jump-Wings conversion

Function paths moved but top-level re-exports remain:

~~~rust
use regit_svi::{jw_to_raw, raw_to_jw};
# use regit_svi::RawSvi;
# let raw = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1)?;

let jw = raw_to_jw(&raw, 1.0)?;
let back = jw_to_raw(&jw)?;
# let _ = back;
# Ok::<(), regit_svi::ConvertError>(())
~~~

The forward map requires positive maturity and positive ATM total variance.
The inverse now rejects non-identifiable tuples rather than fabricating a Raw
scale:

- flat wings;
- zero put or call wing;
- minimum variance equal to ATM variance;
- |beta|>=1;
- a near-zero stable scale A.

Code that assumed every constructed SviJw value had a Raw preimage must handle
ConvertError::DegenerateJw and ConvertError::JwHasNoRawPreimage. beta=0 means
m=0; minimum-at-ATM is a different degeneracy.

## 9. Density

The public density module remains, but its mathematical naming is now explicit:
risk_neutral_density returns density with respect to log-strike k,

~~~text
q_k(k)=g(k) exp(-d_-^2/2)/sqrt(2 pi w(k)).
~~~

Strike density is q_k/K. The bounded integral integrates q_k over k.

~~~rust
use regit_svi::density::{density_report, integral};
# use regit_svi::RawSvi;
# let raw = RawSvi::new(0.04, 0.05, -0.1, 0.0, 0.4).expect("valid fixture");

let mass = integral(&raw, -8.0, 8.0, 4_000)?;
let report = density_report(&raw, -8.0, 8.0, 4_000)?;
if report.violation_observed() {
    assert!(report.min_density() < 0.0);
}
let audit = (
    report.domain(),
    report.panels(),
    report.integration_error(),
);
# let _ = (mass, audit);
# Ok::<(), regit_svi::DensityError>(())
~~~

For integral, the final integer n gives 2n Simpson panels and no embedded error
estimate. density_report computes nested 2n/4n-panel estimates, returns the
4n-panel value, samples its minimum on that fine grid, and reports
abs(I_4n-I_2n)/15 as integration_error. That is quadrature-only error on the
declared domain, not tail truncation error. If a downstream gate treated
abs(mass-1) as fully explained numerical error, add explicit window expansion
tests.

The density-report violation flag uses negativity of the sampled g factor
beyond its scale-aware evaluation band. A tiny negative `min_density` whose g
factor remains inside that band is unresolved and need not set the flag.

DensityError now distinguishes NonPositiveVariance for exact zero/negative
variance from IllConditionedVariance for a tiny positive value that is unsafe
to divide by reliably. Do not collapse the latter into a mathematical
zero-variance claim.

## 10. Error handling

Error modules moved from errors to error, while ParamError, ConvertError, and
CalibrationError remain top-level re-exports. New or more frequently observed
variants include:

- ParamError::NotStrictlyIncreasing and DecreasingAtmVariance;
- ConvertError::DegenerateJw and NonPositiveAtmVariance;
- CalibrationError::InvalidConfig, InsufficientEffectiveQuotes, and
  InsufficientThetaLevels;
- DiagnosticError for invalid/non-finite bounded diagnostics;
- DensityError for density-specific point and integration domains.

Avoid exhaustive matching without a deliberate upgrade review. Preserve the
source chain for conversion/calibration errors that wrap ParamError.

## 11. Toolchain and dependency contract

The 2.x shipped library requires Rust 1.85 and edition 2024. It has zero normal
and build dependencies and remains safe std-only Rust.

All-target development checks require a current stable compiler because
Criterion 0.8 requires Rust 1.86 or newer. CI therefore checks:

- the library alone at 1.85;
- tests, examples, and benchmarks on stable;
- a release wasm32-unknown-unknown library build;
- the normal/build and development dependency graphs separately.

The Python oracle under tools/oracle is external verification tooling, not a
Cargo, build, runtime, test-harness, or benchmark dependency.

## 12. Recommended rollout

For a risk-sensitive application:

1. Compile after import/accessor changes without weakening error handling.
2. Snapshot 1.x market inputs and 2.x reports side by side.
3. Decide which evidence variants the application accepts for each workflow.
4. Fix bounded scan and density domains in configuration and regression data.
5. Record report termination, tolerances, and effective quote counts.
6. Exercise strict wing equality, isolated-zero, JW degeneracy, unordered
   maturity, zero-weight, and duplicate-strike cases.
7. Compare selected outputs with the repository oracle or an independent
   high-precision implementation.
8. Deploy 2.0.0 behind normal model-validation controls before broad
   production adoption.