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
//! Genuine cotangent backward sweep for `ReverseChernoff` (§51.9, ADR-0156 Amdt 2).
//! K>1 extension via per-region dual seeding (§51.10, ADR-0177, issue #1).
//!
//! Split rationale: additive sibling to keep `reverse_ad.rs` ≤500 lines (constitution,
//! no carve-out). This module owns the genuine backward-sweep internals;
//! `reverse_ad` owns the public structs and the checkpoint forward pass.
//!
//! ## Algorithm (§51.9 — genuine reverse mode, generalised to K regions)
//!
//! 1. Forward pass with `⌈√n⌉` checkpoints; seed cotangent `λ_n = 2(u_n − target)`.
//! 2. For `k = n … 1` (STRICTLY DECREASING — reverse direction witness):
//! a. Replay `u_{k-1}` via `recompute_segment` (bit-exact from nearest checkpoint).
//! b. For each `r ∈ 0..K`: `grad[r] += ⟨λ_k, b_k^{(r)}⟩`
//! (K=1: byte-identical §51.9 path via single-seed `kernel_dual`;
//! K>1: per-region `step_jacobian_col_region`.)
//! c. Propagate cotangent: `λ_{k-1} = F^⊤ λ_k`.
//! K=1: `apply_transpose_step` (F = F^⊤ for constant-a, §51.5).
//! K>1: matrix-explicit F^⊤ via unit-vector probing (§51.10 ADR-0177 Amdt 1).
//! F is time-homogeneous; F^⊤ is built ONCE per `backward_sweep` call.
use alloc::{sync::Arc, vec, vec::Vec};
// `f64` has inherent math methods only when `std` is linked; otherwise they come
// from `num_traits::Float` (libm). Test builds link `std` even without the
// feature (harness, dev-dependencies), hence the `allow`.
#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float;
use crate::{
diffusion::DiffusionChernoff,
dual::Dual,
error::SemiflowError,
float::{from_f64, SemiflowFloat},
grid::Grid1D,
grid_fn::GridFn1D,
reverse_ad::{recompute_segment, step_jacobian_col, CheckpointSchedule, TransposeApply},
reverse_region::RegionMap,
};
// ---------------------------------------------------------------------------
// Dual grid builder (mirrors pattern in g_reverse_ad.rs)
// ---------------------------------------------------------------------------
/// Build a `Grid1D<Dual<F>>` matching the geometry of a `Grid1D<F>`.
fn dual_grid<F: SemiflowFloat>(g: &Grid1D<F>) -> Grid1D<Dual<F>> {
Grid1D::<Dual<F>>::new_generic(Dual::constant(g.xmin), Dual::constant(g.xmax), g.n)
.expect("dual grid from valid primal grid")
.with_interp(g.interp)
}
// ---------------------------------------------------------------------------
// Per-region Jacobian column (§51.10 — per-region dual seeding)
// ---------------------------------------------------------------------------
/// Compute `b_k^{(r)} = (∂F/∂θ_r)(u_{k-1})` for region `r`.
///
/// Per §51.10: builds a fresh `DiffusionChernoff<Dual<F>>` whose coefficient
/// closure returns `Dual::variable(θ_r)` on nodes `i ∈ Ω_r` (x near node i)
/// and `Dual::constant(θ_{ρ(i)})` elsewhere.
///
/// Const-per-region means `a'(x) = 0`, so sample positions `x_pre = x_i`
/// (no inner shift). The closure maps any queried x to the nearest grid node
/// to recover the region assignment.
///
/// # Errors
/// Returns `SemiflowError` if the dual kernel application fails.
/// Build per-node lookup tables for region dual seeding.
///
/// Returns `(is_in_r, theta_by_region)` where:
/// - `is_in_r[i]` = true if node i belongs to `region`
/// - `theta_by_region[i]` = θ_{ρ(i)} (primal value at node i)
fn region_lookup_tables<F: SemiflowFloat>(
theta: &[F],
rmap: &RegionMap,
region: usize,
) -> (Arc<Vec<bool>>, Arc<Vec<F>>) {
let n = rmap.n_grid();
let is_in_r = Arc::new((0..n).map(|i| rmap.region_of(i) == region).collect());
let tbr = Arc::new((0..n).map(|i| theta[rmap.region_of(i)]).collect());
(is_in_r, tbr)
}
fn step_jacobian_col_region<F: SemiflowFloat>(
primal: &DiffusionChernoff<F>,
tau: F,
u: &GridFn1D<F>,
theta: &[F],
rmap: &RegionMap,
region: usize,
) -> Result<Vec<F>, SemiflowError> {
let grid = primal.grid;
let n_nodes = grid.n;
let theta_r: F = theta[region];
let (is_in_r_a, tbr_a) = region_lookup_tables(theta, rmap, region);
// Grid geometry for node recovery inside the closure.
let xmin_f64 = grid.xmin.to_f64().unwrap_or(f64::NEG_INFINITY);
let xmax_f64 = grid.xmax.to_f64().unwrap_or(f64::INFINITY);
let n_minus1 = (n_nodes - 1).max(1);
// cast_precision_loss: n_minus1 < 2^52 in practice (grid size ≤ billions).
#[allow(clippy::cast_precision_loss)]
let dx_f64 = (xmax_f64 - xmin_f64) / n_minus1 as f64;
// Build per-region dual kernel. a'=a''=0 for const-per-region (§51.10 scope).
let kernel_dual = DiffusionChernoff::<Dual<F>>::with_closure(
move |x: Dual<F>| {
let xv = x.value.to_f64().unwrap_or(xmin_f64);
// cast_possible_truncation: `.round()` bounds the f64; clamp below guards sign.
// cast_sign_loss: clamp(0, …) ensures non-negative before usize cast.
// cast_possible_wrap: n_minus1 < isize::MAX (grid size limited in practice).
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
let raw = ((xv - xmin_f64) / dx_f64).round() as isize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
let idx = raw.clamp(0, n_minus1 as isize) as usize;
if is_in_r_a[idx] {
Dual::variable(theta_r)
} else {
Dual::constant(tbr_a[idx])
}
},
|_: Dual<F>| Dual::constant(F::zero()),
|_: Dual<F>| Dual::constant(F::zero()),
primal.a_norm_bound,
dual_grid(&primal.grid),
);
step_jacobian_col(&kernel_dual, Dual::constant(tau), u)
}
// ---------------------------------------------------------------------------
// backward_step_k1 — original K=1 path (byte-identical to §51.9)
// ---------------------------------------------------------------------------
/// One backward step for K=1: accumulate gradient + propagate cotangent.
///
/// Byte-identical to the §51.9 path — uses the same `kernel_dual` and
/// `step_jacobian_col` as the original implementation. LOAD-BEARING (§51.6).
#[allow(clippy::too_many_arguments)]
fn backward_step_k1<F: SemiflowFloat>(
kernel: &DiffusionChernoff<F>,
kernel_dual: &DiffusionChernoff<Dual<F>>,
tau: F,
tau_dual: Dual<F>,
u_prev: &GridFn1D<F>,
lambda: &mut Vec<F>,
grad: &mut [F],
) -> Result<(), SemiflowError> {
// (b) Accumulate K=1 gradient: ∇J[0] += ⟨λ_k, b_k^{(0)}⟩.
let b_k = step_jacobian_col(kernel_dual, tau_dual, u_prev)?;
let dot: F = lambda
.iter()
.zip(b_k.iter())
.fold(F::zero(), |acc, (&l, &b)| acc + l * b);
grad[0] += dot;
// (c) Propagate cotangent (LOAD-BEARING — §51.6 sub-check a).
let lambda_fn = GridFn1D {
values: lambda.clone(),
grid: kernel.grid,
};
let lambda_next = kernel.apply_transpose_step(tau, &lambda_fn)?;
*lambda = lambda_next.values;
Ok(())
}
// ---------------------------------------------------------------------------
// build_f_transpose — exact F^⊤ for piecewise-constant a (§51.10 Amdt 1)
// ---------------------------------------------------------------------------
/// Build the N×N transpose matrix of the GH step operator F via unit-vector probing.
///
/// For piecewise-constant a(x) with K>1 regions, F is NOT self-adjoint (non-Toeplitz).
/// This builds `F^T[j][i] = F[i][j]` by applying `F` to each unit basis vector `e_j`.
///
/// Result: `ft[j * n + i] = F[i][j]` stored row-major, where n = grid.n.
/// Cotangent propagation: `(F^⊤ λ)[j] = Σ_i ft[j * n + i] · λ_i`.
///
/// Cost: n `apply_f` calls. Built once per `backward_sweep` for K>1.
///
/// # Errors
/// Returns `SemiflowError` if any unit-vector kernel application fails.
fn build_f_transpose<F: SemiflowFloat>(
kernel: &DiffusionChernoff<F>,
tau: F,
) -> Result<Vec<F>, SemiflowError> {
let n = kernel.grid.n;
let mut ft = vec![F::zero(); n * n]; // ft[j * n + i] = F[i][j]
let mut e_j = GridFn1D {
values: vec![F::zero(); n],
grid: kernel.grid,
};
for j in 0..n {
// Set unit vector e_j.
if j > 0 {
e_j.values[j - 1] = F::zero();
}
e_j.values[j] = F::one();
// F · e_j gives column j of F (= row j of F^T).
let col_j = kernel.apply_f(tau, &e_j)?;
for i in 0..n {
// ft[j * n + i] = F[i][j] = col_j[i].
ft[j * n + i] = col_j.values[i];
}
}
// Reset last unit vector.
e_j.values[n - 1] = F::zero();
Ok(ft)
}
/// Apply precomputed F^⊤ (from `build_f_transpose`) to `lambda`, returning `F^⊤ λ`.
///
/// `ft[j * n + i] = F[i][j]`; `(F^⊤ λ)[j] = Σ_i ft[j * n + i] * λ[i]`.
fn apply_f_transpose<F: SemiflowFloat>(ft: &[F], lambda: &[F], n: usize) -> Vec<F> {
(0..n)
.map(|j| {
let row = &ft[j * n..(j + 1) * n];
row.iter()
.zip(lambda.iter())
.fold(F::zero(), |acc, (&ftji, &li)| acc + ftji * li)
})
.collect()
}
// ---------------------------------------------------------------------------
// backward_step_kvec — K>1 per-region accumulation
// ---------------------------------------------------------------------------
/// One backward step for K>1: per-region accumulation + exact F^⊤ cotangent propagate.
///
/// For each `r ∈ 0..K`: `grad[r] += ⟨λ_k, b_k^{(r)}⟩` via per-region seeding.
/// Then propagates `λ_{k-1} = F^⊤ λ_k` using precomputed transpose matrix. (§51.10)
#[allow(clippy::too_many_arguments)]
fn backward_step_kvec<F: SemiflowFloat>(
kernel: &DiffusionChernoff<F>,
tau: F,
u_prev: &GridFn1D<F>,
lambda: &mut Vec<F>,
grad: &mut [F],
theta: &[F],
rmap: &RegionMap,
ft: &[F],
) -> Result<(), SemiflowError> {
let k = rmap.region_count();
let n = kernel.grid.n;
// (b) Per-region gradient accumulation.
// `r` is passed as region index to step_jacobian_col_region, so iter() is not applicable.
#[allow(clippy::needless_range_loop)]
for r in 0..k {
let b_kr = step_jacobian_col_region(kernel, tau, u_prev, theta, rmap, r)?;
let dot: F = lambda
.iter()
.zip(b_kr.iter())
.fold(F::zero(), |acc, (&l, &b)| acc + l * b);
grad[r] += dot;
}
// (c) Propagate cotangent via exact F^⊤ (LOAD-BEARING for K>1).
*lambda = apply_f_transpose(ft, lambda, n);
Ok(())
}
// ---------------------------------------------------------------------------
// backward_sweep — generalised K-vector cotangent backward sweep
// ---------------------------------------------------------------------------
/// Compute `∂J/∂θ` (length K) via the genuine cotangent backward sweep.
///
/// K=1: routes through the **byte-identical** §51.9 path (structural early branch).
/// K>1: routes through §51.10 per-region seeding.
///
/// # Errors
/// Propagates `SemiflowError` from kernel or recompute applications.
#[allow(clippy::too_many_arguments)]
pub(crate) fn backward_sweep<F: SemiflowFloat>(
kernel: &DiffusionChernoff<F>,
kernel_dual: &DiffusionChernoff<Dual<F>>,
tau: F,
u_n: &GridFn1D<F>,
target: &GridFn1D<F>,
checkpoints: &[GridFn1D<F>],
schedule: &CheckpointSchedule,
theta: &[F],
rmap: &RegionMap,
) -> Result<Vec<F>, SemiflowError> {
let (mut lambda, mut grad, ft_kvec) = init_sweep_state(kernel, tau, u_n, target, rmap)?;
let fwd = |s: F, u: &GridFn1D<F>| kernel.apply_f(s, u);
let tau_dual = Dual::constant(tau);
let n = schedule.n_steps;
let stride = schedule.stride;
let k = rmap.region_count();
// ── 2. Backward loop k = n … 1 (STRICTLY DECREASING) ─────────────────
for step in (1..=n).rev() {
let base = ((step - 1) / stride) * stride;
let ck_idx = base / stride;
let seg = recompute_segment(&fwd, tau, &checkpoints[ck_idx], base, step - 1)?;
let u_prev = seg.last().expect("recompute_segment is non-empty");
if k == 1 {
// K=1 BYTE-IDENTICAL PATH (§51.9 — structural early branch).
backward_step_k1(
kernel,
kernel_dual,
tau,
tau_dual,
u_prev,
&mut lambda,
&mut grad,
)?;
} else {
// K>1 PER-REGION PATH (§51.10) with exact F^⊤.
let ft = ft_kvec.as_deref().expect("ft built for K>1");
backward_step_kvec(kernel, tau, u_prev, &mut lambda, &mut grad, theta, rmap, ft)?;
}
}
Ok(grad)
}
/// Initialise cotangent vector, gradient accumulator, and optional F^⊤ matrix.
///
/// Returns `(lambda, grad, ft_kvec)`. `ft_kvec` is `Some` only when K>1.
///
/// # Errors
/// Returns `SemiflowError` if F^⊤ construction fails.
// (Vec<F>, Vec<F>, Option<Vec<F>>) = (lambda, grad, ft_kvec) — 3-tuple is simpler than a struct.
#[allow(clippy::type_complexity)]
fn init_sweep_state<F: SemiflowFloat>(
kernel: &DiffusionChernoff<F>,
tau: F,
u_n: &GridFn1D<F>,
target: &GridFn1D<F>,
rmap: &RegionMap,
) -> Result<(Vec<F>, Vec<F>, Option<Vec<F>>), SemiflowError> {
let two = from_f64::<F>(2.0);
let n_vals = u_n.values.len();
let k = rmap.region_count();
// Seed cotangent λ_n = 2(u_n − target).
let lambda: Vec<F> = (0..n_vals)
.map(|i| two * (u_n.values[i] - target.values[i]))
.collect();
let grad = vec![F::zero(); k];
// For K>1: build exact F^⊤ via unit-vector probing (once per sweep).
// F is time-homogeneous so it is reused across all n steps.
let ft_kvec = if k > 1 {
Some(build_f_transpose(kernel, tau)?)
} else {
None
};
Ok((lambda, grad, ft_kvec))
}