Skip to main content

ogdoad/forms/
char0.rs

1//! Characteristic-zero quadratic-form and Clifford-algebra classifiers.
2//!
3//! ## The two tables
4//!
5//! Over a **real-closed** field every nonzero square can be rescaled to ±1
6//! (positive elements have square roots). The crate's `Surreal` backend is only
7//! a finite-support Hahn/CNF model with rational coefficients, so the real table
8//! is returned only when the actual represented coefficients can be rescaled by
9//! exact square roots in this implementation. For example `ω` is accepted
10//! (`√ω = ω^{1/2}` is represented), while the rational coefficient `2` is not.
11//! On that checked subdomain, a metric is classified by its signature
12//! `(p, q, r)` = (#positive, #negative, #null) squares, and the nondegenerate
13//! `Cl(p,q)` follows the 8-fold Bott table indexed by `s = (q − p) mod 8`
14//! (with `n = p+q`):
15//!
16//! | s | algebra            |   | s | algebra            |
17//! |---|--------------------|---|---|--------------------|
18//! | 0 | ℝ(2^{n/2})         |   | 4 | ℍ(2^{(n−2)/2})     |
19//! | 1 | ℂ(2^{(n−1)/2})     |   | 5 | ℂ(2^{(n−1)/2})     |
20//! | 2 | ℍ(2^{(n−2)/2})     |   | 6 | ℝ(2^{n/2})         |
21//! | 3 | ℍ(2^{(n−3)/2})²    |   | 7 | ℝ(2^{(n−1)/2})²    |
22//!
23//! Over an **algebraically closed** field all nonzero squares are equivalent, so
24//! only `(n, r)` matter and the classification is 2-fold:
25//! `Cl(n,ℂ) ≅ ℂ(2^{n/2})` for n even, `ℂ(2^{(n−1)/2})²` for n odd. As above,
26//! `Surcomplex<Surreal>` exposes that table only for diagonal entries whose
27//! square roots are actually represented by the finite-support backend.
28//!
29//! The null directions (radical of dim `r`) contribute an exterior factor through
30//! the graded tensor product: `Cl(p,q,r) ≅ Cl(p,q) ⊗̂ Λ(F^r)` over the ground field
31//! `F ∈ {ℝ, ℂ}`.
32//!
33//! The rational backend is **not** treated as real-closed. `classify_rational`
34//! reports the genuine Hasse--Minkowski invariant package: dimension, radical,
35//! discriminant square-class, real signature, and the local Hasse invariants at
36//! the real place and the finitely many relevant `Q_p` places.
37
38use crate::clifford::{Metric, MAX_BASIS_DIM};
39use crate::forms::{relevant_primes, try_disc_class, try_hasse_at_place, try_square_free, Place};
40use crate::scalar::Surcomplex;
41use crate::scalar::Surreal;
42use crate::scalar::{ExactRoots, Rational, Scalar};
43use std::cmp::Ordering;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46/// A real division algebra occurring in the Clifford classification tables.
47pub enum BaseField {
48    /// The real numbers.
49    R,
50    /// The complex numbers.
51    C,
52    /// The quaternions.
53    H,
54}
55
56impl BaseField {
57    fn symbol(self) -> &'static str {
58        match self {
59            BaseField::R => "R",
60            BaseField::C => "C",
61            BaseField::H => "H",
62        }
63    }
64
65    fn real_dimension_log2(self) -> usize {
66        match self {
67            BaseField::R => 0,
68            BaseField::C => 1,
69            BaseField::H => 2,
70        }
71    }
72}
73
74/// The isomorphism class of a char-0 Clifford algebra: a matrix algebra (or a
75/// direct sum of two of them) over ℝ/ℂ/ℍ, optionally tensored with the exterior
76/// algebra of the metric's radical.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct CliffordInvariants {
79    /// The division ring underlying the matrix algebra.
80    pub base: BaseField,
81    /// `m` such that the (semisimple) core is `M_m(base)` (or two copies of it).
82    pub matrix_dim: u128,
83    /// Whether the core is a direct sum of two equal matrix algebras (`⊕`).
84    pub doubled: bool,
85    /// Dimension of the metric radical (null directions): an `Λ(ground^r)` factor.
86    pub radical_dim: usize,
87    /// The ground field of the classification (ℝ for real, ℂ for surcomplex);
88    /// the field over which the radical's exterior factor is taken.
89    pub ground: BaseField,
90    /// The nondegenerate signature `(p, q)` (positive, negative squares). For the
91    /// complex case `q` is 0 and `p` is the nondegenerate dimension.
92    pub signature: (usize, usize),
93}
94
95impl CliffordInvariants {
96    /// Human-readable name, e.g. `M_2(H)`, `M_4(R) ⊕ M_4(R)`, `C ⊗̂ Λ(R^1)`.
97    /// Return the canonical display representation.
98    pub fn display(&self) -> String {
99        self.to_string()
100    }
101}
102
103impl std::fmt::Display for CliffordInvariants {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        let unit = if self.matrix_dim == 1 {
106            self.base.symbol().to_string()
107        } else {
108            format!("M_{}({})", self.matrix_dim, self.base.symbol())
109        };
110        let core = if self.doubled {
111            format!("{unit} ⊕ {unit}")
112        } else {
113            unit
114        };
115        if self.radical_dim > 0 {
116            write!(
117                f,
118                "{core} ⊗̂ Λ({}^{})",
119                self.ground.symbol(),
120                self.radical_dim
121            )
122        } else {
123            f.write_str(&core)
124        }
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129/// A Hasse invariant at one completion of `Q`.
130pub struct RationalPlaceInvariant {
131    /// The real or p-adic place.
132    pub place: Place,
133    /// Hasse invariant at this place: `+1` or `-1`.
134    pub hasse: i128,
135}
136
137/// Complete rational quadratic-form invariants for the metric underlying a
138/// rational Clifford algebra.
139///
140/// The nondegenerate part is classified over `Q` by `(dim, discriminant,
141/// Hasse_v for all places v)`; only the real place and primes dividing
142/// `2·disc` can be nontrivial, so the finite list here is complete. The
143/// `real_closure` field records what the algebra becomes after scalar extension
144/// to `R`, but it is not used as a substitute for the rational invariant. This
145/// is not a full rational Brauer/Brauer-Wall class of the Clifford algebra.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct RationalCliffordInvariants {
148    /// Dimension of the full form.
149    pub dim: usize,
150    /// Dimension of the polar radical.
151    pub radical_dim: usize,
152    /// Canonical representative of the discriminant in `Q*/Q*²`.
153    pub discriminant: i128,
154    /// Real signature of the nondegenerate part.
155    pub signature: (usize, usize),
156    /// Hasse invariants at every potentially nontrivial place.
157    pub local_hasse: Vec<RationalPlaceInvariant>,
158    /// Clifford class after scalar extension to `R`.
159    pub real_closure: CliffordInvariants,
160}
161
162impl RationalCliffordInvariants {
163    /// Return the canonical display representation.
164    pub fn display(&self) -> String {
165        self.to_string()
166    }
167}
168
169impl std::fmt::Display for RationalCliffordInvariants {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        let locals = self
172            .local_hasse
173            .iter()
174            .map(|h| match h.place {
175                Place::Real => format!("R:{:+}", h.hasse),
176                Place::Prime(p) => format!("Q_{}:{:+}", p, h.hasse),
177            })
178            .collect::<Vec<_>>()
179            .join(", ");
180        let rad = if self.radical_dim > 0 {
181            format!(" radical {}", self.radical_dim)
182        } else {
183            String::new()
184        };
185        write!(
186            f,
187            "Q: dim {} disc {} sig ({},{}) hasse [{}]{}; over R: {}",
188            self.dim,
189            self.discriminant,
190            self.signature.0,
191            self.signature.1,
192            locals,
193            rad,
194            self.real_closure
195        )
196    }
197}
198
199/// `2^k`.
200fn p2(k: usize) -> u128 {
201    1u128
202        .checked_shl(k.try_into().expect("matrix exponent fits u32"))
203        .expect("matrix dimension exceeds u128")
204}
205
206/// Classify the nondegenerate real Clifford algebra `Cl(p,q)` (no radical) by
207/// Bott periodicity. `radical_dim`/`ground` are filled in by the callers.
208fn real_core(p: usize, q: usize) -> (BaseField, u128, bool) {
209    let n = p + q;
210    let s = (q as i128 - p as i128).rem_euclid(8) as usize;
211    let base = match s {
212        0 | 6 | 7 => BaseField::R,
213        1 | 5 => BaseField::C,
214        2..=4 => BaseField::H,
215        _ => unreachable!(),
216    };
217    let doubled = s % 4 == 3;
218    let matrix_exp = (n - base.real_dimension_log2() - usize::from(doubled)) / 2;
219    (base, p2(matrix_exp), doubled)
220}
221
222/// Classify a real Clifford algebra from its signature `(p, q, r)`.
223pub fn classify_real(p: usize, q: usize, r: usize) -> CliffordInvariants {
224    assert!(
225        p + q <= MAX_BASIS_DIM,
226        "classify_real: signature dimension p+q={} exceeds MAX_BASIS_DIM={MAX_BASIS_DIM}",
227        p + q
228    );
229    let (base, matrix_dim, doubled) = real_core(p, q);
230    CliffordInvariants {
231        base,
232        matrix_dim,
233        doubled,
234        radical_dim: r,
235        ground: BaseField::R,
236        signature: (p, q),
237    }
238}
239
240/// Classify a complex Clifford algebra from `(n, r)` (nondegenerate dim, radical).
241pub fn classify_complex(n: usize, r: usize) -> CliffordInvariants {
242    assert!(
243        n <= MAX_BASIS_DIM,
244        "classify_complex: dimension n={n} exceeds MAX_BASIS_DIM={MAX_BASIS_DIM}"
245    );
246    let doubled = !n.is_multiple_of(2);
247    let matrix_dim = p2((n - usize::from(doubled)) / 2);
248    CliffordInvariants {
249        base: BaseField::C,
250        matrix_dim,
251        doubled,
252        radical_dim: r,
253        ground: BaseField::C,
254        signature: (n, 0),
255    }
256}
257
258/// Signature over the implemented `Surreal` subdomain where every nonzero
259/// diagonal entry is exactly square-equivalent to ±1. The exact-square test uses
260/// [`ExactRoots`] from the scalar layer.
261pub(crate) fn surreal_signature(metric: &Metric<Surreal>) -> Option<(usize, usize, usize)> {
262    let diag = crate::forms::as_diagonal(metric)?;
263    let (mut p, mut q, mut r) = (0, 0, 0);
264    for x in &diag.q {
265        match x.sign() {
266            Ordering::Greater => {
267                x.sqrt()?; // representable exact square root?
268                p += 1;
269            }
270            Ordering::Less => {
271                x.neg().sqrt()?;
272                q += 1;
273            }
274            Ordering::Equal => r += 1,
275        }
276    }
277    Some((p, q, r))
278}
279
280/// Rank/radical over the implemented `Surcomplex<Surreal>` subdomain where each
281/// nonzero diagonal entry has an exact represented square root — the algebraic-
282/// closure [`ExactRoots`] `sqrt` (the `Surcomplex` blanket impl).
283pub(crate) fn surcomplex_rank(metric: &Metric<Surcomplex<Surreal>>) -> Option<(usize, usize)> {
284    let diag = crate::forms::as_diagonal(metric)?;
285    let mut nonzero = 0usize;
286    let mut radical = 0usize;
287    for z in &diag.q {
288        if z.is_zero() {
289            radical += 1;
290        } else {
291            z.sqrt()?;
292            nonzero += 1;
293        }
294    }
295    Some((nonzero, radical))
296}
297
298fn rational_square_class(x: &Rational) -> Option<i128> {
299    try_square_free(x.numer().checked_mul(x.denom())?)
300}
301
302/// Classify a rational-scalar quadratic form by the genuine rational invariants:
303/// nondegenerate dimension, radical, discriminant square-class, real signature,
304/// and the Hasse invariant at every relevant place.
305pub fn classify_rational(metric: &Metric<Rational>) -> Option<RationalCliffordInvariants> {
306    let diag = crate::forms::as_diagonal(metric)?;
307    let mut entries = Vec::new();
308    let mut radical_dim = 0usize;
309    let mut signature = (0usize, 0usize);
310    for x in &diag.q {
311        if x.is_zero() {
312            radical_dim += 1;
313            continue;
314        }
315        match x.sign() {
316            Ordering::Greater => signature.0 += 1,
317            Ordering::Less => signature.1 += 1,
318            Ordering::Equal => unreachable!("zero handled above"),
319        }
320        entries.push(rational_square_class(x)?);
321    }
322    let discriminant = if entries.is_empty() {
323        1
324    } else {
325        try_disc_class(&entries)?
326    };
327    let mut local_hasse = vec![RationalPlaceInvariant {
328        place: Place::Real,
329        hasse: try_hasse_at_place(&entries, Place::Real)?,
330    }];
331    for p in relevant_primes(&entries) {
332        local_hasse.push(RationalPlaceInvariant {
333            place: Place::Prime(p),
334            hasse: try_hasse_at_place(&entries, Place::Prime(p))?,
335        });
336    }
337    Some(RationalCliffordInvariants {
338        dim: entries.len(),
339        radical_dim,
340        discriminant,
341        signature,
342        local_hasse,
343        real_closure: classify_real(signature.0, signature.1, radical_dim),
344    })
345}
346
347/// Classify a surreal-scalar Clifford algebra when the represented coefficients
348/// can be exactly rescaled to ±1. Returns `None` for forms such as `⟨2⟩`, which
349/// would need `√2` outside the finite-support rational-coefficient backend.
350pub fn classify_surreal(metric: &Metric<Surreal>) -> Option<CliffordInvariants> {
351    let (p, q, r) = surreal_signature(metric)?;
352    Some(classify_real(p, q, r))
353}
354
355/// Classify a surcomplex-scalar Clifford algebra on the exact-square subdomain.
356/// Returns `None` when a diagonal entry has no represented square root.
357pub fn classify_surcomplex(metric: &Metric<Surcomplex<Surreal>>) -> Option<CliffordInvariants> {
358    let (nonzero, r) = surcomplex_rank(metric)?;
359    Some(classify_complex(nonzero, r))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::clifford::{CliffordAlgebra, Metric};
366    use crate::scalar::Scalar;
367
368    fn rat(n: i128) -> Rational {
369        Rational::from_int(n)
370    }
371    fn surreal_diag(qs: &[i128]) -> Metric<Surreal> {
372        Metric::diagonal(qs.iter().map(|&x| Surreal::from_int(x)).collect())
373    }
374    fn cl_real(qs: &[i128]) -> Option<CliffordInvariants> {
375        classify_surreal(&surreal_diag(qs))
376    }
377    fn name(qs: &[i128]) -> String {
378        cl_real(qs).unwrap().display()
379    }
380
381    #[test]
382    fn low_dimensional_real_clifford_table() {
383        assert_eq!(name(&[]), "R"); // Cl(0,0) = ℝ
384        assert_eq!(name(&[1]), "R ⊕ R"); // Cl(1,0) = ℝ⊕ℝ
385        assert_eq!(name(&[-1]), "C"); // Cl(0,1) = ℂ
386        assert_eq!(name(&[1, 1]), "M_2(R)"); // Cl(2,0) = M₂(ℝ)
387        assert_eq!(name(&[1, -1]), "M_2(R)"); // Cl(1,1) = M₂(ℝ)
388        assert_eq!(name(&[-1, -1]), "H"); // Cl(0,2) = ℍ
389        assert_eq!(name(&[1, 1, 1]), "M_2(C)"); // Cl(3,0) = M₂(ℂ)
390        assert_eq!(name(&[-1, -1, -1]), "H ⊕ H"); // Cl(0,3) = ℍ⊕ℍ
391        assert_eq!(name(&[-1, -1, -1, -1]), "M_2(H)"); // Cl(0,4) = M₂(ℍ)
392    }
393
394    #[test]
395    fn physics_signatures() {
396        // Spacetime algebra Cl(1,3) ≅ M₂(ℍ); Cl(3,1) ≅ M₄(ℝ) (the two conventions
397        // are genuinely different algebras — a classic subtlety the table shows).
398        assert_eq!(name(&[1, -1, -1, -1]), "M_2(H)"); // Cl(1,3)
399        assert_eq!(name(&[1, 1, 1, -1]), "M_4(R)"); // Cl(3,1)
400                                                    // Conformal geometric algebra Cl(4,1) ≅ M₄(ℂ).
401        assert_eq!(name(&[1, 1, 1, 1, -1]), "M_4(C)"); // Cl(4,1)
402    }
403
404    #[test]
405    fn dimension_is_consistent() {
406        // real-dim of the algebra must equal 2^n for every nondegenerate signature.
407        for p in 0..=5usize {
408            for q in 0..=5usize {
409                let t = classify_real(p, q, 0);
410                let unit = match t.base {
411                    BaseField::R => 1u128,
412                    BaseField::C => 2u128,
413                    BaseField::H => 4u128,
414                };
415                let copies = if t.doubled { 2u128 } else { 1u128 };
416                let real_dim = copies * unit * t.matrix_dim * t.matrix_dim;
417                assert_eq!(real_dim, 1u128 << (p + q), "Cl({p},{q})");
418            }
419        }
420    }
421
422    #[test]
423    fn radical_gives_exterior_factor() {
424        // Cl(0,1,2): ℂ tensor an exterior algebra on the 2 null directions.
425        assert_eq!(name(&[-1, 0, 0]), "C ⊗̂ Λ(R^2)");
426        // pure Grassmann Λ(R^3) = Cl(0,0,3): trivial core ⊗ Λ.
427        assert_eq!(name(&[0, 0, 0]), "R ⊗̂ Λ(R^3)");
428    }
429
430    #[test]
431    fn matrix_dimension_reaches_dim_128_boundary() {
432        assert_eq!(classify_real(128, 0, 0).matrix_dim, 1u128 << 64);
433        assert_eq!(classify_complex(128, 0).matrix_dim, 1u128 << 64);
434    }
435
436    // The classifiers assert the crate's named `MAX_BASIS_DIM` boundary,
437    // matching `CliffordAlgebra::new`, rather than relying on a lower-level
438    // shift overflow.
439    #[test]
440    #[should_panic(expected = "MAX_BASIS_DIM")]
441    fn classify_real_rejects_dimension_past_max_basis_dim() {
442        classify_real(129, 0, 0);
443    }
444
445    #[test]
446    #[should_panic(expected = "MAX_BASIS_DIM")]
447    fn classify_complex_rejects_dimension_past_max_basis_dim() {
448        classify_complex(129, 0);
449    }
450
451    #[test]
452    fn rational_classification_keeps_square_classes_and_local_hasse_data() {
453        let one = classify_rational(&Metric::diagonal(vec![rat(1)])).unwrap();
454        let two = classify_rational(&Metric::diagonal(vec![rat(2)])).unwrap();
455        assert_eq!(one.signature, two.signature);
456        assert_ne!(one.discriminant, two.discriminant);
457
458        let h = classify_rational(&Metric::diagonal(vec![rat(-1), rat(-1)])).unwrap();
459        assert_eq!(h.discriminant, 1);
460        assert_eq!(h.signature, (0, 2));
461        assert!(h
462            .local_hasse
463            .iter()
464            .any(|x| x.place == Place::Real && x.hasse == -1));
465        assert!(h
466            .local_hasse
467            .iter()
468            .any(|x| x.place == Place::Prime(2) && x.hasse == -1));
469    }
470
471    #[test]
472    fn surreal_accepts_represented_exact_square_classes() {
473        // Infinite/infinitesimal square classes are represented exactly here:
474        // sqrt(ω)=ω^(1/2), sqrt(ε)=ω^(-1/2), so the signature is (1,1).
475        let m = Metric::diagonal(vec![Surreal::omega(), Surreal::epsilon().neg()]);
476        assert_eq!(classify_surreal(&m).unwrap().display(), "M_2(R)");
477        assert_eq!(
478            classify_surreal(&surreal_diag(&[4])).unwrap().display(),
479            "R ⊕ R"
480        );
481    }
482
483    #[test]
484    fn surreal_declines_unrepresented_square_classes() {
485        // The implemented Surreal model has rational coefficients, not all real
486        // coefficients, so sqrt(2) is absent and ⟨2⟩ must not be collapsed to ⟨1⟩.
487        assert_eq!(classify_surreal(&surreal_diag(&[2])), None);
488    }
489
490    #[test]
491    fn surcomplex_is_two_fold_on_exact_square_subdomain() {
492        let even =
493            Metric::<Surcomplex<Surreal>>::diagonal(vec![Surcomplex::one(), Surcomplex::one()]);
494        assert_eq!(classify_surcomplex(&even).unwrap().display(), "M_2(C)"); // n=2
495        let odd = Metric::<Surcomplex<Surreal>>::diagonal(vec![Surcomplex::one()]);
496        assert_eq!(classify_surcomplex(&odd).unwrap().display(), "C ⊕ C"); // n=1
497        let minus_one = Metric::<Surcomplex<Surreal>>::diagonal(vec![Surcomplex::new(
498            Surreal::from_int(-1),
499            Surreal::zero(),
500        )]);
501        assert_eq!(classify_surcomplex(&minus_one).unwrap().display(), "C ⊕ C");
502        let square_of_two_plus_i = Metric::<Surcomplex<Surreal>>::diagonal(vec![Surcomplex::new(
503            Surreal::from_int(3),
504            Surreal::from_int(4),
505        )]);
506        assert_eq!(
507            classify_surcomplex(&square_of_two_plus_i)
508                .unwrap()
509                .display(),
510            "C ⊕ C"
511        );
512    }
513
514    #[test]
515    fn surcomplex_declines_unrepresented_square_classes() {
516        let two = Metric::<Surcomplex<Surreal>>::diagonal(vec![Surcomplex::new(
517            Surreal::from_int(2),
518            Surreal::zero(),
519        )]);
520        assert_eq!(classify_surcomplex(&two), None);
521    }
522
523    #[test]
524    fn even_subalgebra_classification_drops_one_dimension() {
525        // Cl(3,0)⁰ ≅ Cl(0,2) = ℍ — ties the classifier to even_subalgebra.
526        let alg = CliffordAlgebra::new(3, Metric::diagonal(vec![rat(1), rat(1), rat(1)]));
527        let even = alg.even_subalgebra().unwrap();
528        assert_eq!(
529            classify_rational(even.metric())
530                .unwrap()
531                .real_closure
532                .display(),
533            "H"
534        );
535        // Cl(1,3)⁰ ≅ Cl(1,2) ... check it matches a direct signature classification.
536        let st = CliffordAlgebra::new(4, Metric::diagonal(vec![rat(1), rat(-1), rat(-1), rat(-1)]));
537        let st_even = st.even_subalgebra().unwrap();
538        // pivot is the last non-null (a −1 direction): f_i² = −q_i·(−1) = q_i.
539        // signature of the even part here is (1,2) ⇒ same class as Cl(1,2).
540        assert_eq!(
541            classify_rational(st_even.metric())
542                .unwrap()
543                .real_closure
544                .display(),
545            classify_real(1, 2, 0).display()
546        );
547    }
548}