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
//! F2 — Resolvent time-jump amortization (math.md §47, ADR-0134).
//!
//! [`ResolventJumpChernoff`] computes `e^{tA}g` for a LARGE step `t` via the
//! Trefethen–Weideman–Schmelzer (2006) parabolic-contour inverse Laplace quadrature:
//!
//! ```text
//! e^{tA}g ≈ (1/2πi) Σ_k e^{λ_k t} (λ_k I − A)⁻¹ g · λ'(θ_k) · (2π/M)
//! ```
//!
//! The `M/t` contour scaling decouples node count from `t` (geometric convergence,
//! Theorem 47.1). Each resolvent `(λ_k I − A)⁻¹ g` is evaluated by a LEFT-half-
//! plane-capable complex Thomas solve (O(N) for the 3-pt Laplacian). The shipped
//! Gauss-Laguerre `eval`/`eval_complex` (§22.9) is NOT used — it diverges for
//! `Re λ ≤ 0`, which is where the optimal contour places most nodes.
//!
//! ## NARROW scope (§47.4 — NORMATIVE)
//!
//! Self-adjoint / sectorial generators only (every shipped diffusion kernel).
//! Non-self-adjoint / advection-dominated generators are OUT of scope for v8.0.0.
//!
//! ## References
//!
//! - Trefethen, Weideman, Schmelzer, *BIT Numer. Math.* 46:3 (2006), pp. 653–670.
//! - math.md §47 (NORMATIVE), ADR-0134 (ACCEPTED-NARROW, 2026-06-07).
//! - Gate `G_RESOLVENT_JUMP_ORDER` (`RELEASE_BLOCKING`) + oracle `T_RESOLVENT_JUMP`.
// Contour node count M and grid sizes (usize) cast to f64 for step/scale; ≪ 2^52.
#![allow(clippy::cast_precision_loss)]
extern crate alloc;
use alloc::{vec, vec::Vec};
use num_complex::Complex;
use crate::{
chernoff::ChernoffFunction,
error::SemiflowError,
float::{from_f64, SemiflowFloat},
grid::Grid1D,
grid_fn::GridFn1D,
};
// ---------------------------------------------------------------------------
// TWS-2006 parabolic-contour constants (§47.2, NORMATIVE — do NOT alter).
// ---------------------------------------------------------------------------
/// TWS-2006 parabolic-contour coefficients `(a₀, a₁, a₂)`.
/// `λ(θ) = (M/t)(a₀ − a₁ θ² + i a₂ θ)`.
const TWS: [f64; 3] = [0.1309, 0.1194, 0.2500];
// ---------------------------------------------------------------------------
// Minimum node count: below this the geometric regime is not yet reached.
// ---------------------------------------------------------------------------
/// Minimum allowed `m_nodes` (construction gate).
const M_MIN: usize = 6;
// ---------------------------------------------------------------------------
// Public type
// ---------------------------------------------------------------------------
/// Resolvent time-jump Chernoff function (F2 NARROW-GO, ADR-0134, §47).
///
/// Computes `e^{tA}g` for a large time step `t` via a constant-`M` contour
/// quadrature over `(λ_k I − A)⁻¹ g`, evaluated by a complex Thomas solve.
///
/// **Construction**: `ResolventJumpChernoff::new(inner, m_nodes)`. The `inner`
/// must be a 1D diffusion-family Chernoff function (self-adjoint, sectorial);
/// `inner.grid` is used to reconstruct the discrete Laplacian for each LHP node.
///
/// **Usage**: call [`jump`](Self::jump) for a single large-step approximation.
/// This is NOT a [`ChernoffFunction`] implementation: it represents the resolved
/// semigroup directly, not a single-step approximant.
pub struct ResolventJumpChernoff<C, F = f64>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
/// Inner Chernoff function carrying the operator geometry.
pub inner: C,
/// Number of contour nodes `M`. Invariant: `m_nodes >= M_MIN`.
pub m_nodes: usize,
/// Grid geometry (extracted from `inner.grid` at construction).
pub grid: Grid1D<F>,
}
impl<C, F> ResolventJumpChernoff<C, F>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
/// Construct from an inner Chernoff function and a node count `M ≥ 6`.
///
/// The grid is extracted from `inner` at construction; `inner.grid` must
/// be accessible (all shipped 1D diffusion kernels expose `pub grid`).
///
/// # Errors
/// [`SemiflowError::DomainViolation`] if `m_nodes < 6`.
pub fn new(inner: C, m_nodes: usize, grid: Grid1D<F>) -> Result<Self, SemiflowError> {
if m_nodes < M_MIN {
return Err(SemiflowError::DomainViolation {
what: "ResolventJumpChernoff: m_nodes must be >= 6 (geometric regime)",
value: m_nodes as f64,
});
}
Ok(Self {
inner,
m_nodes,
grid,
})
}
/// Approximate `e^{tA}g` via the TWS parabolic-contour inverse Laplace sum.
///
/// Implements math.md §47.3 (NORMATIVE). Each contour node is solved by
/// `resolve_lhp` — a complex Thomas O(N) solve that is
/// valid for any `λ ∉ σ(A)`, including `Re λ < 0`.
///
/// # Errors
/// [`SemiflowError::DomainViolation`] if `t ≤ 0` or `t` non-finite.
// t, g, k, m, s, p: standard resolvent quadrature names from math §47.3.
#[allow(clippy::many_single_char_names)]
pub fn jump(&self, t: F, g: &GridFn1D<F>) -> Result<GridFn1D<F>, SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
{
if !t.is_finite() || t <= F::zero() {
return Err(SemiflowError::DomainViolation {
what: "ResolventJumpChernoff::jump: t must be finite and positive",
value: t.to_f64().unwrap_or(f64::NAN),
});
}
let n = self.grid.n;
// Accumulator in f64 complex (contour arithmetic uses f64 throughout).
let mut acc: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
let m = self.m_nodes;
let t_f64 = t.to_f64().unwrap_or(f64::NAN);
let scale = m as f64 / t_f64; // M/t
let step = 2.0 * core::f64::consts::PI / m as f64; // 2π/M
for k in 0..m {
let (lam, dlam) = contour_node(scale, k, step);
let r = self.resolve_lhp(lam, g)?;
// weight = e^{λ t} · λ'(θ) · (2π/M) / (2π i) = e^{λt} λ' / (i M)
let weight = crate::complex_libm::exp(lam * t_f64) * dlam * step
/ Complex::new(0.0, 2.0 * core::f64::consts::PI);
for i in 0..n {
acc[i] += weight * r[i];
}
}
// Take real part into GridFn1D<F>.
let values: Vec<F> = acc.iter().map(|z| from_f64::<F>(z.re)).collect();
Ok(GridFn1D {
values,
grid: self.grid,
})
}
/// Complex Thomas solve of `(λI − A)g = r` for the discrete 3-pt Laplacian.
///
/// Valid for any `λ ∉ σ(A)` — including `Re λ < 0`. The system is
/// complex tridiagonal; the Thomas algorithm is unconditionally stable
/// off-spectrum for sectorial `A`.
///
/// Returns the per-node complex values as `Vec<Complex<f64>>` (contour
/// arithmetic stays in f64; real part is taken in `jump`).
fn resolve_lhp(
&self,
lam: Complex<f64>,
g: &GridFn1D<F>,
) -> Result<Vec<Complex<f64>>, SemiflowError> {
let n = self.grid.n;
let dx = self.grid.dx().to_f64().unwrap_or(f64::NAN);
let inv_dx2 = 1.0 / (dx * dx);
// Assemble tridiagonal (λI − A) for the 3-pt Neumann Laplacian.
// Neumann BC: boundary rows use one-sided stencil (mirror of laplacian_1d in kit).
let sub = Complex::new(-inv_dx2, 0.0);
let sup = Complex::new(-inv_dx2, 0.0);
// Build RHS and run Thomas forward sweep.
lhp_thomas(lam, inv_dx2, sub, sup, n, g)
}
}
// ---------------------------------------------------------------------------
// Contour helpers
// ---------------------------------------------------------------------------
/// TWS parabolic node and derivative at index `k`.
///
/// Returns `(λ_k, λ'_k)` with `θ_k = −π + (k + ½)(2π/M)`.
/// `pub(crate)` so that `resolvent_jump_nd` can reuse the same coefficients.
#[inline]
pub(crate) fn contour_node(scale: f64, k: usize, step: f64) -> (Complex<f64>, Complex<f64>) {
let theta = -core::f64::consts::PI + (k as f64 + 0.5) * step;
let lam = Complex::new(
scale * (TWS[0] - TWS[1] * theta * theta),
scale * TWS[2] * theta,
);
let dlam = Complex::new(scale * (-2.0 * TWS[1] * theta), scale * TWS[2]);
(lam, dlam)
}
// ---------------------------------------------------------------------------
// Scalar complex Thomas solve for (λI − A)r = g
// ---------------------------------------------------------------------------
/// Forward-backward Thomas solve for the complex tridiagonal `(λI − A)r = g`.
///
/// `A` is the 3-pt Neumann Laplacian: interior diag `−2/dx²`, off-diag `1/dx²`;
/// boundary diag `−1/dx²` (one-sided). Returns complex nodal solution.
///
/// # Errors
/// [`SemiflowError::DomainViolation`] if a pivot is (near) zero (λ on spectrum).
fn lhp_thomas<F: SemiflowFloat>(
lam: Complex<f64>,
inv_dx2: f64,
sub: Complex<f64>,
sup: Complex<f64>,
n: usize,
g: &GridFn1D<F>,
) -> Result<Vec<Complex<f64>>, SemiflowError> {
// Diagonal entries: d[0] = λ − (−1/dx²) = λ + 1/dx² (Neumann boundary)
// d[k] = λ − (−2/dx²) = λ + 2/dx² (interior)
// d[n-1] same as d[0] by symmetry.
let d_bnd = lam + Complex::new(inv_dx2, 0.0);
let d_int = lam + Complex::new(2.0 * inv_dx2, 0.0);
// Thomas forward sweep.
let mut c_prime: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
let mut d_prime: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
// Row 0 (boundary, no sub-diagonal).
let piv0 = d_bnd;
if crate::complex_libm::norm(piv0) < 1e-300 {
return Err(SemiflowError::DomainViolation {
what: "resolve_lhp: pivot near zero (λ on spectrum)",
value: crate::complex_libm::norm(piv0),
});
}
c_prime[0] = sup / piv0;
d_prime[0] = Complex::new(g.values[0].to_f64().unwrap_or(0.0), 0.0) / piv0;
for k in 1..n {
let dk = if k == n - 1 { d_bnd } else { d_int };
let piv = dk - sub * c_prime[k - 1];
if crate::complex_libm::norm(piv) < 1e-300 {
return Err(SemiflowError::DomainViolation {
what: "resolve_lhp: pivot near zero (λ on spectrum)",
value: crate::complex_libm::norm(piv),
});
}
c_prime[k] = sup / piv;
let gk = Complex::new(g.values[k].to_f64().unwrap_or(0.0), 0.0);
d_prime[k] = (gk - sub * d_prime[k - 1]) / piv;
}
// Back substitution.
let mut r: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
r[n - 1] = d_prime[n - 1];
for k in (0..n - 1).rev() {
r[k] = d_prime[k] - c_prime[k] * r[k + 1];
}
Ok(r)
}