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
//! Heisenberg group H₁ heat kernel oracle via Gaveau-Hulanicki integral form.
//!
//! Per math.md §28 AMENDMENT 2 (corrected from AMENDMENT 1, 4 transcription
//! bugs fixed; see ADR-0087 AMENDMENT 1). Implementation mirrors
//! `scripts/verify_hormander_heisenberg.py::_gh_integrand` line-for-line
//! per spec AC4 cross-validation requirement.
//!
//! Formula (math.md §28 AMENDMENT 2):
//!
//! ```text
//! p_h(x, y, t) = (1/(2π)²) · ∫_{-Λ}^{+Λ}
//! (λ/sinh(λh/2)) · exp(−(λ/4)·coth(λh/2)·(x²+y²)) · cos(λt) dλ
//! ```
//!
//! where Λ = 16/h. Quadrature: 32-pt Gauss-Legendre on `[-1, 1]` mapped to
//! `[-Λ, Λ]` (canonical nodes/weights from Abramowitz & Stegun Table 25.4).
//!
//! λ=0 removable singularity by L'Hôpital: `(2/h) · exp(−r²/(2h))`.
//!
//! This file is `no_std + libm`-compatible: only `sinh`, `cosh`, `exp`,
//! `cos` consumed via `SemiflowFloat` trait methods (ADR-0025).
//!
//! # References
//!
//! - Beals-Greiner 1988 *Calculus on Heisenberg Manifolds* Theorem 5.18
//! - KTX 2005 arxiv math/0401243 §2
//! - Hulanicki 1976 *Studia Math.* 56:165-173
//! - math.md §28 AMENDMENT 2
use crate::float::{from_f64, SemiflowFloat};
// ─── 32-pt Gauss-Legendre nodes/weights on [-1, 1] ───────────────────────────
//
// Canonical table from Abramowitz & Stegun Table 25.4.
// These are Gauss-LEGENDRE on [-1,1], NOT the Gauss-Laguerre nodes in
// resolvent_quad.rs (which are for ∫₀^∞ e^{-s} h(s) ds — different form).
/// 32-pt Gauss-Legendre nodes on `[-1, 1]` (symmetric pairs, A&S Table 25.4).
pub(crate) const GL32_LEGENDRE_NODES: [f64; 32] = [
-9.972_638_618_494_816e-01,
-9.856_115_115_452_684e-01,
-9.647_622_555_875_064e-01,
-9.349_060_759_377_397e-01,
-8.963_211_557_660_521e-01,
-8.493_676_137_325_7e-1,
-7.944_837_959_679_424e-01,
-7.321_821_187_402_897e-01,
-6.630_442_669_302_152e-01,
-5.877_157_572_407_623e-01,
-5.068_999_089_322_294e-01,
-4.213_512_761_306_353e-01,
-3.318_686_022_821_276e-01,
-2.392_873_622_521_371e-01,
-1.444_719_615_827_965e-01,
-4.830_766_568_773_832e-02,
4.830_766_568_773_832e-02,
1.444_719_615_827_965e-01,
2.392_873_622_521_371e-01,
3.318_686_022_821_276e-01,
4.213_512_761_306_353e-01,
5.068_999_089_322_294e-01,
5.877_157_572_407_623e-01,
6.630_442_669_302_152e-01,
7.321_821_187_402_897e-01,
7.944_837_959_679_424e-01,
8.493_676_137_325_7e-1,
8.963_211_557_660_521e-01,
9.349_060_759_377_397e-01,
9.647_622_555_875_064e-01,
9.856_115_115_452_684e-01,
9.972_638_618_494_816e-01,
];
/// 32-pt Gauss-Legendre weights for `[-1, 1]` (A&S Table 25.4).
pub(crate) const GL32_LEGENDRE_WEIGHTS: [f64; 32] = [
7.018_610_009_470_506e-03,
1.627_439_473_090_574e-02,
2.539_206_530_926_202e-02,
3.427_386_291_302_177e-02,
4.283_589_802_222_684e-02,
5.099_805_926_237_61e-2,
5.868_409_347_853_557e-02,
6.582_222_277_636_168e-02,
7.234_579_410_884_834e-02,
7.819_389_578_707_023e-02,
8.331_192_422_694_671e-02,
8.765_209_300_440_379e-02,
9.117_387_869_576_378e-02,
9.384_439_908_080_451e-02,
9.563_872_007_927_471e-02,
9.654_008_851_472_766e-02,
9.654_008_851_472_766e-02,
9.563_872_007_927_471e-02,
9.384_439_908_080_451e-02,
9.117_387_869_576_378e-02,
8.765_209_300_440_379e-02,
8.331_192_422_694_671e-02,
7.819_389_578_707_023e-02,
7.234_579_410_884_834e-02,
6.582_222_277_636_168e-02,
5.868_409_347_853_557e-02,
5.099_805_926_237_61e-2,
4.283_589_802_222_684e-02,
3.427_386_291_302_177e-02,
2.539_206_530_926_202e-02,
1.627_439_473_090_574e-02,
7.018_610_009_470_506e-03,
];
// ─── Gaveau-Hulanicki integrand (mirrors _gh_integrand in the Python oracle) ──
/// Single integrand evaluation at `λ`, mirroring `_gh_integrand` in
/// `scripts/verify_hormander_heisenberg.py` byte-for-byte.
///
/// Formula: `(λ/sinh(λh/2)) · exp(−(λ/4)·coth(λh/2)·r²) · cos(λt)`.
/// λ=0 limit: `(2/h) · exp(−r²/(2h))`.
// cosh_lhh and coth_lhh are mathematically related hyperbolic functions; names are intentional.
#[allow(clippy::similar_names)]
#[inline]
fn heisenberg_integrand<F: SemiflowFloat>(lam: F, h: F, r2: F, tc: F) -> F {
let tiny = from_f64::<F>(1e-10_f64);
if lam.abs() < tiny {
// L'Hôpital limit: (2/h) · exp(-r²/(2h))
let two = from_f64::<F>(2.0_f64);
let exp_arg = -(r2 / (two * h));
return (two / h) * exp_arg.libm_exp();
}
let half = from_f64::<F>(0.5_f64);
let lam_h_half = lam * h * half; // λh/2
let sinh_lhh = lam_h_half.libm_sinh();
// Guard against numerical underflow near sinh=0 (defensive; only triggers
// at very large |λ| where the exponential decays the integrand to zero).
let tiny2 = from_f64::<F>(1e-10_f64);
if sinh_lhh.abs() < tiny2 {
return F::zero();
}
let cosh_lhh = lam_h_half.libm_cosh();
let coth_lhh = cosh_lhh / sinh_lhh; // coth(λh/2)
let four = from_f64::<F>(4.0_f64);
let exp_arg = -(lam / four) * coth_lhh * r2;
let cos_arg = lam * tc;
(lam / sinh_lhh) * exp_arg.libm_exp() * cos_arg.libm_cos()
}
// ─── Public surface ───────────────────────────────────────────────────────────
/// Evaluate the Gaveau-Hulanicki heat kernel `p_h(x, y, t)` for the
/// Heisenberg sub-Laplacian `L = ½(X₁² + X₂²)` on `ℍ¹`.
///
/// Uses 32-pt Gauss-Legendre on `[-16/h, +16/h]` (production oracle for the
/// `G_HORM_HEISENBERG` slope gate). For higher-precision reference, use
/// `scripts/verify_hormander_heisenberg.py::_gh_kernel_mp` (mpmath, ~1000×
/// slower; used only in the sympy `T_HORM_HEISENBERG` sub-checks).
///
/// # Arguments
/// - `h`: evolution time (must be positive; returns `F::zero()` for `h ≤ 0`).
/// - `x`, `y`: horizontal Heisenberg coordinates.
/// - `t`: central coordinate (third dimension, direction of `[X₁, X₂]`).
///
/// # References
/// - Beals-Greiner 1988 Theorem 5.18; math.md §28 AMENDMENT 2.
pub fn heisenberg_heat_kernel<F: SemiflowFloat>(h: F, x: F, y: F, tc: F) -> F {
if h <= F::zero() {
return F::zero();
}
let r2 = x * x + y * y;
let sixteen = from_f64::<F>(16.0_f64);
let lam_max = sixteen / h;
let four_pi_sq_inv = {
let pi = from_f64::<F>(core::f64::consts::PI);
let four = from_f64::<F>(4.0_f64);
F::one() / (four * pi * pi)
};
let mut total = F::zero();
for (&node, &weight) in GL32_LEGENDRE_NODES.iter().zip(GL32_LEGENDRE_WEIGHTS.iter()) {
let node_f = from_f64::<F>(node);
let weight_f = from_f64::<F>(weight);
let lam = lam_max * node_f; // map [-1,1] → [-Λ, Λ]
total += weight_f * heisenberg_integrand(lam, h, r2, tc);
}
// Jacobian: dλ = Λ dξ
four_pi_sq_inv * (lam_max * total)
}
// ─── Unit tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
// Exact float comparisons in tests verify round-trip identity or sentinel values.
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
/// Cross-validate 32-pt GL Rust impl against the INDEPENDENT Python
/// `_gh_kernel` reference at 6 probe points.
///
/// **Reference values are the Python oracle output** (independent
/// implementation), NOT the Rust kernel's own output. They were generated
/// via:
/// ```text
/// python3 -c "
/// from scripts.verify_hormander_heisenberg import _gh_kernel
/// cases = [(0.5,0.1,0.0,0.0),(0.5,0.0,0.1,0.0),(0.5,0.0,0.0,0.1),
/// (1.0,0.0,0.0,0.0),(0.5,0.2,0.2,0.0),(0.3,0.0,0.0,0.05)]
/// for h,x,y,tc in cases: print(f'{_gh_kernel(h,x,y,tc):.18e}')
/// "
/// ```
///
/// Both implementations use the identical 32-pt GL formula on `[-16/h, +16/h]`
/// but differ in summation/evaluation order (Rust: libm, Python: `math`),
/// producing genuine floating-point noise of ≤1.124e-9 — the measured max
/// diff across all 6 cases on x86-64 release build. The fact that Rust and
/// Python values differ by ~1e-9 (not zero) confirms they are independent.
///
/// **Tolerance: 2e-9 absolute** (~1.8× above the measured max cross-impl diff
/// of 1.124e-9). This test answers: "does the Rust GL32 kernel agree with an
/// independent Python GL32 implementation to eval-order noise?" — yes, ~1e-9.
///
/// Note: `_gh_kernel_mp` (384-pt mpmath on `[-50/h, +50/h]`) differs by ~4e-3
/// due to a wider integration range; that gap is expected quadrature truncation
/// and is independent of the cross-implementation agreement measured here.
#[test]
fn heisenberg_kernel_matches_sympy_oracle() {
// Columns: (h, x, y, tc, python_expected)
// python_expected = _gh_kernel(h,x,y,tc) from scripts/verify_hormander_heisenberg.py
// These are INDEPENDENT Python oracle values (not the Rust kernel's own output).
// Rust vs Python diffs are ~1e-9 (eval-order noise, NOT regression baseline).
let cases: &[(f64, f64, f64, f64, f64)] = &[
(0.5, 0.1, 0.0, 0.0, 1.956_071_483_627_801_7),
(0.5, 0.0, 0.1, 0.0, 1.956_071_483_627_801_7),
(0.5, 0.0, 0.0, 0.1, 1.383_716_318_640_359_6),
(1.0, 0.0, 0.0, 0.0, 4.987_763_792_291_724_5e-1),
(0.5, 0.2, 0.2, 0.0, 1.709_440_273_079_713_6),
(0.3, 0.0, 0.0, 0.05, 4.285_703_873_427_739),
];
let mut max_diff: f64 = 0.0;
for &(h, x, y, tc, expected) in cases {
let actual = heisenberg_heat_kernel(h, x, y, tc);
let diff = (actual - expected).abs();
if diff > max_diff {
max_diff = diff;
}
assert!(
diff < 2e-9,
"h={h} x={x} y={y} t={tc}: Rust={actual:.18e} python_oracle={expected:.18e} \
diff={diff:.3e} (gate 2e-9, cross-impl eval-order noise expected ~1e-9)"
);
}
// Verify the overall max cross-implementation diff stays well below gate.
assert!(
max_diff < 2e-9,
"max Rust-vs-Python diff {max_diff:.3e} exceeds gate 2e-9 — \
cross-impl GL32 summation-order noise"
);
}
/// Verify the on-diagonal analytic identity: `p_h(0,0,0)` = 1/(2h²).
///
/// The 32-pt GL on `[-16/h, +16/h]` misses the tails ∫_{|λ|>16/h} by ~0.24%
/// (confirmed against Python `_gh_kernel`). Gate 5e-3 accommodates this
/// quadrature truncation; the slope test uses the oracle consistently so
/// the truncation is a systematic bias and does not affect slope measurement.
#[test]
fn heisenberg_kernel_on_diagonal_identity() {
for h in [0.25_f64, 0.5, 1.0, 2.0] {
let expected = 1.0 / (2.0 * h * h);
let actual = heisenberg_heat_kernel(h, 0.0_f64, 0.0_f64, 0.0_f64);
let rel = (actual - expected).abs() / expected;
assert!(
rel < 5e-3,
"h={h}: p_h(0,0,0)={actual:.6e} expected={expected:.6e} rel={rel:.3e}"
);
}
}
/// Verify kernel returns zero for non-positive h (defensive guard).
#[test]
fn heisenberg_kernel_nonpositive_h() {
assert_eq!(heisenberg_heat_kernel(0.0_f64, 0.5, 0.5, 0.5), 0.0);
assert_eq!(heisenberg_heat_kernel(-1.0_f64, 0.5, 0.5, 0.5), 0.0);
}
}