Skip to content

Commit 6ce2c8e

Browse files
Defensive validation, numerical robustness, golub_welsch Result API (v0.2.0) (#13)
* Defensive input validation, numerical robustness, and golub_welsch Result API (v0.2.0) Address 12 deferred codebase review items across 5 phases: - Reject ±Inf (not just NaN) in tanh-sinh, oscillatory, Cauchy PV, cubature - Replace partial_cmp().unwrap() with unwrap_or(Ordering::Equal) - Promote debug_assert to assert in CubatureRule::new - Split Lobatto n<2 error into ZeroOrder (n=0) vs InvalidInput (n=1) - Clamp global_error to non-negative in adaptive cubature - Add dimension cap (d≤30) for adaptive cubature - Clamp Newton iterates in Lobatto interior node computation - Fix tanh-sinh non-convergence error: use last two estimates instead of fabricated tol*10 - Fix ln_gamma reflection: .sin().ln() → .sin().abs().ln() - Document QUADPACK error simplification in gauss_kronrod - Make golub_welsch return Result, propagate through all compute_* functions - Bump version to 0.2.0, update CHANGELOG, SECURITY, README * Run cargo fmt
1 parent ae30bd2 commit 6ce2c8e

19 files changed

+364
-90
lines changed

CHANGELOG.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,30 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.1.0] - Unreleased
8+
## [0.2.0] - 2026-03-13
9+
10+
### Changed
11+
12+
- **Breaking:** `golub_welsch()` now returns `Result`, propagating QL non-convergence as `QuadratureError::InvalidInput` instead of silently returning inaccurate nodes/weights. All internal `compute_*` functions in `gauss_jacobi`, `gauss_laguerre`, `gauss_hermite`, and `gauss_radau` propagate accordingly. Public constructors already returned `Result`, so most callers are unaffected.
13+
- **Breaking:** `GaussLobatto::new(0)` now returns `QuadratureError::ZeroOrder` (previously `InvalidInput`). `GaussLobatto::new(1)` still returns `InvalidInput`.
14+
- Input validation for `tanh_sinh`, `oscillatory`, `cauchy_pv`, and `cubature::adaptive` now rejects `±Inf` bounds (not just `NaN`). Error variant is unchanged (`DegenerateInterval`).
15+
- `CubatureRule::new` assertion promoted from `debug_assert_eq!` to `assert_eq!`.
16+
- Tanh-sinh non-convergence error estimate now uses the difference between the last two level estimates instead of a fabricated `tol * 10` value.
17+
- QUADPACK error estimation heuristic in `gauss_kronrod` documented as an intentional simplification of the full formula.
18+
19+
### Fixed
20+
21+
- `ln_gamma` reflection formula: `.sin().ln()``.sin().abs().ln()` prevents `NaN` for negative arguments where `sin(π·x)` is negative (affects Gauss-Jacobi with certain α, β near negative integers).
22+
- Newton iteration in Gauss-Lobatto interior node computation now clamps iterates to `(-1+ε, 1-ε)`, preventing division by zero in the `P''` formula when an iterate lands on ±1.
23+
- `partial_cmp().unwrap()` in `golub_welsch` and `gauss_lobatto` sort replaced with `.unwrap_or(Ordering::Equal)` to avoid panics on NaN nodes.
24+
- Adaptive cubature `global_error` subtraction clamped to `max(0.0)` to prevent negative error estimates from floating-point cancellation.
25+
26+
### Added
27+
28+
- Dimension cap (d ≤ 30) for adaptive cubature — returns `InvalidInput` for higher dimensions where Genz-Malik's `2^d` vertex evaluations would be impractical.
29+
- New tests: infinity rejection (tanh-sinh, oscillatory, Cauchy PV, cubature), dimension cap, Lobatto error variant splitting, tanh-sinh non-fabricated error, Jacobi negative α/β exercising the reflection path.
30+
31+
## [0.1.0] - 2026-03-12
932

1033
Initial release of bilby.
1134

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bilby"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
rust-version = "1.93"
66
description = "A high-performance numerical quadrature (integration) library for Rust"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Add to `Cargo.toml`:
2222

2323
```toml
2424
[dependencies]
25-
bilby = "0.1"
25+
bilby = "0.2"
2626
```
2727

2828
```rust
@@ -49,7 +49,7 @@ bilby works in `no_std` environments (with `alloc`). Disable default features:
4949

5050
```toml
5151
[dependencies]
52-
bilby = { version = "0.1", default-features = false }
52+
bilby = { version = "0.2", default-features = false }
5353
```
5454

5555
### Parallelism
@@ -58,7 +58,7 @@ Enable the `parallel` feature for parallel variants of integration methods:
5858

5959
```toml
6060
[dependencies]
61-
bilby = { version = "0.1", features = ["parallel"] }
61+
bilby = { version = "0.2", features = ["parallel"] }
6262
```
6363

6464
This provides `integrate_composite_par`, `integrate_par`, `integrate_box_par`,

SECURITY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
| Version | Supported |
66
|---------|-----------|
7-
| 0.1.x | Yes |
7+
| 0.2.x | Yes |
8+
| < 0.2 | No |
89

910
Only the latest minor release receives security updates.
1011

src/adaptive.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ impl AdaptiveIntegrator {
180180
where
181181
G: Fn(f64) -> f64,
182182
{
183+
// Validate interval bounds
184+
for &(a, b) in intervals {
185+
if a.is_nan() || b.is_nan() || a.is_infinite() || b.is_infinite() {
186+
return Err(QuadratureError::DegenerateInterval);
187+
}
188+
}
189+
183190
// Validate tolerances
184191
if self.abs_tol <= 0.0 && self.rel_tol <= 0.0 {
185192
return Err(QuadratureError::InvalidInput(
@@ -589,4 +596,33 @@ mod tests {
589596
reverse.value
590597
);
591598
}
599+
600+
/// NaN bounds are rejected.
601+
#[test]
602+
fn nan_bounds_rejected() {
603+
assert!(adaptive_integrate(f64::sin, f64::NAN, 1.0, 1e-10).is_err());
604+
assert!(adaptive_integrate(f64::sin, 0.0, f64::NAN, 1e-10).is_err());
605+
}
606+
607+
/// Infinite bounds are rejected.
608+
#[test]
609+
fn infinite_bounds_rejected() {
610+
assert!(adaptive_integrate(f64::sin, f64::INFINITY, 1.0, 1e-10).is_err());
611+
assert!(adaptive_integrate(f64::sin, 0.0, f64::NEG_INFINITY, 1e-10).is_err());
612+
}
613+
614+
/// NaN break point is rejected.
615+
#[test]
616+
fn nan_break_point_rejected() {
617+
assert!(adaptive_integrate_with_breaks(f64::sin, 0.0, 1.0, &[f64::NAN], 1e-10).is_err());
618+
}
619+
620+
/// max_evals smaller than one GK panel is rejected.
621+
#[test]
622+
fn max_evals_too_small() {
623+
let r = AdaptiveIntegrator::default()
624+
.with_max_evals(1)
625+
.integrate(0.0, 1.0, f64::sin);
626+
assert!(r.is_err());
627+
}
592628
}

src/cauchy_pv.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ impl CauchyPV {
7474
///
7575
/// # Errors
7676
///
77-
/// Returns [`QuadratureError::DegenerateInterval`] if any bound or `c` is NaN.
77+
/// Returns [`QuadratureError::DegenerateInterval`] if any bound or `c` is non-finite.
7878
/// Returns [`QuadratureError::InvalidInput`] if `c` is not strictly inside
7979
/// `(a, b)` or if `f(c)` is not finite.
8080
#[allow(clippy::many_single_char_names)] // a, b, c, f, g are conventional in quadrature
@@ -89,7 +89,7 @@ impl CauchyPV {
8989
G: Fn(f64) -> f64,
9090
{
9191
// Validate inputs
92-
if a.is_nan() || b.is_nan() || c.is_nan() {
92+
if !a.is_finite() || !b.is_finite() || !c.is_finite() {
9393
return Err(QuadratureError::DegenerateInterval);
9494
}
9595
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
@@ -112,7 +112,7 @@ impl CauchyPV {
112112

113113
// The subtracted integrand: g(x) = [f(x) - f(c)] / (x - c)
114114
// This has a removable singularity at x = c.
115-
let guard = 1e-15 * (b - a);
115+
let guard = 1e-15 * (b - a).abs();
116116
let g = |x: f64| -> f64 {
117117
if (x - c).abs() < guard {
118118
0.0
@@ -152,7 +152,7 @@ impl CauchyPV {
152152
///
153153
/// # Errors
154154
///
155-
/// Returns [`QuadratureError::DegenerateInterval`] if any bound or `c` is NaN.
155+
/// Returns [`QuadratureError::DegenerateInterval`] if any bound or `c` is non-finite.
156156
/// Returns [`QuadratureError::InvalidInput`] if `c` is not strictly inside
157157
/// `(a, b)` or if `f(c)` is not finite.
158158
pub fn pv_integrate<G>(
@@ -236,9 +236,28 @@ mod tests {
236236
assert!(pv_integrate(|x| x, 0.0, 1.0, 1.0, 1e-10).is_err());
237237
}
238238

239+
#[test]
240+
fn reversed_bounds() {
241+
// PV ∫₁⁰ x²/(x - 0.3) dx = -[∫₀¹ x²/(x - 0.3) dx]
242+
let exact = -(0.8 + 0.09 * (7.0_f64 / 3.0).ln());
243+
let result = pv_integrate(|x| x * x, 1.0, 0.0, 0.3, 1e-10).unwrap();
244+
assert!(
245+
(result.value - exact).abs() < 1e-7,
246+
"value={}, expected={exact}",
247+
result.value
248+
);
249+
}
250+
239251
#[test]
240252
fn nan_inputs() {
241253
assert!(pv_integrate(|x| x, f64::NAN, 1.0, 0.5, 1e-10).is_err());
242254
assert!(pv_integrate(|x| x, 0.0, 1.0, f64::NAN, 1e-10).is_err());
243255
}
256+
257+
#[test]
258+
fn inf_inputs_rejected() {
259+
assert!(pv_integrate(|x| x, f64::INFINITY, 1.0, 0.5, 1e-10).is_err());
260+
assert!(pv_integrate(|x| x, 0.0, f64::NEG_INFINITY, 0.5, 1e-10).is_err());
261+
assert!(pv_integrate(|x| x, 0.0, 1.0, f64::INFINITY, 1e-10).is_err());
262+
}
244263
}

src/cubature/adaptive.rs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ impl AdaptiveCubature {
7979
///
8080
/// Returns [`QuadratureError::InvalidInput`] if `lower` and `upper` have
8181
/// different lengths or are empty. Returns [`QuadratureError::DegenerateInterval`]
82-
/// if any bound is NaN.
82+
/// if any bound is non-finite.
8383
pub fn integrate<G>(
8484
&self,
8585
lower: &[f64],
@@ -95,20 +95,24 @@ impl AdaptiveCubature {
9595
"lower and upper must have equal nonzero length",
9696
));
9797
}
98+
if d > 30 {
99+
return Err(QuadratureError::InvalidInput(
100+
"adaptive cubature is limited to at most 30 dimensions",
101+
));
102+
}
98103
for i in 0..d {
99-
if lower[i].is_nan() || upper[i].is_nan() {
104+
if !lower[i].is_finite() || !upper[i].is_finite() {
100105
return Err(QuadratureError::DegenerateInterval);
101106
}
102107
}
103108

104109
if d == 1 {
105-
// Delegate to 1D adaptive integrator
106-
return crate::adaptive::adaptive_integrate(
107-
|x: f64| f(&[x]),
108-
lower[0],
109-
upper[0],
110-
self.abs_tol,
111-
);
110+
// Delegate to 1D adaptive integrator, forwarding all settings
111+
return crate::adaptive::AdaptiveIntegrator::default()
112+
.with_abs_tol(self.abs_tol)
113+
.with_rel_tol(self.rel_tol)
114+
.with_max_evals(self.max_evals)
115+
.integrate(lower[0], upper[0], |x: f64| f(&[x]));
112116
}
113117

114118
let mut heap: BinaryHeap<SubRegion> = BinaryHeap::new();
@@ -135,7 +139,7 @@ impl AdaptiveCubature {
135139
let Some(worst) = heap.pop() else { break };
136140

137141
global_estimate -= worst.estimate;
138-
global_error -= worst.error;
142+
global_error = (global_error - worst.error).max(0.0);
139143

140144
// Bisect along the split axis
141145
let axis = worst.split_axis;
@@ -197,7 +201,7 @@ impl AdaptiveCubature {
197201
///
198202
/// Returns [`QuadratureError::InvalidInput`] if `lower` and `upper` have
199203
/// different lengths or are empty. Returns [`QuadratureError::DegenerateInterval`]
200-
/// if any bound is NaN.
204+
/// if any bound is non-finite.
201205
pub fn adaptive_cubature<G>(
202206
f: G,
203207
lower: &[f64],
@@ -400,6 +404,20 @@ mod tests {
400404
assert!(adaptive_cubature(|_| 1.0, &[0.0], &[1.0, 2.0], 1e-8).is_err());
401405
}
402406

407+
#[test]
408+
fn inf_bounds_rejected() {
409+
assert!(adaptive_cubature(|_| 1.0, &[f64::INFINITY], &[1.0], 1e-8).is_err());
410+
assert!(adaptive_cubature(|_| 1.0, &[0.0], &[f64::NEG_INFINITY], 1e-8).is_err());
411+
assert!(adaptive_cubature(|_| 1.0, &[0.0, f64::INFINITY], &[1.0, 1.0], 1e-8).is_err());
412+
}
413+
414+
#[test]
415+
fn dimension_cap() {
416+
let lower = vec![0.0; 31];
417+
let upper = vec![1.0; 31];
418+
assert!(adaptive_cubature(|_| 1.0, &lower, &upper, 1e-8).is_err());
419+
}
420+
403421
/// Constant function over [0,1]^2.
404422
#[test]
405423
fn constant_2d() {

src/cubature/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ impl CubatureRule {
6464
/// `nodes_flat` must have length `weights.len() * dim`.
6565
#[must_use]
6666
pub fn new(nodes_flat: Vec<f64>, weights: Vec<f64>, dim: usize) -> Self {
67-
debug_assert_eq!(nodes_flat.len(), weights.len() * dim);
67+
assert_eq!(nodes_flat.len(), weights.len() * dim);
6868
Self {
6969
nodes: nodes_flat,
7070
weights,

src/cubature/monte_carlo.rs

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -223,22 +223,25 @@ impl MonteCarloIntegrator {
223223
let estimate = volume * sum / n as f64;
224224

225225
// Heuristic error estimate: compare N/2 and N estimates
226-
let half_sum = {
227-
let mut seq2 = S::new(d)?;
228-
let half = n / 2;
229-
let mut sm = 0.0;
230-
for _ in 0..half {
231-
seq2.next_point(&mut u);
232-
for j in 0..d {
233-
x[j] = lower[j] + widths[j] * u[j];
226+
let half = n / 2;
227+
let error = if half == 0 {
228+
f64::INFINITY
229+
} else {
230+
let half_sum = {
231+
let mut seq2 = S::new(d)?;
232+
let mut sm = 0.0;
233+
for _ in 0..half {
234+
seq2.next_point(&mut u);
235+
for j in 0..d {
236+
x[j] = lower[j] + widths[j] * u[j];
237+
}
238+
sm += f(&x);
234239
}
235-
sm += f(&x);
236-
}
237-
volume * sm / half as f64
240+
volume * sm / half as f64
241+
};
242+
(estimate - half_sum).abs()
238243
};
239244

240-
let error = (estimate - half_sum).abs();
241-
242245
Ok(QuadratureResult {
243246
value: estimate,
244247
error_estimate: error,
@@ -352,13 +355,21 @@ impl MonteCarloIntegrator {
352355
})
353356
.collect();
354357

355-
// Merge chunk results
358+
// Merge chunk results using parallel Welford formula
356359
let mut total_sum = 0.0;
357360
let mut total_m2 = 0.0;
358361
let mut total_n = 0usize;
359362
for (sum, m2, count) in chunk_results {
363+
if total_n > 0 && count > 0 {
364+
let mean_a = total_sum / total_n as f64;
365+
let mean_b = sum / count as f64;
366+
let delta = mean_b - mean_a;
367+
total_m2 +=
368+
m2 + delta * delta * (total_n as f64 * count as f64) / (total_n + count) as f64;
369+
} else {
370+
total_m2 += m2;
371+
}
360372
total_sum += sum;
361-
total_m2 += m2;
362373
total_n += count;
363374
}
364375

@@ -420,12 +431,16 @@ impl MonteCarloIntegrator {
420431

421432
// Heuristic error: compare N/2 and N estimates
422433
let half = n / 2;
423-
let half_sum: f64 = (0..half)
424-
.into_par_iter()
425-
.map(|i| f(&points[i * d..(i + 1) * d]))
426-
.sum();
427-
let half_estimate = volume * half_sum / half as f64;
428-
let error = (estimate - half_estimate).abs();
434+
let error = if half == 0 {
435+
f64::INFINITY
436+
} else {
437+
let half_sum: f64 = (0..half)
438+
.into_par_iter()
439+
.map(|i| f(&points[i * d..(i + 1) * d]))
440+
.sum();
441+
let half_estimate = volume * half_sum / half as f64;
442+
(estimate - half_estimate).abs()
443+
};
429444

430445
Ok(QuadratureResult {
431446
value: estimate,

src/gauss_hermite.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ impl GaussHermite {
4444
return Err(QuadratureError::ZeroOrder);
4545
}
4646

47-
let (nodes, weights) = compute_hermite(n);
47+
let (nodes, weights) = compute_hermite(n)?;
4848
Ok(Self {
4949
rule: QuadratureRule { nodes, weights },
5050
})
@@ -61,7 +61,7 @@ impl_rule_accessors!(GaussHermite, nodes_doc: "Returns the nodes on (-∞, ∞).
6161
/// Jacobi matrix: diagonal = 0, off-diagonal² = (k+1)/2 for k=0..n-2.
6262
/// μ₀ = √π.
6363
#[allow(clippy::cast_precision_loss)] // n is a quadrature order, always small enough for exact f64
64-
fn compute_hermite(n: usize) -> (Vec<f64>, Vec<f64>) {
64+
fn compute_hermite(n: usize) -> Result<(Vec<f64>, Vec<f64>), QuadratureError> {
6565
let diag = vec![0.0; n];
6666
let off_diag_sq: Vec<f64> = (1..n).map(|k| k as f64 / 2.0).collect();
6767
let mu0 = core::f64::consts::PI.sqrt();

0 commit comments

Comments
 (0)