Skip to main content

gam_sae/
attention_kernel.rs

1//! Harmonic fits for making attention heads legible on chart coordinates.
2//!
3//! The QK part is fit two ways:
4//! - a stationary circulant kernel depending only on `t_q - t_k`;
5//! - a separable low-harmonic surface on `(t_q, t_k)` for heads whose score is
6//!   not well described by phase difference alone.
7//!
8//! Both fits are ordinary least squares in a fixed harmonic basis. The module
9//! does not choose harmonics by search; callers provide the maximum harmonic
10//! they want to inspect.
11
12use ndarray::ArrayView2;
13
14const TWO_PI: f64 = std::f64::consts::PI * 2.0;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum HarmonicBasisKind {
18    Constant,
19    Cos,
20    Sin,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct HarmonicBasisTerm {
25    pub harmonic: usize,
26    pub kind: HarmonicBasisKind,
27}
28
29#[derive(Clone, Debug)]
30pub struct HarmonicCoefficient {
31    pub harmonic: usize,
32    pub cos: f64,
33    pub sin: f64,
34    pub amplitude: f64,
35}
36
37#[derive(Clone, Debug)]
38pub struct HarmonicContent {
39    pub harmonic: usize,
40    pub cos: f64,
41    pub sin: f64,
42    pub amplitude: f64,
43    pub amplitude_fraction: f64,
44}
45
46#[derive(Clone, Debug)]
47pub struct StationaryKernelFit {
48    pub intercept: f64,
49    pub harmonics: Vec<HarmonicCoefficient>,
50    pub r2: f64,
51    pub sse: f64,
52    pub sst: f64,
53}
54
55#[derive(Clone, Debug)]
56pub struct SeparableKernelFit {
57    pub max_harmonic: usize,
58    pub basis_terms: Vec<HarmonicBasisTerm>,
59    pub coefficients_row_major: Vec<f64>,
60    pub r2: f64,
61    pub sse: f64,
62    pub sst: f64,
63}
64
65#[derive(Clone, Debug)]
66pub struct AttentionKernelFit {
67    pub stationary: StationaryKernelFit,
68    pub separable: SeparableKernelFit,
69    pub stationary_r2_gap: f64,
70    pub is_stationary: bool,
71}
72
73#[derive(Clone, Debug)]
74pub struct AttentionKernelReport {
75    pub stationary_r2: f64,
76    pub separable_r2: f64,
77    pub stationary_r2_gap: f64,
78    pub is_stationary: bool,
79    pub dominant_stationary_harmonic: Option<HarmonicCoefficient>,
80    pub stationary_harmonic_content: Vec<HarmonicContent>,
81}
82
83#[derive(Clone, Debug)]
84pub struct CoordinateMapFit {
85    pub intercept: f64,
86    pub harmonics: Vec<HarmonicCoefficient>,
87    pub r2: f64,
88    pub sse: f64,
89    pub sst: f64,
90}
91
92impl StationaryKernelFit {
93    pub fn dominant_harmonic(&self) -> Option<&HarmonicCoefficient> {
94        self.harmonics
95            .iter()
96            .max_by(|left, right| left.amplitude.total_cmp(&right.amplitude))
97    }
98
99    pub fn harmonic_content(&self) -> Vec<HarmonicContent> {
100        harmonic_content(&self.harmonics)
101    }
102
103    pub fn predict(&self, query_t: f64, key_t: f64) -> f64 {
104        let mut out = self.intercept;
105        let delta = query_t - key_t;
106        for coefficient in &self.harmonics {
107            let angle = TWO_PI * coefficient.harmonic as f64 * delta;
108            out += coefficient.cos * angle.cos() + coefficient.sin * angle.sin();
109        }
110        out
111    }
112}
113
114impl SeparableKernelFit {
115    pub fn coefficient(&self, query_basis: usize, key_basis: usize) -> Option<f64> {
116        let width = self.basis_terms.len();
117        if query_basis >= width || key_basis >= width {
118            return None;
119        }
120        Some(self.coefficients_row_major[query_basis * width + key_basis])
121    }
122
123    pub fn predict(&self, query_t: f64, key_t: f64) -> f64 {
124        let query_basis = harmonic_basis_values(query_t, self.max_harmonic);
125        let key_basis = harmonic_basis_values(key_t, self.max_harmonic);
126        let width = self.basis_terms.len();
127        let mut out = 0.0;
128        for query_index in 0..width {
129            for key_index in 0..width {
130                out += self.coefficients_row_major[query_index * width + key_index]
131                    * query_basis[query_index]
132                    * key_basis[key_index];
133            }
134        }
135        out
136    }
137}
138
139impl AttentionKernelFit {
140    pub fn report(&self) -> AttentionKernelReport {
141        AttentionKernelReport {
142            stationary_r2: self.stationary.r2,
143            separable_r2: self.separable.r2,
144            stationary_r2_gap: self.stationary_r2_gap,
145            is_stationary: self.is_stationary,
146            dominant_stationary_harmonic: self.stationary.dominant_harmonic().cloned(),
147            stationary_harmonic_content: self.stationary.harmonic_content(),
148        }
149    }
150}
151
152impl CoordinateMapFit {
153    pub fn dominant_harmonic(&self) -> Option<&HarmonicCoefficient> {
154        self.harmonics
155            .iter()
156            .max_by(|left, right| left.amplitude.total_cmp(&right.amplitude))
157    }
158
159    pub fn harmonic_content(&self) -> Vec<HarmonicContent> {
160        harmonic_content(&self.harmonics)
161    }
162
163    pub fn predict_delta(&self, key_t: f64) -> f64 {
164        let mut out = self.intercept;
165        for coefficient in &self.harmonics {
166            let angle = TWO_PI * coefficient.harmonic as f64 * key_t;
167            out += coefficient.cos * angle.cos() + coefficient.sin * angle.sin();
168        }
169        out
170    }
171}
172
173pub fn fit_attention_kernel(
174    query_t: &[f64],
175    key_t: &[f64],
176    scores: ArrayView2<'_, f64>,
177    max_harmonic: usize,
178) -> Result<AttentionKernelFit, String> {
179    validate_kernel_inputs(query_t, key_t, scores)?;
180    let stationary = fit_stationary_kernel(query_t, key_t, scores, max_harmonic)?;
181    let separable = fit_separable_kernel(query_t, key_t, scores, max_harmonic)?;
182    let stationary_r2_gap = separable.r2 - stationary.r2;
183    // Stationarity is a NESTED-model decision, not a raw training-R² tie at
184    // machine epsilon. The separable `(t_q, t_k)` surface strictly CONTAINS the
185    // stationary circulant kernel (set every off-diagonal query⊗key coefficient
186    // to the circulant value), so under a truly stationary process the larger
187    // model almost surely lowers in-sample SSE — a machine-epsilon R² gap always
188    // fires, declaring even stationary heads non-stationary. Compare the two
189    // nested Gaussian fits by BIC on their SSE with the models' own parameter
190    // counts: `BIC = n·ln(SSE/n) + p·ln n`. The head is stationary unless the
191    // separable surface reduces SSE by more than its extra parameters cost — the
192    // same BIC nested-model comparison the structure search uses elsewhere.
193    let n_obs = (query_t.len() * key_t.len()) as f64;
194    let params_stationary = (1 + 2 * max_harmonic) as f64;
195    let separable_width = 1 + 2 * max_harmonic;
196    let params_separable = (separable_width * separable_width) as f64;
197    let bic = |sse: f64, params: f64| -> f64 {
198        let mean_sq = (sse / n_obs).max(f64::MIN_POSITIVE);
199        n_obs * mean_sq.ln() + params * n_obs.ln()
200    };
201    let is_stationary =
202        bic(stationary.sse, params_stationary) <= bic(separable.sse, params_separable);
203    Ok(AttentionKernelFit {
204        stationary,
205        separable,
206        stationary_r2_gap,
207        is_stationary,
208    })
209}
210
211pub fn fit_stationary_kernel(
212    query_t: &[f64],
213    key_t: &[f64],
214    scores: ArrayView2<'_, f64>,
215    max_harmonic: usize,
216) -> Result<StationaryKernelFit, String> {
217    validate_kernel_inputs(query_t, key_t, scores)?;
218    let parameter_count = 1 + 2 * max_harmonic;
219    let mut normal = vec![0.0; parameter_count * parameter_count];
220    let mut rhs = vec![0.0; parameter_count];
221    let mut basis = vec![0.0; parameter_count];
222    for query_index in 0..query_t.len() {
223        for key_index in 0..key_t.len() {
224            stationary_basis(
225                query_t[query_index],
226                key_t[key_index],
227                max_harmonic,
228                &mut basis,
229            );
230            accumulate_normal_equation(
231                &mut normal,
232                &mut rhs,
233                &basis,
234                scores[[query_index, key_index]],
235            );
236        }
237    }
238    let coefficients = solve_linear_system(normal, rhs, parameter_count)?;
239    let (sse, sst) =
240        stationary_sums_of_squares(query_t, key_t, scores, max_harmonic, &coefficients);
241    Ok(StationaryKernelFit {
242        intercept: coefficients[0],
243        harmonics: harmonic_coefficients_from_regression(&coefficients, max_harmonic),
244        r2: r_squared(sse, sst),
245        sse,
246        sst,
247    })
248}
249
250pub fn fit_separable_kernel(
251    query_t: &[f64],
252    key_t: &[f64],
253    scores: ArrayView2<'_, f64>,
254    max_harmonic: usize,
255) -> Result<SeparableKernelFit, String> {
256    validate_kernel_inputs(query_t, key_t, scores)?;
257    let basis_terms = harmonic_basis_terms(max_harmonic);
258    let basis_width = basis_terms.len();
259    let parameter_count = basis_width * basis_width;
260    let mut normal = vec![0.0; parameter_count * parameter_count];
261    let mut rhs = vec![0.0; parameter_count];
262    let mut row_basis = vec![0.0; parameter_count];
263    for query_value in query_t {
264        assert_finite(*query_value, "query coordinate")?;
265    }
266    for key_value in key_t {
267        assert_finite(*key_value, "key coordinate")?;
268    }
269    for query_index in 0..query_t.len() {
270        let query_basis = harmonic_basis_values(query_t[query_index], max_harmonic);
271        for key_index in 0..key_t.len() {
272            let key_basis = harmonic_basis_values(key_t[key_index], max_harmonic);
273            fill_separable_basis(&query_basis, &key_basis, &mut row_basis);
274            accumulate_normal_equation(
275                &mut normal,
276                &mut rhs,
277                &row_basis,
278                scores[[query_index, key_index]],
279            );
280        }
281    }
282    let coefficients = solve_linear_system(normal, rhs, parameter_count)?;
283    let (sse, sst) = separable_sums_of_squares(query_t, key_t, scores, max_harmonic, &coefficients);
284    Ok(SeparableKernelFit {
285        max_harmonic,
286        basis_terms,
287        coefficients_row_major: coefficients,
288        r2: r_squared(sse, sst),
289        sse,
290        sst,
291    })
292}
293
294pub fn fit_ov_coordinate_map(
295    key_t: &[f64],
296    delta_t: &[f64],
297    max_harmonic: usize,
298) -> Result<CoordinateMapFit, String> {
299    if key_t.len() != delta_t.len() {
300        return Err(format!(
301            "fit_ov_coordinate_map: key_t length {} must equal delta_t length {}",
302            key_t.len(),
303            delta_t.len()
304        ));
305    }
306    if key_t.is_empty() {
307        return Err("fit_ov_coordinate_map requires at least one observation".to_string());
308    }
309    for index in 0..key_t.len() {
310        assert_finite(key_t[index], "key coordinate")?;
311        assert_finite(delta_t[index], "coordinate delta")?;
312    }
313    // The OV coordinate delta is a PHASE (turns, period 1): `delta` and
314    // `delta + 1` are the same shift. A raw Euclidean least-squares of the
315    // wrapped delta collapses seam-straddling pairs to their arithmetic midpoint
316    // — `-0.49` and `+0.49` (nearly the same half-turn) average to `0`, the
317    // antipode of the truth. Regress the SHORTEST-ARC representative instead:
318    // unwrap each delta around the circular mean `μ = atan2(Σsin, Σcos)/2π`, i.e.
319    // `δ̃ = δ − round(δ − μ)`, so the response is seam-invariant. For a delta map
320    // localized within a half-turn (the OV shift case) unwrapping is the identity;
321    // it only bites when the deltas straddle the seam, exactly where the raw fit
322    // was antipode-biased.
323    let (mut cos_sum, mut sin_sum) = (0.0_f64, 0.0_f64);
324    for &delta in delta_t {
325        let angle = TWO_PI * delta;
326        cos_sum += angle.cos();
327        sin_sum += angle.sin();
328    }
329    let circular_mean_turns = sin_sum.atan2(cos_sum) / TWO_PI;
330    let unwrapped_delta: Vec<f64> = delta_t
331        .iter()
332        .map(|&delta| delta - (delta - circular_mean_turns).round())
333        .collect();
334    let parameter_count = 1 + 2 * max_harmonic;
335    let mut normal = vec![0.0; parameter_count * parameter_count];
336    let mut rhs = vec![0.0; parameter_count];
337    let mut basis = vec![0.0; parameter_count];
338    for index in 0..key_t.len() {
339        coordinate_basis(key_t[index], max_harmonic, &mut basis);
340        accumulate_normal_equation(&mut normal, &mut rhs, &basis, unwrapped_delta[index]);
341    }
342    let coefficients = solve_linear_system(normal, rhs, parameter_count)?;
343    let (sse, sst) =
344        coordinate_sums_of_squares(key_t, &unwrapped_delta, max_harmonic, &coefficients);
345    Ok(CoordinateMapFit {
346        intercept: coefficients[0],
347        harmonics: harmonic_coefficients_from_regression(&coefficients, max_harmonic),
348        r2: r_squared(sse, sst),
349        sse,
350        sst,
351    })
352}
353
354fn validate_kernel_inputs(
355    query_t: &[f64],
356    key_t: &[f64],
357    scores: ArrayView2<'_, f64>,
358) -> Result<(), String> {
359    if query_t.is_empty() || key_t.is_empty() {
360        return Err(
361            "attention kernel fit requires non-empty query and key coordinates".to_string(),
362        );
363    }
364    if scores.nrows() != query_t.len() || scores.ncols() != key_t.len() {
365        return Err(format!(
366            "attention kernel score shape {:?} must equal ({}, {})",
367            scores.dim(),
368            query_t.len(),
369            key_t.len()
370        ));
371    }
372    for query_value in query_t {
373        assert_finite(*query_value, "query coordinate")?;
374    }
375    for key_value in key_t {
376        assert_finite(*key_value, "key coordinate")?;
377    }
378    for score in scores.iter() {
379        assert_finite(*score, "QK score")?;
380    }
381    Ok(())
382}
383
384fn stationary_basis(query_t: f64, key_t: f64, max_harmonic: usize, out: &mut [f64]) {
385    out[0] = 1.0;
386    let delta = query_t - key_t;
387    for harmonic in 1..=max_harmonic {
388        let angle = TWO_PI * harmonic as f64 * delta;
389        let base = 1 + 2 * (harmonic - 1);
390        out[base] = angle.cos();
391        out[base + 1] = angle.sin();
392    }
393}
394
395fn coordinate_basis(t: f64, max_harmonic: usize, out: &mut [f64]) {
396    out[0] = 1.0;
397    for harmonic in 1..=max_harmonic {
398        let angle = TWO_PI * harmonic as f64 * t;
399        let base = 1 + 2 * (harmonic - 1);
400        out[base] = angle.cos();
401        out[base + 1] = angle.sin();
402    }
403}
404
405fn harmonic_basis_values(t: f64, max_harmonic: usize) -> Vec<f64> {
406    let mut out = vec![0.0; 1 + 2 * max_harmonic];
407    coordinate_basis(t, max_harmonic, &mut out);
408    out
409}
410
411fn harmonic_basis_terms(max_harmonic: usize) -> Vec<HarmonicBasisTerm> {
412    let mut out = Vec::with_capacity(1 + 2 * max_harmonic);
413    out.push(HarmonicBasisTerm {
414        harmonic: 0,
415        kind: HarmonicBasisKind::Constant,
416    });
417    for harmonic in 1..=max_harmonic {
418        out.push(HarmonicBasisTerm {
419            harmonic,
420            kind: HarmonicBasisKind::Cos,
421        });
422        out.push(HarmonicBasisTerm {
423            harmonic,
424            kind: HarmonicBasisKind::Sin,
425        });
426    }
427    out
428}
429
430fn fill_separable_basis(query_basis: &[f64], key_basis: &[f64], out: &mut [f64]) {
431    let width = query_basis.len();
432    for query_index in 0..width {
433        for key_index in 0..width {
434            out[query_index * width + key_index] = query_basis[query_index] * key_basis[key_index];
435        }
436    }
437}
438
439fn accumulate_normal_equation(normal: &mut [f64], rhs: &mut [f64], basis: &[f64], y: f64) {
440    let width = basis.len();
441    for row in 0..width {
442        rhs[row] += basis[row] * y;
443        for col in 0..width {
444            normal[row * width + col] += basis[row] * basis[col];
445        }
446    }
447}
448
449fn solve_linear_system(
450    mut matrix: Vec<f64>,
451    mut rhs: Vec<f64>,
452    width: usize,
453) -> Result<Vec<f64>, String> {
454    let mut matrix_scale = 0.0_f64;
455    for value in &matrix {
456        matrix_scale = matrix_scale.max(value.abs());
457    }
458    let pivot_floor = f64::EPSILON * width.max(1) as f64 * matrix_scale.max(1.0);
459    for col in 0..width {
460        let mut pivot_row = col;
461        let mut pivot_abs = matrix[col * width + col].abs();
462        for candidate in (col + 1)..width {
463            let candidate_abs = matrix[candidate * width + col].abs();
464            if candidate_abs > pivot_abs {
465                pivot_row = candidate;
466                pivot_abs = candidate_abs;
467            }
468        }
469        if pivot_abs <= pivot_floor {
470            return Err(format!(
471                "least-squares normal equation is rank deficient at column {col}; pivot {pivot_abs:e}"
472            ));
473        }
474        if pivot_row != col {
475            for swap_col in 0..width {
476                matrix.swap(col * width + swap_col, pivot_row * width + swap_col);
477            }
478            rhs.swap(col, pivot_row);
479        }
480        let pivot = matrix[col * width + col];
481        for row in (col + 1)..width {
482            let factor = matrix[row * width + col] / pivot;
483            matrix[row * width + col] = 0.0;
484            for update_col in (col + 1)..width {
485                matrix[row * width + update_col] -= factor * matrix[col * width + update_col];
486            }
487            rhs[row] -= factor * rhs[col];
488        }
489    }
490    let mut solution = vec![0.0; width];
491    for row in (0..width).rev() {
492        let mut residual = rhs[row];
493        for col in (row + 1)..width {
494            residual -= matrix[row * width + col] * solution[col];
495        }
496        solution[row] = residual / matrix[row * width + row];
497    }
498    Ok(solution)
499}
500
501fn stationary_sums_of_squares(
502    query_t: &[f64],
503    key_t: &[f64],
504    scores: ArrayView2<'_, f64>,
505    max_harmonic: usize,
506    coefficients: &[f64],
507) -> (f64, f64) {
508    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
509    let mut basis = vec![0.0; coefficients.len()];
510    let mut sse = 0.0;
511    let mut sst = 0.0;
512    for query_index in 0..query_t.len() {
513        for key_index in 0..key_t.len() {
514            stationary_basis(
515                query_t[query_index],
516                key_t[key_index],
517                max_harmonic,
518                &mut basis,
519            );
520            let prediction = dot(&basis, coefficients);
521            let observed = scores[[query_index, key_index]];
522            let residual = observed - prediction;
523            let centered = observed - mean;
524            sse += residual * residual;
525            sst += centered * centered;
526        }
527    }
528    (sse, sst)
529}
530
531fn separable_sums_of_squares(
532    query_t: &[f64],
533    key_t: &[f64],
534    scores: ArrayView2<'_, f64>,
535    max_harmonic: usize,
536    coefficients: &[f64],
537) -> (f64, f64) {
538    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
539    let basis_width = 1 + 2 * max_harmonic;
540    let mut row_basis = vec![0.0; coefficients.len()];
541    // Precondition: the separable (query ⊗ key) harmonic design has
542    // `basis_width²` columns, so the coefficient vector `fill_separable_basis`
543    // writes into must match. Checked before the loop that indexes it, in every
544    // build (not a debug-only invariant).
545    assert_eq!(
546        row_basis.len(),
547        basis_width * basis_width,
548        "separable harmonic coefficient length {} must equal basis_width² = {}",
549        row_basis.len(),
550        basis_width * basis_width,
551    );
552    let mut sse = 0.0;
553    let mut sst = 0.0;
554    for query_index in 0..query_t.len() {
555        let query_basis = harmonic_basis_values(query_t[query_index], max_harmonic);
556        for key_index in 0..key_t.len() {
557            let key_basis = harmonic_basis_values(key_t[key_index], max_harmonic);
558            fill_separable_basis(&query_basis, &key_basis, &mut row_basis);
559            let prediction = dot(&row_basis, coefficients);
560            let observed = scores[[query_index, key_index]];
561            let residual = observed - prediction;
562            let centered = observed - mean;
563            sse += residual * residual;
564            sst += centered * centered;
565        }
566    }
567    (sse, sst)
568}
569
570fn coordinate_sums_of_squares(
571    key_t: &[f64],
572    delta_t: &[f64],
573    max_harmonic: usize,
574    coefficients: &[f64],
575) -> (f64, f64) {
576    let mean = delta_t.iter().sum::<f64>() / delta_t.len() as f64;
577    let mut basis = vec![0.0; coefficients.len()];
578    let mut sse = 0.0;
579    let mut sst = 0.0;
580    for index in 0..key_t.len() {
581        coordinate_basis(key_t[index], max_harmonic, &mut basis);
582        let prediction = dot(&basis, coefficients);
583        let residual = delta_t[index] - prediction;
584        let centered = delta_t[index] - mean;
585        sse += residual * residual;
586        sst += centered * centered;
587    }
588    (sse, sst)
589}
590
591fn harmonic_coefficients_from_regression(
592    coefficients: &[f64],
593    max_harmonic: usize,
594) -> Vec<HarmonicCoefficient> {
595    let mut out = Vec::with_capacity(max_harmonic);
596    for harmonic in 1..=max_harmonic {
597        let base = 1 + 2 * (harmonic - 1);
598        let cos = coefficients[base];
599        let sin = coefficients[base + 1];
600        out.push(HarmonicCoefficient {
601            harmonic,
602            cos,
603            sin,
604            amplitude: cos.hypot(sin),
605        });
606    }
607    out
608}
609
610fn harmonic_content(harmonics: &[HarmonicCoefficient]) -> Vec<HarmonicContent> {
611    let total_amplitude: f64 = harmonics
612        .iter()
613        .map(|coefficient| coefficient.amplitude)
614        .sum();
615    harmonics
616        .iter()
617        .map(|coefficient| {
618            let amplitude_fraction = if total_amplitude > 0.0 {
619                coefficient.amplitude / total_amplitude
620            } else {
621                0.0
622            };
623            HarmonicContent {
624                harmonic: coefficient.harmonic,
625                cos: coefficient.cos,
626                sin: coefficient.sin,
627                amplitude: coefficient.amplitude,
628                amplitude_fraction,
629            }
630        })
631        .collect()
632}
633
634fn dot(left: &[f64], right: &[f64]) -> f64 {
635    left.iter()
636        .zip(right.iter())
637        .map(|(left_value, right_value)| left_value * right_value)
638        .sum()
639}
640
641fn r_squared(sse: f64, sst: f64) -> f64 {
642    if sst > 0.0 {
643        1.0 - sse / sst
644    } else if sse == 0.0 {
645        1.0
646    } else {
647        0.0
648    }
649}
650
651fn assert_finite(value: f64, label: &str) -> Result<(), String> {
652    if value.is_finite() {
653        Ok(())
654    } else {
655        Err(format!("{label} must be finite, got {value}"))
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use ndarray::Array2;
663
664    #[test]
665    fn stationary_single_harmonic_qk_fit_recovers_planted_phase_kernel() {
666        let query_t: Vec<f64> = (0..24).map(|index| index as f64 / 24.0).collect();
667        let key_t: Vec<f64> = (0..20).map(|index| (index as f64 + 0.25) / 20.0).collect();
668        let mut scores = Array2::<f64>::zeros((query_t.len(), key_t.len()));
669        for query_index in 0..query_t.len() {
670            for key_index in 0..key_t.len() {
671                let delta = query_t[query_index] - key_t[key_index];
672                let deterministic_noise =
673                    1.0e-5 * (TWO_PI * (3.0 * query_t[query_index] + 5.0 * key_t[key_index])).sin();
674                scores[[query_index, key_index]] =
675                    1.7 * (TWO_PI * delta).cos() + deterministic_noise;
676            }
677        }
678
679        let fit = fit_attention_kernel(&query_t, &key_t, scores.view(), 3)
680            .expect("stationary kernel fit should succeed");
681        let dominant = fit
682            .stationary
683            .dominant_harmonic()
684            .expect("stationary fit should report a dominant harmonic");
685
686        assert_eq!(dominant.harmonic, 1);
687        assert!(dominant.amplitude > 1.699);
688        assert!(fit.stationary.r2 > 0.999_999_999);
689        assert!(fit.is_stationary);
690
691        let report = fit.report();
692        let reported_dominant = report
693            .dominant_stationary_harmonic
694            .expect("report should carry the dominant harmonic");
695        assert_eq!(reported_dominant.harmonic, 1);
696        assert!(report.stationary_harmonic_content[0].amplitude_fraction > 0.999);
697        assert!(report.stationary_r2 > 0.999_999_999);
698        assert!(report.is_stationary);
699    }
700
701    #[test]
702    fn separable_fit_beats_stationary_fit_for_nonstationary_head() {
703        let query_t: Vec<f64> = (0..23).map(|index| index as f64 / 23.0).collect();
704        let key_t: Vec<f64> = (0..29).map(|index| (index as f64 + 0.4) / 29.0).collect();
705        let mut scores = Array2::<f64>::zeros((query_t.len(), key_t.len()));
706        for query_index in 0..query_t.len() {
707            for key_index in 0..key_t.len() {
708                scores[[query_index, key_index]] =
709                    (TWO_PI * query_t[query_index]).cos() * (TWO_PI * 2.0 * key_t[key_index]).sin();
710            }
711        }
712
713        let fit = fit_attention_kernel(&query_t, &key_t, scores.view(), 2)
714            .expect("nonstationary kernel fit should succeed");
715
716        assert!(fit.separable.r2 > 0.999_999_999);
717        assert!(
718            fit.separable.r2 > fit.stationary.r2 + 0.5,
719            "separable r2 {} should beat stationary r2 {}",
720            fit.separable.r2,
721            fit.stationary.r2
722        );
723        assert!(!fit.is_stationary);
724    }
725
726    #[test]
727    fn ov_coordinate_map_fit_recovers_planted_shift() {
728        let key_t: Vec<f64> = (0..31).map(|index| index as f64 / 31.0).collect();
729        let delta_t: Vec<f64> = key_t
730            .iter()
731            .map(|t| 1.0 / 7.0 + 0.25 * (TWO_PI * *t).sin())
732            .collect();
733
734        let fit = fit_ov_coordinate_map(&key_t, &delta_t, 2)
735            .expect("coordinate map harmonic fit should succeed");
736        let dominant = fit
737            .dominant_harmonic()
738            .expect("coordinate map should report a dominant harmonic");
739
740        assert_eq!(dominant.harmonic, 1);
741        assert!((fit.intercept - 1.0 / 7.0).abs() < 1.0e-12);
742        assert!((dominant.sin - 0.25).abs() < 1.0e-12);
743        assert!(fit.r2 > 0.999_999_999);
744    }
745
746    #[test]
747    fn ov_coordinate_map_unwraps_seam_straddling_half_turn() {
748        // A half-turn (0.5) shift with mild key-dependent variation. In the
749        // wrapped chart the deltas straddle the seam — some near +0.45, some near
750        // −0.45 — so a raw Euclidean regression averages them to ≈0, the antipode.
751        // The shortest-arc unwrapping around the circular mean recovers the true
752        // ≈0.5 half-turn shift and fits the variation.
753        let key_t: Vec<f64> = (0..40).map(|index| index as f64 / 40.0).collect();
754        let delta_t: Vec<f64> = key_t
755            .iter()
756            .map(|t| {
757                let raw = 0.5 + 0.1 * (TWO_PI * *t).cos();
758                raw - raw.round() // wrap into (−0.5, 0.5]
759            })
760            .collect();
761        let fit = fit_ov_coordinate_map(&key_t, &delta_t, 1).expect("ov fit");
762        let recovered = fit.intercept.rem_euclid(1.0);
763        assert!(
764            (recovered - 0.5).abs() < 0.05,
765            "circular unwrapping must recover the half-turn shift, not the antipode: got {recovered}"
766        );
767        assert!(
768            fit.r2 > 0.99,
769            "the unwrapped harmonic fit explains the key-dependent variation: r2={}",
770            fit.r2
771        );
772    }
773}