Skip to main content

ries_rs/
pslq.rs

1//! PSLQ Integer Relation Algorithm
2//!
3//! This module implements the PSLQ (Partial Sums LQ) algorithm for finding
4//! integer relations between real numbers. PSLQ can discover identities like:
5//! - π ≈ 355/113 (rational approximation)
6//! - e^π - π ≈ 20 (near-integer relation)
7//! - 4π² ≈ 39.4784... (relation with π²)
8//!
9//! # References
10//!
11
12#![allow(clippy::needless_range_loop)]
13#![allow(dead_code)]
14//! - Ferguson, H.R.P., & Bailey, D.H. (1992). "A Polynomial Time, Numerically
15//!   Stable Integer Relation Algorithm"
16//! - Bailey, D.H., & Broadhurst, D. (2000). "Parallel Integer Relation Detection"
17
18use crate::thresholds::EXACT_MATCH_TOLERANCE;
19use std::f64::consts::PI;
20
21/// Maximum iterations for PSLQ algorithm
22const MAX_ITERATIONS: usize = 10000;
23
24/// Default precision for PSLQ (number of decimal digits)
25pub const DEFAULT_PSLQ_PRECISION: usize = 50;
26
27/// Result of PSLQ integer relation search
28#[derive(Debug, Clone)]
29pub struct IntegerRelation {
30    /// The integer coefficients found (not all zeros)
31    pub coefficients: Vec<i64>,
32    /// The basis vectors that were searched
33    pub basis_names: Vec<String>,
34    /// The residual error (should be near zero if a relation exists)
35    pub residual: f64,
36    /// Whether the relation is considered exact
37    pub is_exact: bool,
38}
39
40impl IntegerRelation {
41    /// Format the relation as a human-readable string
42    pub fn format(&self) -> String {
43        let terms: Vec<String> = self
44            .coefficients
45            .iter()
46            .zip(self.basis_names.iter())
47            .filter(|(c, _)| **c != 0)
48            .map(|(c, name)| {
49                if *c == 1 {
50                    name.clone()
51                } else if *c == -1 {
52                    format!("-{}", name)
53                } else {
54                    format!("{}*{}", c, name)
55                }
56            })
57            .collect();
58
59        if terms.is_empty() {
60            "0".to_string()
61        } else {
62            terms.join(" + ").replace("+ -", "- ")
63        }
64    }
65}
66
67/// Standard mathematical constants for PSLQ searches
68pub fn standard_constants() -> Vec<(String, f64)> {
69    vec![
70        ("1".to_string(), 1.0),
71        ("π".to_string(), PI),
72        ("π²".to_string(), PI * PI),
73        ("π³".to_string(), PI * PI * PI),
74        ("e".to_string(), std::f64::consts::E),
75        ("e²".to_string(), std::f64::consts::E * std::f64::consts::E),
76        ("e^π".to_string(), std::f64::consts::E.powf(PI)),
77        ("ln(2)".to_string(), (2.0f64).ln()),
78        ("ln(π)".to_string(), PI.ln()),
79        ("√2".to_string(), std::f64::consts::SQRT_2),
80        ("√π".to_string(), PI.sqrt()),
81        ("φ".to_string(), (1.0 + 5.0f64.sqrt()) / 2.0), // Golden ratio
82        ("γ".to_string(), 0.5772156649015329),          // Euler-Mascheroni
83        ("ζ(2)".to_string(), PI * PI / 6.0),            // Basel problem
84        ("ζ(3)".to_string(), 1.202056903159594),        // Apéry's constant
85        ("G".to_string(), 0.915965594177219),           // Catalan's constant
86    ]
87}
88
89/// Extended constants for thorough searches
90pub fn extended_constants() -> Vec<(String, f64)> {
91    let mut constants = standard_constants();
92    constants.extend(vec![
93        ("√3".to_string(), 3.0f64.sqrt()),
94        ("√5".to_string(), 5.0f64.sqrt()),
95        ("√7".to_string(), 7.0f64.sqrt()),
96        ("ln(3)".to_string(), (3.0f64).ln()),
97        ("ln(5)".to_string(), (5.0f64).ln()),
98        ("ln(7)".to_string(), (7.0f64).ln()),
99        ("π*√2".to_string(), PI * std::f64::consts::SQRT_2),
100        ("e+π".to_string(), std::f64::consts::E + PI),
101        ("e*π".to_string(), std::f64::consts::E * PI),
102        ("2^π".to_string(), 2.0f64.powf(PI)),
103        ("π^e".to_string(), PI.powf(std::f64::consts::E)),
104    ]);
105    constants
106}
107
108/// PSLQ algorithm configuration
109#[derive(Debug, Clone)]
110pub struct PslqConfig {
111    /// Maximum coefficient magnitude to search
112    pub max_coefficient: i64,
113    /// Maximum number of iterations
114    pub max_iterations: usize,
115    /// Tolerance for detecting zero relations
116    pub tolerance: f64,
117    /// Whether to use extended constant set
118    pub extended_constants: bool,
119}
120
121impl Default for PslqConfig {
122    fn default() -> Self {
123        Self {
124            max_coefficient: 1000,
125            max_iterations: MAX_ITERATIONS,
126            tolerance: EXACT_MATCH_TOLERANCE,
127            extended_constants: false,
128        }
129    }
130}
131
132/// Search for integer relations using PSLQ algorithm
133///
134/// Given a target value and a set of basis vectors (constants), find integer
135/// coefficients such that the linear combination is close to zero.
136///
137/// # Arguments
138///
139/// * `target` - The value to find relations for
140/// * `config` - PSLQ configuration options
141///
142/// # Returns
143///
144/// An optional integer relation if one is found
145pub fn find_integer_relation(target: f64, config: &PslqConfig) -> Option<IntegerRelation> {
146    // Get the basis constants
147    let constants = if config.extended_constants {
148        extended_constants()
149    } else {
150        standard_constants()
151    };
152
153    // Build the vector: [target, c1, c2, c3, ...]
154    // We want to find a relation a0*target + a1*c1 + ... = 0
155    let _n = constants.len() + 1;
156    let mut x: Vec<f64> = vec![target];
157    for (_, val) in &constants {
158        x.push(*val);
159    }
160
161    let coefficients =
162        find_two_term_relation(target, &constants, config).or_else(|| pslq(&x, config))?;
163
164    // Check if the first coefficient (for target) is non-zero
165    if coefficients[0] == 0 {
166        return None;
167    }
168
169    // Calculate residual
170    let mut residual = 0.0;
171    for (i, c) in coefficients.iter().enumerate() {
172        residual += (*c as f64) * x[i];
173    }
174    residual = residual.abs();
175
176    // Check if residual is small enough
177    if residual > config.tolerance * target.abs().max(1.0) {
178        return None;
179    }
180
181    // Build basis names
182    let mut basis_names = vec!["x".to_string()];
183    for (name, _) in &constants {
184        basis_names.push(name.clone());
185    }
186
187    Some(IntegerRelation {
188        coefficients,
189        basis_names,
190        residual,
191        is_exact: residual < EXACT_MATCH_TOLERANCE,
192    })
193}
194
195fn find_two_term_relation(
196    target: f64,
197    constants: &[(String, f64)],
198    config: &PslqConfig,
199) -> Option<Vec<i64>> {
200    let residual_tolerance = config.tolerance * target.abs().max(1.0);
201    let relation_len = constants.len() + 1;
202    let mut best: Option<(Vec<i64>, i64, f64)> = None;
203
204    for (idx, (_, value)) in constants.iter().enumerate() {
205        let value = *value;
206        if !value.is_finite() {
207            continue;
208        }
209
210        let direct_residual = (target - value).abs();
211        if direct_residual <= residual_tolerance {
212            let mut coeffs = vec![0_i128; relation_len];
213            coeffs[0] = 1;
214            coeffs[idx + 1] = -1;
215            if let Some(normalized) = normalize_relation(coeffs, config.max_coefficient) {
216                return Some(normalized);
217            }
218        }
219
220        if value == 0.0 {
221            continue;
222        }
223
224        let Some((num, den)) = find_rational_approximation(target / value, config.max_coefficient)
225        else {
226            continue;
227        };
228        if den == 0 || num.abs() > config.max_coefficient || den.abs() > config.max_coefficient {
229            continue;
230        }
231
232        let residual = ((den as f64) * target - (num as f64) * value).abs();
233        if residual > residual_tolerance {
234            continue;
235        }
236
237        let mut coeffs = vec![0_i128; relation_len];
238        coeffs[0] = den as i128;
239        coeffs[idx + 1] = -(num as i128);
240        let Some(normalized) = normalize_relation(coeffs, config.max_coefficient) else {
241            continue;
242        };
243
244        let height = normalized
245            .iter()
246            .map(|coeff| coeff.abs())
247            .max()
248            .unwrap_or(config.max_coefficient);
249        match &best {
250            None => best = Some((normalized, height, residual)),
251            Some((_, best_height, best_residual)) => {
252                if height < *best_height
253                    || (height == *best_height && residual + residual_tolerance < *best_residual)
254                {
255                    best = Some((normalized, height, residual));
256                }
257            }
258        }
259    }
260
261    best.map(|(coeffs, _, _)| coeffs)
262}
263
264/// Core PSLQ algorithm implementation
265///
266/// Finds integer relations among a vector of real numbers.
267/// Based on the algorithm from Ferguson & Bailey (1992).
268fn pslq(x: &[f64], config: &PslqConfig) -> Option<Vec<i64>> {
269    let n = x.len();
270    if n < 2 || x.iter().any(|value| !value.is_finite()) {
271        return None;
272    }
273
274    // Ferguson/Bailey PSLQ uses a lower-trapezoidal H with n rows and n - 1 columns.
275    // The previous implementation stored a transposed/truncated variant and skipped the
276    // corner-removal rotation, which caused it to miss even direct basis relations.
277    let gamma = (4.0 / 3.0_f64).sqrt();
278
279    // Compute initial norms and scale the vector
280    let mut s: Vec<f64> = vec![0.0; n];
281    s[n - 1] = x[n - 1].abs();
282    for i in (0..n - 1).rev() {
283        s[i] = (s[i + 1].powi(2) + x[i].powi(2)).sqrt();
284    }
285
286    let scale = s[0];
287    if scale <= f64::EPSILON || !scale.is_finite() {
288        return None;
289    }
290
291    // Normalize
292    let mut y: Vec<f64> = x.iter().map(|xi| xi / scale).collect();
293    for value in &mut s {
294        *value /= scale;
295    }
296
297    // Initialize H as an n × (n - 1) lower-trapezoidal matrix.
298    let mut h: Vec<Vec<f64>> = vec![vec![0.0; n - 1]; n];
299    for i in 0..n {
300        for j in 0..n - 1 {
301            if i == j {
302                h[i][j] = s[j + 1] / s[j];
303            } else if i > j {
304                h[i][j] = -y[i] * y[j] / (s[j] * s[j + 1]);
305            } else {
306                h[i][j] = 0.0;
307            }
308        }
309    }
310
311    // Initialize A and B matrices (identity).
312    let mut a: Vec<Vec<i128>> = vec![vec![0; n]; n];
313    let mut b: Vec<Vec<i128>> = vec![vec![0; n]; n];
314    for i in 0..n {
315        a[i][i] = 1;
316        b[i][i] = 1;
317    }
318
319    reduce_h(&mut y, &mut h, &mut a, &mut b, 1, n - 2);
320
321    // Main iteration loop
322    for _iteration in 0..config.max_iterations {
323        if let Some(coeffs) = detect_relation(x, &y, &b, config.max_coefficient, config.tolerance) {
324            return Some(coeffs);
325        }
326
327        // Select m to maximize gamma^i * |h[i][i]|.
328        let mut max_metric = 0.0;
329        let mut max_idx = 0;
330        for i in 0..n - 1 {
331            let metric = gamma.powi(i as i32) * h[i][i].abs();
332            if metric > max_metric {
333                max_metric = metric;
334                max_idx = i;
335            }
336        }
337
338        // Exchange y[m], y[m + 1], corresponding rows of A and H, and columns of B.
339        y.swap(max_idx, max_idx + 1);
340        a.swap(max_idx, max_idx + 1);
341        h.swap(max_idx, max_idx + 1);
342        for row in &mut b {
343            row.swap(max_idx, max_idx + 1);
344        }
345
346        remove_corner(&mut h, max_idx);
347
348        // Block reduction after the swap only needs to touch the affected suffix.
349        reduce_h(
350            &mut y,
351            &mut h,
352            &mut a,
353            &mut b,
354            max_idx + 1,
355            (max_idx + 1).min(n - 2),
356        );
357
358        if let Some(coeffs) = detect_relation(x, &y, &b, config.max_coefficient, config.tolerance) {
359            return Some(coeffs);
360        }
361
362        let max_diag = (0..n - 1).map(|i| h[i][i].abs()).fold(0.0_f64, f64::max);
363        if max_diag <= f64::EPSILON {
364            break;
365        }
366
367        // If the norm lower bound already exceeds the user's coefficient cap, no valid
368        // relation can remain: any vector with |c_i| <= C has Euclidean norm <= C * sqrt(n).
369        let norm_lower_bound = 1.0 / max_diag;
370        let coefficient_norm_cap = (config.max_coefficient as f64) * (n as f64).sqrt();
371        if norm_lower_bound > coefficient_norm_cap {
372            break;
373        }
374
375        // IEEE-754 doubles only preserve 53 bits of mantissa. If A grows beyond that,
376        // the floating-point y/H state is no longer trustworthy for exact detection.
377        if max_abs_matrix_entry(&a) > (1_i128 << 52) {
378            break;
379        }
380    }
381
382    detect_relation(x, &y, &b, config.max_coefficient, config.tolerance)
383}
384
385fn reduce_h(
386    y: &mut [f64],
387    h: &mut [Vec<f64>],
388    a: &mut [Vec<i128>],
389    b: &mut [Vec<i128>],
390    row_start: usize,
391    max_active_col: usize,
392) {
393    if h.is_empty() || h[0].is_empty() || row_start >= h.len() {
394        return;
395    }
396
397    let max_col_count = h[0].len();
398    let active_col_count = (max_active_col + 1).min(max_col_count);
399
400    for i in row_start.max(1)..h.len() {
401        let upper = i.min(active_col_count);
402        for j in (0..upper).rev() {
403            let denom = h[j][j];
404            if denom.abs() <= f64::EPSILON {
405                continue;
406            }
407
408            let t = (h[i][j] / denom).round();
409            if t == 0.0 {
410                continue;
411            }
412
413            y[i] -= t * y[j];
414            for k in 0..=j {
415                h[i][k] -= t * h[j][k];
416            }
417
418            let t_int = t as i128;
419            for k in 0..a[i].len() {
420                a[i][k] -= t_int * a[j][k];
421                b[k][j] += t_int * b[k][i];
422            }
423        }
424    }
425}
426
427fn remove_corner(h: &mut [Vec<f64>], pivot_row: usize) {
428    if h.is_empty() || h[0].len() < 2 || pivot_row + 1 >= h[0].len() {
429        return;
430    }
431
432    let corner = h[pivot_row][pivot_row + 1];
433    if corner.abs() <= f64::EPSILON {
434        return;
435    }
436
437    let diagonal = h[pivot_row][pivot_row];
438    let norm = (diagonal * diagonal + corner * corner).sqrt();
439    if norm <= f64::EPSILON {
440        return;
441    }
442
443    let c = diagonal / norm;
444    let s = corner / norm;
445    for row in pivot_row..h.len() {
446        let left = h[row][pivot_row];
447        let right = h[row][pivot_row + 1];
448        h[row][pivot_row] = c * left + s * right;
449        h[row][pivot_row + 1] = -s * left + c * right;
450    }
451}
452
453fn detect_relation(
454    x: &[f64],
455    y: &[f64],
456    b: &[Vec<i128>],
457    max_coefficient: i64,
458    tolerance: f64,
459) -> Option<Vec<i64>> {
460    let mut candidate_order: Vec<usize> = (0..y.len()).collect();
461    candidate_order.sort_by(|&left, &right| y[left].abs().total_cmp(&y[right].abs()));
462
463    let residual_tolerance = tolerance * x.iter().map(|value| value.abs()).sum::<f64>().max(1.0);
464    let mut best: Option<(Vec<i64>, f64, f64)> = None;
465
466    for idx in candidate_order {
467        let coeffs: Vec<i128> = (0..y.len()).map(|row| b[row][idx]).collect();
468        let Some(normalized) = normalize_relation(coeffs, max_coefficient) else {
469            continue;
470        };
471
472        let residual = x
473            .iter()
474            .zip(normalized.iter())
475            .map(|(value, coeff)| value * (*coeff as f64))
476            .sum::<f64>()
477            .abs();
478        if residual > residual_tolerance {
479            continue;
480        }
481
482        let y_magnitude = y[idx].abs();
483        match &best {
484            None => best = Some((normalized, residual, y_magnitude)),
485            Some((_, best_residual, best_y)) => {
486                let clearly_better = residual + residual_tolerance < *best_residual;
487                let same_residual = (residual - *best_residual).abs() <= residual_tolerance;
488                if clearly_better || (same_residual && y_magnitude < *best_y) {
489                    best = Some((normalized, residual, y_magnitude));
490                }
491            }
492        }
493    }
494
495    best.map(|(coeffs, _, _)| coeffs)
496}
497
498fn normalize_relation(coeffs: Vec<i128>, max_coefficient: i64) -> Option<Vec<i64>> {
499    if coeffs.iter().all(|&coeff| coeff == 0) {
500        return None;
501    }
502
503    let mut gcd = 0_i128;
504    for &coeff in &coeffs {
505        gcd = gcd_i128(gcd, coeff.abs());
506    }
507
508    let mut normalized = if gcd > 1 {
509        coeffs
510            .into_iter()
511            .map(|coeff| coeff / gcd)
512            .collect::<Vec<_>>()
513    } else {
514        coeffs
515    };
516
517    if let Some(&first_non_zero) = normalized.iter().find(|&&coeff| coeff != 0) {
518        if first_non_zero < 0 {
519            for coeff in &mut normalized {
520                *coeff = -*coeff;
521            }
522        }
523    }
524
525    let cap = i128::from(max_coefficient);
526    if normalized.iter().any(|&coeff| coeff.abs() > cap) {
527        return None;
528    }
529
530    normalized
531        .into_iter()
532        .map(|coeff| i64::try_from(coeff).ok())
533        .collect()
534}
535
536fn gcd_i128(mut left: i128, mut right: i128) -> i128 {
537    if left == 0 {
538        return right;
539    }
540    if right == 0 {
541        return left;
542    }
543
544    while right != 0 {
545        let remainder = left % right;
546        left = right;
547        right = remainder;
548    }
549    left.abs()
550}
551
552fn max_abs_matrix_entry(matrix: &[Vec<i128>]) -> i128 {
553    matrix
554        .iter()
555        .flat_map(|row| row.iter())
556        .map(|value| value.abs())
557        .max()
558        .unwrap_or(0)
559}
560
561/// Find rational approximation using continued fractions
562///
563/// This is simpler than PSLQ but only finds rational relations (a/b).
564pub fn find_rational_approximation(x: f64, max_denominator: i64) -> Option<(i64, i64)> {
565    let a0 = x.floor() as i64;
566    let mut remainder = x - a0 as f64;
567
568    if remainder.abs() < EXACT_MATCH_TOLERANCE {
569        return Some((a0, 1));
570    }
571
572    // Continued fraction expansion
573    let mut h_prev = 1i64;
574    let mut h_curr = a0;
575    let mut k_prev = 0i64;
576    let mut k_curr = 1i64;
577
578    for _ in 0..100 {
579        if remainder.abs() < EXACT_MATCH_TOLERANCE {
580            break;
581        }
582
583        let reciprocal = 1.0 / remainder;
584        let a = reciprocal.floor() as i64;
585        remainder = reciprocal - a as f64;
586
587        let h_next = a * h_curr + h_prev;
588        let k_next = a * k_curr + k_prev;
589
590        // Check denominator bound
591        if k_next > max_denominator {
592            break;
593        }
594
595        h_prev = h_curr;
596        h_curr = h_next;
597        k_prev = k_curr;
598        k_curr = k_next;
599
600        // Check if this is a good approximation
601        let approx = h_curr as f64 / k_curr as f64;
602        if (approx - x).abs() < EXACT_MATCH_TOLERANCE {
603            return Some((h_curr, k_curr));
604        }
605    }
606
607    // Return the best approximation found
608    if k_curr > 0 && k_curr <= max_denominator {
609        let approx = h_curr as f64 / k_curr as f64;
610        if (approx - x).abs() < x.abs() * 0.01 {
611            return Some((h_curr, k_curr));
612        }
613    }
614
615    None
616}
617
618/// Find minimal polynomial using LLL-based approach (simplified)
619///
620/// Given a value x, attempts to find a polynomial with integer coefficients
621/// that has x as a root. This is a simplified implementation.
622pub fn find_minimal_polynomial(x: f64, max_degree: usize, max_coeff: i64) -> Option<Vec<i64>> {
623    // Try polynomials of increasing degree
624    for degree in 1..=max_degree {
625        // Build a lattice basis for the polynomial coefficients
626        // This is a simplified approach - a full implementation would use LLL
627
628        // For small degrees, try to fit the polynomial directly
629        if let Some(coeffs) = try_polynomial_degree(x, degree, max_coeff) {
630            return Some(coeffs);
631        }
632    }
633    None
634}
635
636/// Try to find polynomial coefficients for a given degree
637fn try_polynomial_degree(x: f64, degree: usize, max_coeff: i64) -> Option<Vec<i64>> {
638    if degree == 0 {
639        return None;
640    }
641
642    // Compute powers of x
643    let mut powers = vec![1.0; degree + 1];
644    for i in 1..=degree {
645        powers[i] = powers[i - 1] * x;
646    }
647
648    // For degree 1: find a*x + b ≈ 0
649    // For degree 2: find a*x² + b*x + c ≈ 0
650    // etc.
651
652    // Use a simple search for small coefficients
653    let mut best_coeffs: Option<Vec<i64>> = None;
654    let mut best_error = f64::MAX;
655
656    // Search space - limit based on max_coeff
657    let search_range = (-(max_coeff / 10).max(1)..=(max_coeff / 10).max(1)).collect::<Vec<_>>();
658
659    // For very small degrees, exhaustive search is feasible
660    if degree <= 2 {
661        for coeffs in coefficients_product(&search_range, degree + 1) {
662            let mut value = 0.0;
663            for (i, c) in coeffs.iter().enumerate() {
664                value += (*c as f64) * powers[i];
665            }
666
667            let error = value.abs();
668            if error < best_error && error < EXACT_MATCH_TOLERANCE * 100.0 {
669                best_error = error;
670                best_coeffs = Some(coeffs);
671            }
672        }
673    }
674
675    best_coeffs
676}
677
678/// Generate all combinations of coefficients
679fn coefficients_product(ranges: &[i64], count: usize) -> Vec<Vec<i64>> {
680    if count == 0 {
681        return vec![vec![]];
682    }
683
684    let mut result = Vec::new();
685    let rest = coefficients_product(ranges, count - 1);
686    for r in rest {
687        for &val in ranges {
688            let mut combo = r.clone();
689            combo.push(val);
690            result.push(combo);
691        }
692    }
693    result
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn test_rational_approximation_pi() {
702        // π ≈ 355/113
703        let result = find_rational_approximation(PI, 1000);
704        assert!(result.is_some());
705        let (num, den) = result.unwrap();
706        assert_eq!(num, 355);
707        assert_eq!(den, 113);
708    }
709
710    #[test]
711    fn test_rational_approximation_sqrt2() {
712        // √2 ≈ 99/70 or 140/99
713        let result = find_rational_approximation(std::f64::consts::SQRT_2, 200);
714        assert!(result.is_some());
715        let (num, den) = result.unwrap();
716        let approx = num as f64 / den as f64;
717        assert!((approx - std::f64::consts::SQRT_2).abs() < 0.001);
718    }
719
720    #[test]
721    fn test_integer_relation_simple() {
722        let config = PslqConfig::default();
723        let rel = find_integer_relation(2.0 * PI, &config).expect("2*pi should be found");
724        assert_eq!(rel.format(), "x - 2*π");
725        assert!(rel.residual < EXACT_MATCH_TOLERANCE);
726    }
727
728    #[test]
729    fn test_pslq_duplicate_relation() {
730        let coeffs = pslq(&[PI, PI], &PslqConfig::default()).expect("duplicate relation");
731        assert_eq!(coeffs, vec![1, -1]);
732    }
733
734    #[test]
735    fn test_pslq_scalar_multiple_relation() {
736        let coeffs =
737            pslq(&[2.0 * PI, PI], &PslqConfig::default()).expect("scalar multiple relation");
738        assert_eq!(coeffs, vec![1, -2]);
739    }
740
741    #[test]
742    fn test_integer_relation_direct_basis_hits() {
743        let config = PslqConfig::default();
744
745        let pi = find_integer_relation(PI, &config).expect("pi should be found");
746        assert_eq!(pi.format(), "x - π");
747
748        let phi = find_integer_relation((1.0 + 5.0_f64.sqrt()) / 2.0, &config)
749            .expect("phi should be found");
750        assert_eq!(phi.format(), "x - φ");
751
752        let sqrt_pi = find_integer_relation(PI.sqrt(), &config).expect("sqrt(pi) should be found");
753        assert_eq!(sqrt_pi.format(), "x - √π");
754
755        let zeta2 = find_integer_relation(PI * PI / 6.0, &config).expect("zeta(2) should be found");
756        assert_eq!(zeta2.format(), "x - ζ(2)");
757    }
758
759    #[test]
760    fn test_minimal_polynomial_sqrt2() {
761        // √2 is a root of x² - 2 = 0
762        let result = find_minimal_polynomial(std::f64::consts::SQRT_2, 4, 100);
763        if let Some(coeffs) = result {
764            // Should be something like [-2, 0, 1] for x² - 2
765            let value: f64 = coeffs
766                .iter()
767                .enumerate()
768                .map(|(i, c)| *c as f64 * std::f64::consts::SQRT_2.powi(i as i32))
769                .sum();
770            assert!(value.abs() < 0.01);
771        }
772    }
773
774    #[test]
775    fn test_pslq_last_diagonal_no_panic() {
776        let config = PslqConfig {
777            max_iterations: 1,
778            ..PslqConfig::default()
779        };
780
781        let _ = pslq(&[100.0, 1.0, 1.0], &config);
782    }
783
784    #[test]
785    fn test_reduce_h_keeps_y_in_sync_with_a() {
786        let x: [f64; 3] = [10.0, 1.0, 1.0];
787        let n = x.len();
788
789        let mut s = vec![0.0; n];
790        s[n - 1] = x[n - 1].abs();
791        for i in (0..n - 1).rev() {
792            s[i] = (s[i + 1].powi(2) + x[i].powi(2)).sqrt();
793        }
794        let scale = s[0];
795
796        let mut y: Vec<f64> = x.iter().map(|value| value / scale).collect();
797        for value in &mut s {
798            *value /= scale;
799        }
800
801        let mut h = vec![vec![0.0; n - 1]; n];
802        for i in 0..n {
803            for j in 0..n - 1 {
804                if i == j {
805                    h[i][j] = s[j + 1] / s[j];
806                } else if i > j {
807                    h[i][j] = -y[i] * y[j] / (s[j] * s[j + 1]);
808                }
809            }
810        }
811
812        let mut a = vec![vec![0_i128; n]; n];
813        let mut b = vec![vec![0_i128; n]; n];
814        for i in 0..n {
815            a[i][i] = 1;
816            b[i][i] = 1;
817        }
818
819        reduce_h(&mut y, &mut h, &mut a, &mut b, 1, n - 2);
820        assert!(
821            max_abs_matrix_entry(&a) > 1,
822            "test vector should trigger a non-trivial reduction"
823        );
824
825        for i in 0..n {
826            let expected = a[i]
827                .iter()
828                .zip(x.iter())
829                .map(|(coeff, value)| (*coeff as f64) * *value / scale)
830                .sum::<f64>();
831            assert!(
832                (y[i] - expected).abs() < 1e-12,
833                "row {i} drifted: y={}, expected={expected}",
834                y[i]
835            );
836        }
837    }
838}