1use 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 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 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 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 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() })
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}