semiflow/truncated_exp4_cached.rs
1//! Wave B2 cache types for `TruncatedExp4thDiffusionChernoff` (v0.13.0, ADR-0034 Amendment 1).
2//!
3//! Kept in a separate module so that `truncated_exp4.rs` stays within the
4//! 700-line constitution cap (Override #1).
5//!
6//! ## What is here
7//!
8//! - [`HalfNodeCoeffCache`]: pre-evaluated `a(x)` at the four G⁴ half-node offsets.
9//! - [`TruncatedExp4WithCache`]: wrapper that substitutes cache lookups for closure
10//! dispatch in the stencil hot loop, enabling Wave B3 SIMD lane saturation.
11
12use alloc::vec::Vec;
13
14// Wave B3: SIMD dispatch imports (ADR-0019 Amendment 2).
15// Re-exported from simd/mod.rs; arch-specific impls in simd/x86_64.rs / aarch64.rs.
16#[cfg(all(feature = "simd", target_arch = "x86_64", target_feature = "avx2"))]
17use crate::simd::apply_g4_stencil_avx2_4nodes;
18#[cfg(all(feature = "simd", target_arch = "aarch64", target_feature = "neon"))]
19use crate::simd::apply_g4_stencil_neon_4nodes;
20use crate::{
21 chernoff::{ChernoffFunction, Growth},
22 error::SemiflowError,
23 grid::Grid1D,
24 grid_fn::GridFn1D,
25 scratch::ScratchPool,
26 truncated_exp4::{
27 apply_power_series_f64_at_node, validate_a_norm_bound_f64, validate_cfl_4th,
28 validate_tau_f64, TruncatedExp4thDiffusionChernoff, TRUNC_ORDER_USIZE,
29 },
30};
31
32// ---------------------------------------------------------------------------
33// HalfNodeCoeffCache
34// ---------------------------------------------------------------------------
35
36/// Pre-evaluated `a(x)` at the four half-node offsets required by the G⁴ stencil.
37///
38/// Layout (N = number of grid nodes):
39/// - `ar3h[i]` = `a(x_i + 3·dx/2)` for i in 0..N
40/// - `ar1h[i]` = `a(x_i + dx/2)` for i in 0..N
41/// - `al1h[i]` = `a(x_i - dx/2)` for i in 0..N
42/// - `al3h[i]` = `a(x_i - 3·dx/2)` for i in 0..N
43///
44/// Eliminates four `a(x)` function-pointer calls per node per stencil application.
45/// `precompute_g4_grids_f64` calls the stencil K=4 times, so Wave B2 removes
46/// `4 × K × N = 16N` function-pointer dispatches per `apply()`.
47///
48/// # Caller contract
49///
50/// The function pointer `a` passed to [`HalfNodeCoeffCache::new`] MUST be **pure
51/// and deterministic** (same input always yields the same output). If `a` has
52/// side effects or non-deterministic behaviour the cached values will diverge
53/// from the live-dispatch path and `TEXP4_CACHED_COEFF_BIT_EQUAL` will fail.
54///
55/// See ADR-0034 Amendment 1.
56#[derive(Clone)]
57pub struct HalfNodeCoeffCache {
58 /// `a(x_i + 3·dx/2)` for i in 0..N
59 pub ar3h: Vec<f64>,
60 /// `a(x_i + dx/2)` for i in 0..N
61 pub ar1h: Vec<f64>,
62 /// `a(x_i - dx/2)` for i in 0..N
63 pub al1h: Vec<f64>,
64 /// `a(x_i - 3·dx/2)` for i in 0..N
65 pub al3h: Vec<f64>,
66}
67
68impl HalfNodeCoeffCache {
69 /// Populate cache by evaluating `a(x)` at all half-node offsets for grid `g`.
70 ///
71 /// Boundary half-nodes (`i=0` left side, `i=N-1` right side) are evaluated by
72 /// calling `a()` at the extrapolated coordinate — matching the live-closure path
73 /// in `apply_g4_at_node_f64` which calls `(mc.a)(x_i ± offset)` without clamping
74 /// (ADR-0007 boundary policy applies at the `GridFn` level, not here).
75 // ar3h/ar1h/al1h/al3h: a×right/left×3/1×half — mathematically unambiguous.
76 #[allow(clippy::similar_names)]
77 pub fn new(a: fn(f64) -> f64, grid: Grid1D<f64>) -> Self {
78 let n = grid.n;
79 let dx = grid.dx();
80 let mut ar3h = Vec::with_capacity(n);
81 let mut ar1h = Vec::with_capacity(n);
82 let mut al1h = Vec::with_capacity(n);
83 let mut al3h = Vec::with_capacity(n);
84 for i in 0..n {
85 let x = grid.x_at(i);
86 ar3h.push(a(x + 1.5 * dx));
87 ar1h.push(a(x + 0.5 * dx));
88 al1h.push(a(x - 0.5 * dx));
89 al3h.push(a(x - 1.5 * dx));
90 }
91 Self {
92 ar3h,
93 ar1h,
94 al1h,
95 al3h,
96 }
97 }
98}
99
100// ---------------------------------------------------------------------------
101// TruncatedExp4WithCache
102// ---------------------------------------------------------------------------
103
104/// [`TruncatedExp4thDiffusionChernoff`] with pre-evaluated `a(x)` half-node cache.
105///
106/// Implements [`ChernoffFunction`] identically to the wrapped type but replaces
107/// the 4-per-node function-pointer dispatches in `apply_g4_at_node_f64` with
108/// direct `Vec<f64>` index reads.
109///
110/// # Construction
111///
112/// ```rust
113/// use semiflow::{chernoff::ApplyChernoffExt, Grid1D, GridFn1D, TruncatedExp4WithCache};
114/// let grid = Grid1D::new(-4.0, 4.0, 64).unwrap();
115/// let me4c = TruncatedExp4WithCache::with_cached_coefficients(
116/// |_| 1.0, |_| 0.0, |_| 0.0, 1.0, grid,
117/// );
118/// let u0 = GridFn1D::from_fn(grid, |x| (-x * x).exp());
119/// let u1 = me4c.apply_chernoff(0.001, &u0).unwrap();
120/// assert_eq!(u1.values.len(), 64);
121/// ```
122///
123/// # Caller invariants
124///
125/// Same as [`TruncatedExp4thDiffusionChernoff`]. Additionally, `a(x)` MUST be
126/// **pure and deterministic** — see [`HalfNodeCoeffCache`] doc.
127///
128/// See ADR-0034 Amendment 1.
129#[derive(Clone)]
130pub struct TruncatedExp4WithCache {
131 /// Wrapped Chernoff operator (closure path, unchanged, for validation reuse).
132 pub(crate) inner: TruncatedExp4thDiffusionChernoff<f64>,
133 /// Pre-evaluated half-node coefficient cache.
134 pub(crate) cache: HalfNodeCoeffCache,
135}
136
137impl TruncatedExp4WithCache {
138 /// Construct with pre-evaluated `a(x)` half-node coefficients.
139 ///
140 /// The hot loop indexes the cache instead of calling the closure, enabling
141 /// SIMD lane saturation in Wave B3.
142 ///
143 /// Memory cost: 4N × 8 bytes ≈ 16 KB at N=512, negligible vs the 2.8 MB
144 /// per-call baseline measured in iter-3 benchmarks.
145 ///
146 /// # Panics
147 ///
148 /// Does not panic. All grid operations are deterministic.
149 #[must_use]
150 pub fn with_cached_coefficients(
151 a: fn(f64) -> f64,
152 a_prime: fn(f64) -> f64,
153 a_double_prime: fn(f64) -> f64,
154 a_norm_bound: f64,
155 grid: Grid1D<f64>,
156 ) -> Self {
157 let inner =
158 TruncatedExp4thDiffusionChernoff::new(a, a_prime, a_double_prime, a_norm_bound, grid);
159 let cache = HalfNodeCoeffCache::new(a, grid);
160 Self { inner, cache }
161 }
162}
163
164impl ChernoffFunction<f64> for TruncatedExp4WithCache {
165 type S = GridFn1D<f64>;
166
167 /// Consistency order **2**.
168 fn order(&self) -> u32 {
169 2
170 }
171
172 /// Growth `(M, ω) = (1.0, 0.0)`.
173 fn growth(&self) -> Growth<f64> {
174 Growth::contraction()
175 }
176
177 /// Allocation-free output override using pre-evaluated coefficient cache.
178 ///
179 /// Uses the same `precompute_g4_grids_cached` stencil path (including SIMD
180 /// dispatch) as `apply` to guarantee bit-identical results. The only savings vs
181 /// the default bridge: output is written directly into `dst` instead of returning
182 /// a new `GridFn1D` allocation.
183 ///
184 /// The four g-grid `GridFn1D` intermediates are still heap-allocated per call.
185 /// Scratch is accepted but unused (`GridFn1D` state cannot be stored in `ScratchPool`).
186 fn apply_into(
187 &self,
188 tau: f64,
189 src: &GridFn1D<f64>,
190 dst: &mut GridFn1D<f64>,
191 _scratch: &mut ScratchPool<f64>,
192 ) -> Result<(), SemiflowError> {
193 validate_tau_f64(tau)?;
194 validate_a_norm_bound_f64(self.inner.a_norm_bound)?;
195 let dx = self.inner.grid.dx();
196 validate_cfl_4th(tau, self.inner.a_norm_bound, dx)?;
197 let n = src.values.len();
198 let g_grids = precompute_g4_grids_cached(&self.inner, &self.cache, src)?;
199 dst.values.resize(n, 0.0);
200 // No drift conjugation — direct index (bit-identical to apply).
201 for i in 0..n {
202 dst.values[i] = apply_power_series_f64_at_node(tau, &g_grids, i);
203 }
204 Ok(())
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Cached stencil helpers
210// ---------------------------------------------------------------------------
211
212/// Precompute G⁴ power-series grids using cache lookups instead of closure dispatch.
213fn precompute_g4_grids_cached(
214 mc: &TruncatedExp4thDiffusionChernoff<f64>,
215 cache: &HalfNodeCoeffCache,
216 f: &GridFn1D<f64>,
217) -> Result<[GridFn1D<f64>; TRUNC_ORDER_USIZE + 1], SemiflowError> {
218 let g0 = f.clone();
219 let g1 = apply_g4_stencil_cached(mc, cache, &g0)?;
220 let g2 = apply_g4_stencil_cached(mc, cache, &g1)?;
221 let g3 = apply_g4_stencil_cached(mc, cache, &g2)?;
222 let g4 = apply_g4_stencil_cached(mc, cache, &g3)?;
223 Ok([g0, g1, g2, g3, g4])
224}
225
226/// Apply the G⁴ stencil once; reads coefficients from cache instead of closure.
227///
228/// Dispatches to the 4-node lane path (Wave B3) for interior chunks in every
229/// build: AVX2/NEON intrinsics under `feature = "simd"`, a scalar mirror of the
230/// same arithmetic otherwise (ADR-0200).
231/// Boundary nodes (first/last 2) always use the scalar path (requires `sample()`
232/// for out-of-bounds extrapolation).
233fn apply_g4_stencil_cached(
234 mc: &TruncatedExp4thDiffusionChernoff<f64>,
235 cache: &HalfNodeCoeffCache,
236 prev: &GridFn1D<f64>,
237) -> Result<GridFn1D<f64>, SemiflowError> {
238 let n = prev.values.len();
239 let dx = mc.grid.dx();
240 let dx_sq = dx * dx;
241 let mut out = prev.zeroed_like();
242
243 #[cfg(test)]
244 if crate::simd::FORCE_SCALAR.with(core::cell::Cell::get) {
245 apply_g4_stencil_cached_scalar(mc, cache, prev, &mut out, n, dx, dx_sq)?;
246 return Ok(out);
247 }
248 apply_g4_stencil_cached_simd(mc, cache, prev, &mut out, n, dx, dx_sq)
249}
250
251/// Scalar implementation of the G⁴ stencil (all nodes, no SIMD).
252#[allow(dead_code)] // used by the test force-scalar hook
253#[allow(clippy::too_many_arguments)]
254fn apply_g4_stencil_cached_scalar(
255 mc: &TruncatedExp4thDiffusionChernoff<f64>,
256 cache: &HalfNodeCoeffCache,
257 prev: &GridFn1D<f64>,
258 out: &mut GridFn1D<f64>,
259 n: usize,
260 dx: f64,
261 dx_sq: f64,
262) -> Result<(), SemiflowError> {
263 for i in 0..n {
264 let x_i = mc.grid.x_at(i);
265 out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
266 }
267 Ok(())
268}
269
270/// SIMD dispatch path: boundary nodes scalar, interior chunks of 4 via arch SIMD.
271///
272/// Boundary invariant: the first/last 2 nodes may require `prev.sample()` for
273/// out-of-bounds extrapolation — those are always processed by the scalar path.
274/// Interior nodes [2 .. n-2] are safe for the 5-point stencil without extrapolation.
275#[allow(clippy::too_many_arguments)]
276fn apply_g4_stencil_cached_simd(
277 mc: &TruncatedExp4thDiffusionChernoff<f64>,
278 cache: &HalfNodeCoeffCache,
279 prev: &GridFn1D<f64>,
280 out: &mut GridFn1D<f64>,
281 n: usize,
282 dx: f64,
283 dx_sq: f64,
284) -> Result<GridFn1D<f64>, SemiflowError> {
285 let dx_sq_inv = 1.0 / dx_sq;
286 let scalar_left = 2_usize.min(n);
287 let simd_end = n.saturating_sub(2); // exclusive: last 2 nodes stay scalar
288
289 // Scalar left boundary (nodes 0, 1).
290 for i in 0..scalar_left {
291 let x_i = mc.grid.x_at(i);
292 out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
293 }
294
295 // SIMD interior: chunks of 4 where all 5-point stencil reads are in-bounds.
296 // Condition: base >= 2 (scalar_left) and base+5 <= n (i.e. base <= n-5).
297 // simd_end = n-2, so base+4 <= n-2 → base <= n-6 → base+5 <= n-1 < n. Safe.
298 let mut i = scalar_left;
299 while i + 4 <= simd_end {
300 apply_g4_4nodes_simd(i, prev, cache, dx_sq_inv, out);
301 i += 4;
302 }
303
304 // Scalar tail + right boundary (remainder and last 2 nodes).
305 while i < n {
306 let x_i = mc.grid.x_at(i);
307 out.values[i] = apply_g4_at_node_cached(cache, prev, i, n, x_i, dx, dx_sq)?;
308 i += 1;
309 }
310 Ok(out.clone())
311}
312
313/// Dispatch 4-node lane kernel: AVX2 or NEON under `feature = "simd"`, the
314/// scalar mirror of the same arithmetic otherwise.
315///
316/// Interior invariant: `base >= 2` and `base + 5 <= n` — caller ensures this.
317#[allow(clippy::similar_names)]
318fn apply_g4_4nodes_simd(
319 base: usize,
320 prev: &GridFn1D<f64>,
321 cache: &HalfNodeCoeffCache,
322 dx_sq_inv: f64,
323 out: &mut GridFn1D<f64>,
324) {
325 // AVX2 path.
326 #[cfg(all(feature = "simd", target_arch = "x86_64", target_feature = "avx2"))]
327 apply_g4_stencil_avx2_4nodes(
328 base,
329 &prev.values,
330 &cache.ar3h,
331 &cache.ar1h,
332 &cache.al1h,
333 &cache.al3h,
334 dx_sq_inv,
335 &mut out.values,
336 );
337
338 // NEON path.
339 #[cfg(all(feature = "simd", target_arch = "aarch64", target_feature = "neon"))]
340 apply_g4_stencil_neon_4nodes(
341 base,
342 &prev.values,
343 &cache.ar3h,
344 &cache.ar1h,
345 &cache.al1h,
346 &cache.al3h,
347 dx_sq_inv,
348 &mut out.values,
349 );
350
351 // Scalar mirror for builds/arches without AVX2/NEON intrinsics.
352 #[cfg(not(all(
353 feature = "simd",
354 any(
355 all(target_arch = "x86_64", target_feature = "avx2"),
356 all(target_arch = "aarch64", target_feature = "neon")
357 )
358 )))]
359 apply_g4_4nodes_scalar_fallback(base, prev, cache, dx_sq_inv, out);
360}
361
362/// Pure-scalar 4-node G⁴ stencil — used when AVX2/NEON intrinsics are not compiled in.
363///
364/// Mirrors `avx2_g4_fluxes` / `neon_g4_lane_pair` operation for operation
365/// (`× 0.25`, `× (1/12)`, `× dx_sq_inv`, same addition order), so every build
366/// produces the same bits (ADR-0200).
367///
368/// Interior invariant: `base >= 2` and `base + 5 <= n` — caller ensures this.
369#[cfg(not(all(
370 feature = "simd",
371 any(
372 all(target_arch = "x86_64", target_feature = "avx2"),
373 all(target_arch = "aarch64", target_feature = "neon")
374 )
375)))]
376#[allow(clippy::similar_names)]
377fn apply_g4_4nodes_scalar_fallback(
378 base: usize,
379 prev: &GridFn1D<f64>,
380 cache: &HalfNodeCoeffCache,
381 dx_sq_inv: f64,
382 out: &mut GridFn1D<f64>,
383) {
384 let one_over_12 = 1.0 / 12.0;
385 for j in base..base + 4 {
386 // Interior invariant guarantees prev[j-2..j+3] are all in-bounds.
387 let rp2 = prev.values[j + 2];
388 let rp1 = prev.values[j + 1];
389 let ctr = prev.values[j];
390 let lm1 = prev.values[j - 1];
391 let lm2 = prev.values[j - 2];
392 let ar3h = cache.ar3h[j];
393 let ar1h = cache.ar1h[j];
394 let al1h = cache.al1h[j];
395 let al3h = cache.al3h[j];
396 let flux_right = ((5.0 * ar1h) * (rp1 - ctr)) * 0.25;
397 let flux_right_far = ((-ar3h) * (rp2 - rp1)) * one_over_12;
398 let flux_left = ((-5.0 * al1h) * (ctr - lm1)) * 0.25;
399 let flux_left_far = (al3h * (lm1 - lm2)) * one_over_12;
400 out.values[j] = (((flux_right_far + flux_right) + flux_left) + flux_left_far) * dx_sq_inv;
401 }
402}
403
404#[allow(clippy::similar_names, clippy::too_many_arguments)]
405fn apply_g4_at_node_cached(
406 cache: &HalfNodeCoeffCache,
407 prev: &GridFn1D<f64>,
408 i: usize,
409 n: usize,
410 x_i: f64,
411 dx: f64,
412 dx_sq: f64,
413) -> Result<f64, SemiflowError> {
414 let rp2 = if i + 2 < n {
415 prev.values[i + 2]
416 } else {
417 prev.sample(x_i + 2.0 * dx)?
418 };
419 let rp1 = if i + 1 < n {
420 prev.values[i + 1]
421 } else {
422 prev.sample(x_i + dx)?
423 };
424 let ctr = prev.values[i];
425 let lm1 = if i >= 1 {
426 prev.values[i - 1]
427 } else {
428 prev.sample(x_i - dx)?
429 };
430 let lm2 = if i >= 2 {
431 prev.values[i - 2]
432 } else {
433 prev.sample(x_i - 2.0 * dx)?
434 };
435
436 // Cache lookup — eliminates function-pointer dispatch.
437 let ar3h = cache.ar3h[i];
438 let ar1h = cache.ar1h[i];
439 let al1h = cache.al1h[i];
440 let al3h = cache.al3h[i];
441
442 let flux_right = 5.0 * ar1h * (rp1 - ctr) / 4.0;
443 let flux_right_far = -ar3h * (rp2 - rp1) / 12.0;
444 let flux_left = -5.0 * al1h * (ctr - lm1) / 4.0;
445 let flux_left_far = al3h * (lm1 - lm2) / 12.0;
446 Ok((flux_right_far + flux_right + flux_left + flux_left_far) / dx_sq)
447}