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
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
//! [`ReverseChernoff`] — genuine reverse-mode AD over `(F(τ))ⁿ` via binomial
//! checkpointing (math §51, ADR-0156 Amendment 2; v9.1.0 Shift B GENUINE).
//!
//! Scope (NARROW, §51.5): linear / truncated-Magnus family ONLY.
//! For constant-a `DiffusionChernoff`, `F = F^⊤` (self-adjoint), so the
//! transpose step is the forward step. Variable-coefficient and nonlinear
//! kernels are OUT of scope.
//!
//! Forward pass: n steps, checkpoint every `⌈√n⌉` states (`O(√n)` peak mem).
//! K-vector gradient: GENUINE cotangent backward sweep (§51.9/§51.10) wired as the public path.
//! `value_and_grad(τ, n, u₀, target, θ ∈ ℝ^K)` accepts K≥1 parameters via region partition.
//! `value_and_grad_k1` is a thin K=1 wrapper (byte-identical to §51.9).
//! Forward-mode `Dual<F>` (§46) is retained ONLY as the `< 1e-12` parity reference
//! (§51.4 Amendment 2 — NOT 0 ULP; two independent float paths agree by adjoint identity).
//!
//! Zero new runtime deps — checkpointing is a `Vec<GridFn1D<F>>` under alloc.
//! Backward sweep helpers live in `reverse_sweep.rs` (additive split, ≤500 lines).
//!
//! References: Griewank-Walther ACM TOMS 2000; math §42/§43/§46/§51; ADR-0156.
// sqrt(n_steps).ceil().max(1.0) as usize: result is always >= 1.0 so non-negative cast is safe.
#![allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
use alloc::vec::Vec;
// Backward sweep internals live in the sibling crate-root module `reverse_sweep`
// (additive split — keeps this file ≤500 lines; declared in lib.rs).
// `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::reverse_sweep::backward_sweep;
use crate::{
diffusion::DiffusionChernoff, dual::Dual, error::SemiflowError, float::SemiflowFloat,
grid_fn::GridFn1D, reverse_region::RegionMap,
};
// ---------------------------------------------------------------------------
// Checkpoint schedule — §51.3 (binomial √n default, Griewank-Walther)
// ---------------------------------------------------------------------------
/// Checkpoint schedule for `O(√n)` memory in the backward pass.
///
/// Stores every `stride`-th state. `stride = ⌈√n⌉` gives `O(√n)` checkpoints
/// and `O(n)` recompute steps (Griewank-Walther revolve, §51.3/§51.7).
#[derive(Clone, Debug)]
pub struct CheckpointSchedule {
/// Checkpoint stride: store a state every `stride` forward steps.
pub stride: usize,
/// Total number of steps `n` this schedule was built for.
pub n_steps: usize,
}
impl CheckpointSchedule {
/// Build the default `⌈√n⌉` binomial schedule for `n` steps (§51.7).
#[must_use]
pub fn sqrt_n(n_steps: usize) -> Self {
let stride = (n_steps as f64).sqrt().ceil().max(1.0) as usize;
Self { stride, n_steps }
}
/// Number of checkpoints = `(n − 1) / stride + 1` (step-0 always stored).
///
/// Returns 1 for `n_steps == 0`.
#[must_use]
pub fn checkpoint_count(&self) -> usize {
if self.n_steps == 0 {
return 1;
}
(self.n_steps - 1) / self.stride + 1
}
}
// ---------------------------------------------------------------------------
// TransposeApply<F> — opt-in trait for exact transpose step (§51.5 NARROW)
// ---------------------------------------------------------------------------
/// Opt-in trait: the implementor provides the exact transpose step `F^⊤`.
///
/// **NORMATIVE (§51.5):** Transpose-exactness is proven only for the linear /
/// truncated-Magnus family. Implementors MUST have established algebraic
/// exactness before opting in.
///
/// For constant-a `DiffusionChernoff`, `F` is self-adjoint (`F^⊤ = F`),
/// so `apply_transpose` delegates to `apply_f`. This is the canonical
/// narrow-scope implementation used by the §51.6 gates.
pub trait TransposeApply<F: SemiflowFloat>: Sized {
/// Apply `F^⊤` (exact transpose step) to `src`, writing result into a new state.
///
/// For symmetric kernels: identical to the forward `apply_f`.
/// For non-symmetric kernels: must negate the shift direction per §51.2.
///
/// # Errors
/// Propagates `SemiflowError` from the underlying kernel.
fn apply_transpose_step(&self, tau: F, src: &GridFn1D<F>)
-> Result<GridFn1D<F>, SemiflowError>;
}
/// `DiffusionChernoff<F>` with constant `a(x) ≡ θ` is self-adjoint:
/// `F^⊤ = F`, so `apply_transpose_step` delegates to `apply_f` (§51.5, narrow scope).
///
/// This satisfies the §51.6 anti-dodge clause: the gate kernel MUST run on
/// the default `Grid1D::new` (`SepticHermite`) grid.
impl<F: SemiflowFloat> TransposeApply<F> for DiffusionChernoff<F> {
fn apply_transpose_step(
&self,
tau: F,
src: &GridFn1D<F>,
) -> Result<GridFn1D<F>, SemiflowError> {
// For constant-a: F is symmetric (no inner Strang shift term), so F^⊤ = F.
// NORMATIVE (§51.5): this impl is valid ONLY for the constant-a case.
// Variable-a usage is caller's responsibility (document OUT of scope).
self.apply_f(tau, src)
}
}
// ---------------------------------------------------------------------------
// Forward pass with checkpointing (§51.3)
// ---------------------------------------------------------------------------
/// Run `n` forward steps, storing every `stride`-th state as a checkpoint.
///
/// Returns `(u_n, checkpoints)` where `checkpoints[j]` = state at step
/// `j * stride`. Step-0 is always stored. Peak: `O(√n · |state|)` vecs.
///
/// # Errors
/// Returns [`SemiflowError`] if any kernel application fails.
pub fn forward_with_checkpoints<F, C>(
kernel: &C,
tau: F,
u0: &GridFn1D<F>,
n: usize,
schedule: &CheckpointSchedule,
) -> Result<(GridFn1D<F>, Vec<GridFn1D<F>>), SemiflowError>
where
F: SemiflowFloat,
C: Fn(F, &GridFn1D<F>) -> Result<GridFn1D<F>, SemiflowError>,
{
let cap = schedule.checkpoint_count() + 1;
let mut checkpoints: Vec<GridFn1D<F>> = Vec::with_capacity(cap);
let mut u = u0.clone();
checkpoints.push(u.clone()); // step-0 checkpoint
for k in 1..=n {
u = kernel(tau, &u)?;
// Store checkpoint after step k if k is a stride boundary (not last step).
if k < n && k % schedule.stride == 0 {
checkpoints.push(u.clone());
}
}
Ok((u, checkpoints))
}
// ---------------------------------------------------------------------------
// Segment recompute (§51.3 — replay for backward sweep)
// ---------------------------------------------------------------------------
/// Recompute forward states `u_{from}, …, u_{to}` from checkpoint `ck`.
/// Used by the backward sweep (§51.9) and by `G_REVERSE_AD_STRUCTURE` oracle.
///
/// `ck` must be the state at step `from`. Returns `to − from + 1` states.
///
/// # Errors
/// Returns [`SemiflowError`] if any kernel application fails.
pub fn recompute_segment<F, C>(
kernel: &C,
tau: F,
ck: &GridFn1D<F>,
from: usize,
to: usize,
) -> Result<Vec<GridFn1D<F>>, SemiflowError>
where
F: SemiflowFloat,
C: Fn(F, &GridFn1D<F>) -> Result<GridFn1D<F>, SemiflowError>,
{
debug_assert!(to >= from, "recompute_segment: to < from");
let len = to - from + 1;
let mut seg: Vec<GridFn1D<F>> = Vec::with_capacity(len);
let mut u = ck.clone();
seg.push(u.clone()); // state at `from`
for _ in (from + 1)..=to {
u = kernel(tau, &u)?;
seg.push(u.clone());
}
Ok(seg)
}
// ---------------------------------------------------------------------------
// Per-step Jacobian column via Dual forward mode (§46)
// ---------------------------------------------------------------------------
/// Compute `b_k^{(p)} = (∂F/∂θ_p)(u_{k-1})` — transport-complete parameter
/// gradient column (§51.9, ADR-0156 Amendment 2, REPAIRED).
///
/// NORMATIVE seeding: `kernel_dual` seeds `∂/∂θ_p` in its coefficient closure
/// (e.g. `a(x)=Dual::variable(θ)`). `tau_dual` MUST be `Dual::constant(τ)` —
/// τ carries NO tangent (Amendment 2 repair: old stub seeded `∂/∂τ`, WRONG).
/// State is lifted with ZERO tangent (held fixed). Output `.tangent` =
/// `b_k^{(p)}` including sample-position transport differentiated by dual.
///
/// LOAD-BEARING on the public backward-sweep path (§51.6 `G_REVERSE_AD_STRUCTURE`).
///
/// # Errors
/// Returns [`SemiflowError`] if the dual kernel application fails.
pub fn step_jacobian_col<F: SemiflowFloat>(
kernel_dual: &DiffusionChernoff<Dual<F>>,
tau_dual: Dual<F>,
u: &GridFn1D<F>,
) -> Result<Vec<F>, SemiflowError> {
let dual_grid = kernel_dual.grid;
// Zero state tangent: the state is held fixed; only θ_p varies.
let u_dual = GridFn1D {
values: u.values.iter().map(|&v| Dual::constant(v)).collect(),
grid: dual_grid,
};
let out = kernel_dual.apply_f(tau_dual, &u_dual)?;
Ok(out.values.iter().map(|d| d.tangent).collect())
}
// ---------------------------------------------------------------------------
// Loss and cotangent init
// ---------------------------------------------------------------------------
/// L² discrete loss `‖u_n − target‖²`.
fn compute_loss<F: SemiflowFloat>(u: &GridFn1D<F>, target: &GridFn1D<F>) -> F {
u.values
.iter()
.zip(target.values.iter())
.fold(F::zero(), |acc, (&a, &b)| {
let d = a - b;
acc + d * d
})
}
// ---------------------------------------------------------------------------
// ReverseChernoff — main public wrapper
// ---------------------------------------------------------------------------
/// Reverse-mode AD over the Chernoff product `(F_θ(τ))ⁿ u₀` for loss
/// `J(θ) = ‖(F_θ(τ))ⁿ u₀ − target‖²`.
///
/// Generic over `F: SemiflowFloat`. Wraps a `DiffusionChernoff<F>` +
/// `DiffusionChernoff<Dual<F>>` pair plus a [`RegionMap`] (K regions).
///
/// `value_and_grad` accepts `theta ∈ ℝ^K` for K ≥ 1 (ADR-0177).
/// K=1 path is byte-identical to the §51.9 scalar path (regression guarantee).
///
/// # Scope (NARROW — §51.5/§51.10)
///
/// Linear / truncated-Magnus family with const-per-region `a` ONLY.
/// Variable-coefficient within a region and nonlinear kernels are out of scope.
pub struct ReverseChernoff<F: SemiflowFloat = f64> {
/// Forward kernel `F_θ(τ)` at primal type `F`.
pub kernel: DiffusionChernoff<F>,
/// Same kernel at `Dual<F>` for per-step Jacobian (§46) — K=1 path only.
pub kernel_dual: DiffusionChernoff<Dual<F>>,
/// Checkpoint schedule (default: `√n` binomial, §51.3).
pub schedule: CheckpointSchedule,
/// Region partition for K-vector reverse-AD (§51.10, ADR-0177).
///
/// K=1 default: single region spanning all nodes (byte-identical to §51.9).
region_map: RegionMap,
}
impl<F: SemiflowFloat> ReverseChernoff<F> {
/// Construct from kernel pair and checkpoint schedule (K=1 default region map).
///
/// Use `CheckpointSchedule::sqrt_n(n)` for the `O(√n)` default (§51.3/§51.7).
/// The region map defaults to K=1 (whole domain is one region). To use K>1
/// per-region gradients, call [`Self::with_region_map`] after construction.
///
/// # Panics
/// Panics if the kernel's grid node count is zero (should never happen for
/// valid grids with n ≥ 4).
#[must_use]
pub fn new(
kernel: DiffusionChernoff<F>,
kernel_dual: DiffusionChernoff<Dual<F>>,
schedule: CheckpointSchedule,
) -> Self {
let n_grid = kernel.grid.n;
let region_map =
RegionMap::contiguous(n_grid, 1).expect("K=1 region map always valid for n >= 1");
Self {
kernel,
kernel_dual,
schedule,
region_map,
}
}
/// Replace the region map (enables K>1 per-region reverse-AD, §51.10).
///
/// `rmap.region_count()` determines the length of the gradient returned by
/// `value_and_grad`. `rmap.n_grid()` must equal `kernel.grid.n`.
///
/// # Errors
/// Returns `SemiflowError::UnsupportedOperation` if grid sizes do not match.
pub fn with_region_map(mut self, rmap: RegionMap) -> Result<Self, SemiflowError> {
if rmap.n_grid() != self.kernel.grid.n {
return Err(SemiflowError::UnsupportedOperation {
what: "ReverseChernoff::with_region_map: rmap.n_grid() != kernel.grid.n",
});
}
self.region_map = rmap;
Ok(self)
}
/// Compute `(J, ∂J/∂θ)` for a **K-parameter** vector in ONE backward pass.
///
/// Accepts `theta.len() == region_map.region_count()` (ADR-0177).
/// K=1: byte-identical to §51.9 (structural early branch in sweep).
/// K>1: per-region dual seeding (§51.10) — genuinely distinct gradients
/// in a single O(1)-in-K backward pass.
///
/// Out-of-scope cases (variable-a within region, non-DoF-aligned, non-self-adjoint)
/// remain fail-loud via `SemiflowError::UnsupportedOperation`.
///
/// # Errors
/// - `SemiflowError::UnsupportedOperation` if `theta.len() != region_map.region_count()`.
/// - Propagates `SemiflowError` from any kernel application.
pub fn value_and_grad(
&self,
tau: F,
n: usize,
u0: &GridFn1D<F>,
target: &GridFn1D<F>,
theta: &[F],
) -> Result<(F, Vec<F>), SemiflowError> {
// ADR-0177: validate theta length matches region count.
if theta.len() != self.region_map.region_count() {
return Err(SemiflowError::UnsupportedOperation {
what: "value_and_grad: theta.len() must equal region_map.region_count() \
(ADR-0177); use with_region_map to set K regions, or \
value_and_grad_k1 for the scalar K=1 path",
});
}
let fwd = |t: F, u: &GridFn1D<F>| self.kernel.apply_f(t, u);
let (u_n, checkpoints) = forward_with_checkpoints(&fwd, tau, u0, n, &self.schedule)?;
let loss = compute_loss(&u_n, target);
let grad = backward_sweep(
&self.kernel,
&self.kernel_dual,
tau,
&u_n,
target,
&checkpoints,
&self.schedule,
theta,
&self.region_map,
)?;
Ok((loss, grad))
}
/// Compute `(J, ∂J/∂θ)` for scalar parameter `K = 1`.
///
/// **Thin wrapper** around [`Self::value_and_grad`] with `theta = &[0.0]`
/// (length-1 slice — actual θ encoded in `kernel_dual`'s closure).
/// K=1 routes through the SAME genuine cotangent backward sweep — NO forward
/// shortcut (§51.9 normative, ADR-0156 Amendment 2).
///
/// The gradient is byte-identical to the original §51.9 implementation
/// (structural K=1 branch in `backward_sweep`).
///
/// # Errors
/// Propagates `SemiflowError` from any kernel application.
pub fn value_and_grad_k1(
&self,
tau: F,
n: usize,
u0: &GridFn1D<F>,
target: &GridFn1D<F>,
) -> Result<(F, F), SemiflowError> {
// K=1: placeholder theta slice — actual θ-seed encoded in kernel_dual's closure.
let placeholder = [F::zero()];
let (loss, grads) = self.value_and_grad(tau, n, u0, target, &placeholder)?;
Ok((loss, grads[0]))
}
}
// ---------------------------------------------------------------------------
// Unit tests (§51 fast unit tests — no slow-tests gate)
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "reverse_ad_tests.rs"]
mod tests;