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
//! TT-Chernoff evolver — tensor-train Chernoff semigroup (math §50, §52.2 Amd 1).
//!
//! Escapes the exponential curse for linear diagonal-A (Gaussian) diffusion:
//! state `u_T = e^{TL}u_0` stored as TT at cost `O(d·n·r²)`, poly in `d`.
//!
//! # Method (P2, v9.1.0 — cubic-Lagrange band-split shift)
//!
//! Per-axis shift uses `Sₕ = Σ_m c_m(t)·P_{s₀+m}` (w=4, §52.2 Amd 1):
//! each band is a permutation (QTT-rank≤2), 4-band sum rounds to rank 3 constant
//! in L and d (`T_TT_BAND_SHIFT_RANK` / `scripts/tt_band_shift_kit.py`).
//! Convergence O(τ²) to the true semigroup (TRIZ §5.1, §6.2).
//!
//! # Scope (MANDATORY — honesty constraint)
//!
//! - **Implemented**: constant diagonal-A, scalar drift/reaction (Gaussian class).
//! Rank bounded d/2 (Rohrbach 2022).
//! - **Research track**: off-diagonal A, variable/nonlinear coefs — rank uncapped.
//!
//! # Curse-escape (Gaussian class)
//!
//! Rank-1 IC + local coupling: r=1 (Strang⊗). Dense Cauchy: r≤d/2, O(d³n).
//! Exponential curse n^d escaped. Only r~c^d re-curses (not algebraically bounded).
//!
//! References: Oseledets 2011; Kazeev–Khoromskij 2012; Rohrbach 2022; math §50/§52.
// Grid/TT indices (usize) cast to f64/isize for coordinates; TT sizes ≪ 2^52 and ≪ isize::MAX.
#![allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
extern crate alloc;
use alloc::{vec, vec::Vec};
use crate::{
float::SemiflowFloat,
tt_core::{tt_round, TtCore},
};
// ═══════════════════════════════════════════════════════════════════════════
// §A — TtState<F>
// ═══════════════════════════════════════════════════════════════════════════
/// Tensor-train state `u(i₁,…,i_d) = G₁[i₁] · G₂[i₂] · … · G_d[i_d]`.
///
/// Each core `G_k` has shape `r_{k-1} × n_k × r_k` (boundary: `r_0 = r_d = 1`).
/// Storage: `O(d · n · r²)` where `r = max_k r_k` is the peak bond rank.
///
/// ## Scope note
/// This type targets the linear diagonal-A Gaussian diffusion class. For that
/// class, `r` is bounded poly-in-d and the exponential curse is escaped.
#[derive(Clone, Debug)]
pub struct TtState<F: SemiflowFloat> {
/// One core per mode dimension.
pub cores: Vec<TtCore<F>>,
}
impl<F: SemiflowFloat> TtState<F> {
/// Number of modes (dimensions).
#[must_use]
pub fn ndim(&self) -> usize {
self.cores.len()
}
/// Mode size for axis `j` (number of grid nodes).
#[must_use]
pub fn n_j(&self, j: usize) -> usize {
self.cores[j].n
}
/// Peak bond rank (max over all internal bonds).
#[must_use]
pub fn peak_rank(&self) -> usize {
self.cores.iter().map(|c| c.r_right).max().unwrap_or(1)
}
/// Total number of stored scalars (working-set size).
#[must_use]
pub fn storage_size(&self) -> usize {
self.cores.iter().map(|c| c.data.len()).sum()
}
/// Construct from a rank-1 separable initial condition.
///
/// `u(i₁,…,i_d) = f₁(i₁) · f₂(i₂) · … · f_d(i_d)`, stored exactly as
/// rank-1 TT (each core is `1 × n_j × 1`, data = the 1D slice `f_j`).
///
/// This is the common initial condition for diagonal-A diffusion and the
/// lower edge of the TT construction (reduces to Strang⊗ at rank-1).
#[must_use]
pub fn rank1_separable(slices: Vec<Vec<F>>) -> Self {
let cores = slices
.into_iter()
.map(|f| {
let n = f.len();
let mut core = TtCore::zeros(1, n, 1);
for (im, &v) in f.iter().enumerate() {
core.set(0, im, 0, v);
}
core
})
.collect();
Self { cores }
}
/// Contract the TT against a separable functional `⟨f, u⟩` where
/// `f(i₁,…,i_d) = f₁(i₁) · f₂(i₂) · … · f_d(i_d)`.
///
/// Cost: `O(d · n · r²)` — polynomial in d.
///
/// ## Algorithm
/// Left-to-right contraction: `η_k = η_{k-1} · (G_k contracted with f_k)`.
/// Each step: `η_k[j] = Σ_{i, α} η_{k-1}[α] · G_k[α, i, j] · f_k[i]`
/// → shape `r_{k-1}` → `r_k`. Final: scalar.
///
/// # Panics
/// Panics if `functionals.len() != self.ndim()` or any functional length mismatches.
#[must_use]
pub fn inner_separable(&self, functionals: &[Vec<F>]) -> F {
assert_eq!(functionals.len(), self.ndim());
// η starts as 1×1 identity (left boundary r=1)
let mut eta: Vec<F> = vec![F::one()];
for (k, (core, fk)) in self.cores.iter().zip(functionals.iter()).enumerate() {
assert_eq!(fk.len(), core.n, "functional length mismatch at axis {k}");
eta = contract_step(core, fk, &eta);
}
assert_eq!(eta.len(), 1);
eta[0]
}
}
/// One contraction step: `eta_new[j] = Σ_{i,α} eta_old[α] · core[α,i,j] · f[i]`.
fn contract_step<F: SemiflowFloat>(core: &TtCore<F>, f: &[F], eta: &[F]) -> Vec<F> {
let r_left = core.r_left;
let r_right = core.r_right;
debug_assert_eq!(eta.len(), r_left);
let mut eta_new = vec![F::zero(); r_right];
for (j, slot) in eta_new.iter_mut().enumerate() {
let mut s = F::zero();
for (alpha, &ea) in eta.iter().enumerate() {
for (i, &fi) in f.iter().enumerate() {
s += ea * core.get(alpha, i, j) * fi;
}
}
*slot = s;
}
eta_new
}
// ═══════════════════════════════════════════════════════════════════════════
// §B — TtChernoff evolver
// ═══════════════════════════════════════════════════════════════════════════
/// TT-Chernoff evolver for linear diagonal-A diffusion (Gaussian class).
///
/// Evolves [`TtState<F>`] by the Chernoff product formula (eq. 50.3 / math §50):
/// for each axis `j`, applies the 3-branch shift:
/// `G_j ← ¼·shift(+h_j)·G_j + ¼·shift(-h_j)·G_j + ½·G_j`
/// where `h_j = 2√(a_j·τ)` (the Chernoff step size on axis `j`).
/// Drift `b_j` shifts the grid points: effective `x → x + b_j·τ`.
/// Reaction `c` scales the whole state: `u → (1 + τ·c)·u`.
///
/// After each full axis sweep: TT-rounding at tolerance `eps_round` compresses
/// bond ranks back to the intrinsic rank of the state.
///
/// ## Scope (MANDATORY)
/// Constant diagonal `a`, `b`, scalar `c`. Variable / off-diagonal A: not supported.
///
/// ## Grid convention
/// Axis `j` has `n_j` uniformly spaced nodes at `x_k = x_min_j + k·dx_j`,
/// `dx_j = (x_max_j - x_min_j) / (n_j - 1)`.
/// The shift-by-`h_j` is implemented as a **periodic index shift** on the core
/// (wrap-around boundary), consistent with the TT-operator rank-1 structure.
/// For smooth, decaying initial conditions the wrap-around error is negligible
/// for sufficiently large domain.
#[derive(Clone, Debug)]
pub struct TtChernoff<F: SemiflowFloat> {
/// Per-axis diffusion coefficients `a_j` ≥ 0.
pub a: Vec<F>,
/// Per-axis drift coefficients `b_j`.
pub b: Vec<F>,
/// Scalar reaction `c`.
pub c: F,
/// Per-axis domain bounds: `(x_min_j, x_max_j)`.
pub domain: Vec<(F, F)>,
/// TT-rounding tolerance.
pub eps_round: F,
}
impl<F: SemiflowFloat> TtChernoff<F> {
/// Construct with per-axis `a`, `b`, scalar `c`, domain bounds, rounding eps.
///
/// # Panics
/// Panics if `b.len()` or `domain.len()` differs from `a.len()`.
#[must_use]
pub fn new(a: Vec<F>, b: Vec<F>, c: F, domain: Vec<(F, F)>, eps_round: F) -> Self {
let d = a.len();
assert_eq!(b.len(), d);
assert_eq!(domain.len(), d);
Self {
a,
b,
c,
domain,
eps_round,
}
}
/// Number of axes.
pub fn ndim(&self) -> usize {
self.a.len()
}
/// Apply one Chernoff step of size `tau` to `state` (in-place).
///
/// 1. For each axis j: apply the 3-branch shift+drift to core j.
/// 2. Apply global reaction scalar `(1 + tau*c)` to the first core.
/// 3. TT-round all bonds at `eps_round`.
pub fn step(&self, tau: F, state: &mut TtState<F>) {
debug_assert_eq!(state.ndim(), self.ndim());
for j in 0..self.ndim() {
let n_j = state.cores[j].n;
let dx_j = self.dx(j, n_j);
let h_j = two::<F>() * (self.a[j] * tau).sqrt(); // shift distance
let drift_shift = self.b[j] * tau; // total drift displacement
apply_per_axis_shift(&mut state.cores[j], h_j + drift_shift, dx_j, n_j);
}
// Global reaction: scale first core
let reaction = F::one() + tau * self.c;
for v in &mut state.cores[0].data {
*v *= reaction;
}
// TT-rounding
tt_round(&mut state.cores, self.eps_round);
}
/// Evolve `state` for time `T` using `n_steps` Chernoff steps.
///
/// # Panics
/// Panics if `n_steps < 1`.
pub fn evolve(&self, t_final: F, n_steps: usize, state: &mut TtState<F>) {
assert!(n_steps >= 1);
let tau = t_final / F::from(n_steps).unwrap();
for _ in 0..n_steps {
self.step(tau, state);
}
}
/// Grid spacing for axis `j` with `n` nodes.
fn dx(&self, j: usize, n: usize) -> F {
let (xmin, xmax) = self.domain[j];
if n <= 1 {
return F::one();
}
(xmax - xmin) / F::from(n - 1).unwrap()
}
}
/// Cubic-Lagrange weights for nodes `{−1,0,1,2}` at abscissa `t = h/dx − ⌊h/dx⌋`.
/// Proved sum-to-1 + degree-≤3 reproduction by `T_TT_BAND_SHIFT_RANK` (§52.2 Amd 1).
#[inline]
fn cubic_band_weights<F: SemiflowFloat>(t: F) -> [F; 4] {
let t1 = t - F::one();
let t2 = t - F::from(2.0).unwrap();
let tp1 = t + F::one();
let sixth = F::from(1.0 / 6.0).unwrap();
let half = F::from(0.5).unwrap();
[
-(t * t1 * t2) * sixth, // c_{-1}
(tp1 * t1 * t2) * half, // c_0
-(tp1 * t * t2) * half, // c_1
(tp1 * t * t1) * sixth, // c_2
]
}
/// 3-branch Chernoff shift via cubic-Lagrange band-split (w=4, §52.2 Amd 1).
///
/// `G_new = ¼·S_{+h}·G + ¼·S_{−h}·G + ½·G`, where
/// `Sₕ = Σ_{m∈{−1,0,1,2}} c_m(t)·P_{s₀+m}`, `s₀=⌊h/dx⌋`, `t=h/dx−s₀`.
/// Fixed summation order m=−1,0,1,2 for 0-ULP determinism (ADR-0018).
/// No SIMD path — arithmetic-only loop.
#[allow(clippy::similar_names)]
fn apply_per_axis_shift<F: SemiflowFloat>(core: &mut TtCore<F>, h: F, dx: F, n: usize) {
if dx <= F::zero() || h == F::zero() {
return;
}
let ratio = h / dx;
let s0 = ratio.floor().to_isize().unwrap_or(0);
let t_frac = ratio - F::from(s0 as f64).unwrap();
let wf = cubic_band_weights(t_frac);
// Negative shift: −h
let ratio_neg = -ratio;
let s0_neg = ratio_neg.floor().to_isize().unwrap_or(0);
let t_neg = ratio_neg - F::from(s0_neg as f64).unwrap();
let wb = cubic_band_weights(t_neg);
let w_half = F::from(0.5).unwrap();
let w_qtr = F::from(0.25).unwrap();
let r_left = core.r_left;
let r_right = core.r_right;
let mut new_data = vec![F::zero(); r_left * n * r_right];
// Band offsets for +h: m ∈ {−1, 0, 1, 2}
let fwd_offsets: [isize; 4] = [s0 - 1, s0, s0 + 1, s0 + 2];
// Band offsets for −h: m ∈ {−1, 0, 1, 2}
let bwd_offsets: [isize; 4] = [s0_neg - 1, s0_neg, s0_neg + 1, s0_neg + 2];
let n_i = n as isize;
for il in 0..r_left {
for i in 0..n {
let ii = i as isize;
for ir in 0..r_right {
// ½ identity
let mut v = w_half * core.get(il, i, ir);
// ¼ S_{+h} — fixed order m = −1,0,1,2
for (m, &off) in fwd_offsets.iter().enumerate() {
let idx = ((ii + off).rem_euclid(n_i)) as usize;
v += w_qtr * wf[m] * core.get(il, idx, ir);
}
// ¼ S_{−h} — fixed order m = −1,0,1,2
for (m, &off) in bwd_offsets.iter().enumerate() {
let idx = ((ii + off).rem_euclid(n_i)) as usize;
v += w_qtr * wb[m] * core.get(il, idx, ir);
}
new_data[il * n * r_right + i * r_right + ir] = v;
}
}
}
core.data = new_data;
}
// ═══════════════════════════════════════════════════════════════════════════
// §D — Helpers
// ═══════════════════════════════════════════════════════════════════════════
#[inline]
fn two<F: SemiflowFloat>() -> F {
F::from(2.0).unwrap()
}
// ═══════════════════════════════════════════════════════════════════════════
// §E — pub(crate) re-exports for tt_coupled.rs (additive sibling)
// ═══════════════════════════════════════════════════════════════════════════
/// Re-export for `tt_coupled.rs` diagonal sweep (avoids duplicating the algorithm).
/// Updated to cubic-band signature (P2, math §52.2 Amd 1).
#[inline]
pub(crate) fn apply_per_axis_shift_pub<F: SemiflowFloat>(
core: &mut TtCore<F>,
h: F,
dx: F,
n: usize,
) {
apply_per_axis_shift(core, h, dx, n);
}
// ═══════════════════════════════════════════════════════════════════════════
// §C — Inline tests — extracted to sibling file per ≤500-line cap.
// ═══════════════════════════════════════════════════════════════════════════
#[cfg(test)]
#[path = "tt_chernoff_tests.rs"]
mod tests;