Skip to main content

ocas_atom/tensor/
mod.rs

1//! Tensor algebra — index slots, contraction, symmetries, and canonicalisation.
2//!
3//! This directory module supersedes the 0.18.0 single-file `tensor.rs`.
4//!
5//! ## Sub-modules
6//!
7//! | module | purpose |
8//! |---|---|
9//! | `self` | [`Tensor`], [`IndexSlot`], [`Symmetry`], [`contract`] — basic algebra (0.18.0) |
10//! | [`graph`] | McKay refinement-individualisation graph canonical labelling engine (0.22.0) |
11//! | [`spec`] | Tensor symmetry specifications and index-group registry (0.22.0) |
12//! | [`canon`] | Tensor expression → graph → canonical form with dummy management (0.22.0) |
13//! | [`dummy`] | Dummy index refresh and validation (0.22.0) |
14//! | [`young`] | Explicit Young projector via permutation-sum expansion (0.22.0) |
15
16pub mod canon;
17pub mod dummy;
18pub mod graph;
19pub mod spec;
20pub mod young;
21
22use crate::{Atom, AtomArena, Symbol};
23
24/// Position of an index in a tensor's slot list: upper (contravariant) or
25/// lower (covariant).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum IndexPosition {
28    /// Upper / contravariant index.
29    Upper,
30    /// Lower / covariant index.
31    Lower,
32}
33
34/// A single index slot of a tensor: the index expression and its variance.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub struct IndexSlot<'a> {
37    /// The index label (typically a one-character variable or an integer).
38    label: Atom<'a>,
39    /// Whether the index is upper or lower.
40    position: IndexPosition,
41}
42
43impl<'a> IndexSlot<'a> {
44    /// Create a new index slot.
45    pub fn new(label: Atom<'a>, position: IndexPosition) -> Self {
46        Self { label, position }
47    }
48
49    /// The index label expression.
50    pub fn label(&self) -> Atom<'a> {
51        self.label
52    }
53
54    /// The index variance.
55    pub fn position(&self) -> IndexPosition {
56        self.position
57    }
58}
59
60/// Symmetry of a tensor's index slots.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Symmetry {
63    /// No symmetry (general tensor).
64    None,
65    /// Symmetric: invariant under any swap of slots.
66    Symmetric,
67    /// Antisymmetric: flips sign under any swap of slots.
68    Antisymmetric,
69}
70
71/// A tensor: a named object with a list of index slots, an arity, and a
72/// slot symmetry.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Tensor<'a> {
75    name: Symbol,
76    slots: Vec<IndexSlot<'a>>,
77    symmetry: Symmetry,
78}
79
80impl<'a> Tensor<'a> {
81    /// Create a new tensor with the given slots and no symmetry.
82    pub fn new(name: Symbol, slots: Vec<IndexSlot<'a>>) -> Self {
83        Self {
84            name,
85            slots,
86            symmetry: Symmetry::None,
87        }
88    }
89
90    /// Builder: set the slot symmetry.
91    pub fn with_symmetry(mut self, symmetry: Symmetry) -> Self {
92        self.symmetry = symmetry;
93        self
94    }
95
96    /// The tensor name.
97    pub fn name(&self) -> Symbol {
98        self.name
99    }
100
101    /// The index slots.
102    pub fn slots(&self) -> &[IndexSlot<'a>] {
103        &self.slots
104    }
105
106    /// The slot symmetry.
107    pub fn symmetry(&self) -> Symmetry {
108        self.symmetry
109    }
110
111    /// The tensor arity (number of slots).
112    pub fn rank(&self) -> usize {
113        self.slots.len()
114    }
115
116    /// Return the dummy indices (labels occurring exactly twice across all
117    /// slots, once upper and once lower) — these are the ones that will be
118    /// contracted in a product.
119    pub fn dummy_labels(&self) -> Vec<Atom<'a>> {
120        dummies(self.slots().iter().map(|s| s.label()))
121    }
122
123    /// Render this tensor as an [`Atom`] function node `name(slot, slot, ...)`
124    /// in the supplied arena. Symmetrisation is *not* applied here — the atom
125    /// preserves the slot order of `self`.
126    pub fn to_atom(&self, ctx: &'a AtomArena<'a>) -> Atom<'a> {
127        let args: Vec<Atom<'a>> = self.slots.iter().map(|s| s.label).collect();
128        ctx.fun(self.name.as_str(), &args)
129    }
130}
131
132/// Collect labels occurring exactly twice among the iterator (the contraction
133/// candidates). Labels occurring once are free; occurring more than twice is a
134/// malformed expression (returned as not-a-dummy).
135fn dummies<'a, I: IntoIterator<Item = Atom<'a>>>(labels: I) -> Vec<Atom<'a>> {
136    use crate::FastHashMap;
137    let mut counts: FastHashMap<AtomId<'a>, usize> = FastHashMap::default();
138    for l in labels {
139        let id = AtomId(l);
140        *counts.entry(id).or_insert(0) += 1;
141    }
142    let mut out: Vec<Atom<'a>> = Vec::new();
143    let mut seen: std::collections::HashSet<*const ()> = std::collections::HashSet::new();
144    for (id, n) in counts.iter() {
145        if *n == 2 {
146            let ptr = id.0.node() as *const _ as *const ();
147            if seen.insert(ptr) {
148                out.push(id.0);
149            }
150        }
151    }
152    out
153}
154
155#[derive(Clone, Copy, PartialEq, Eq, Hash)]
156struct AtomId<'a>(Atom<'a>);
157
158/// The result of contracting a pair of tensors: either a scalar atom (when no
159/// dummies remain) or a product/sum expression.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Contracted<'a> {
162    /// A tensor product with the contraction performed (sum over dummies); the
163    /// remaining free slots are concatenated.
164    Product(TensorProduct<'a>),
165    /// Fully contracted to a scalar expression.
166    Scalar(Atom<'a>),
167}
168
169/// A product of tensors with free slots concatenated; dummies have been summed
170/// over.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TensorProduct<'a> {
173    /// The remaining free tensors after contraction (their slots concatenated).
174    pub factors: Vec<Tensor<'a>>,
175}
176
177/// Contract two tensors by summing over shared dummy indices.
178///
179/// Two slots with the same label but opposite variance contract. The result
180/// keeps the surviving free slots in the order `(a.free..., b.free...)`.
181/// Returns [`Contracted::Scalar`] when no free slots survive.
182pub fn contract<'a>(ctx: &'a AtomArena<'a>, a: &Tensor<'a>, b: &Tensor<'a>) -> Contracted<'a> {
183    // Pair up slots with equal label and opposite variance.
184    let mut used_a = vec![false; a.slots.len()];
185    let mut used_b = vec![false; b.slots.len()];
186    let mut pair_labels: Vec<Atom<'a>> = Vec::new();
187    for (i, sa) in a.slots.iter().enumerate() {
188        if used_a[i] {
189            continue;
190        }
191        for (j, sb) in b.slots.iter().enumerate() {
192            if used_b[j] {
193                continue;
194            }
195            if sa.label == sb.label && sa.position != sb.position {
196                used_a[i] = true;
197                used_b[j] = true;
198                pair_labels.push(sa.label);
199                break;
200            }
201        }
202    }
203    // Surviving free slots.
204    let mut free: Vec<IndexSlot<'a>> = Vec::new();
205    for (i, s) in a.slots.iter().enumerate() {
206        if !used_a[i] {
207            free.push(*s);
208        }
209    }
210    for (j, s) in b.slots.iter().enumerate() {
211        if !used_b[j] {
212            free.push(*s);
213        }
214    }
215    if pair_labels.is_empty() {
216        // No contraction: plain tensor product.
217        return Contracted::Product(TensorProduct {
218            factors: vec![a.clone(), b.clone()],
219        });
220    }
221    if free.is_empty() {
222        // Fully contracted: build a Σ over the dummy of (a·b).
223        let a_atom = a.to_atom(ctx);
224        let b_atom = b.to_atom(ctx);
225        let product = ctx.mul(&[a_atom, b_atom]);
226        return Contracted::Scalar(product);
227    }
228    // Partial contraction: a new tensor carrying the free slots.
229    let name = Symbol::new(&format!("{}_contract_{}", a.name.as_str(), b.name.as_str()));
230    Contracted::Product(TensorProduct {
231        factors: vec![Tensor::new(name, free)],
232    })
233}
234
235/// Apply a tensor's slot symmetry by permuting its slots to a canonical order
236/// (ascending label), returning the sign for antisymmetry.
237///
238/// For [`Symmetry::Symmetric`] this sorts slots and returns `+1`. For
239/// [`Symmetry::Antisymmetric`] it returns the parity of the permutation that
240/// sorts the slots. For [`Symmetry::None`] it is a no-op returning `+1`.
241///
242/// This is **not** a full canonicalisation under a permutation group (which
243/// requires graph isomorphism); it merely gives a stable order for equality
244/// comparisons of symmetric tensors with the same multiset of slots.
245pub fn symmetrise_sign(tensor: &Tensor<'_>) -> i64 {
246    match tensor.symmetry {
247        Symmetry::None | Symmetry::Symmetric => 1,
248        Symmetry::Antisymmetric => {
249            let mut slots: Vec<IndexSlot<'_>> = tensor.slots.to_vec();
250            let mut swaps = 0usize;
251            for i in 1..slots.len() {
252                let mut j = i;
253                while j > 0 && slot_less(&slots[j - 1], &slots[j]) {
254                    slots.swap(j - 1, j);
255                    swaps += 1;
256                    j -= 1;
257                }
258            }
259            if swaps.is_multiple_of(2) { 1 } else { -1 }
260        }
261    }
262}
263
264fn slot_less(a: &IndexSlot<'_>, b: &IndexSlot<'_>) -> bool {
265    let pa = a.label.node() as *const _ as *const ();
266    let pb = b.label.node() as *const _ as *const ();
267    (pa as usize) < (pb as usize) || (pa == pb && (a.position as u8) > (b.position as u8))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::AtomArena;
274    use crate::AtomNode;
275
276    fn idx<'a>(ctx: &'a AtomArena<'a>, name: &str, pos: IndexPosition) -> IndexSlot<'a> {
277        IndexSlot::new(ctx.var(name), pos)
278    }
279
280    #[test]
281    fn tensor_rank_and_slots() {
282        let arena = crate::Arena::new();
283        let ctx = AtomArena::new(&arena);
284        let t = Tensor::new(
285            Symbol::new("T"),
286            vec![
287                idx(&ctx, "i", IndexPosition::Upper),
288                idx(&ctx, "j", IndexPosition::Lower),
289            ],
290        );
291        assert_eq!(t.rank(), 2);
292        assert_eq!(t.slots().len(), 2);
293        assert_eq!(t.symmetry(), Symmetry::None);
294    }
295
296    #[test]
297    fn dummy_detection_finds_repeated_label() {
298        let arena = crate::Arena::new();
299        let ctx = AtomArena::new(&arena);
300        let t = Tensor::new(
301            Symbol::new("T"),
302            vec![
303                idx(&ctx, "i", IndexPosition::Upper),
304                idx(&ctx, "i", IndexPosition::Lower),
305            ],
306        );
307        let dummies = t.dummy_labels();
308        assert_eq!(dummies.len(), 1);
309    }
310
311    #[test]
312    fn contract_two_tensors_with_one_dummy() {
313        let arena = crate::Arena::new();
314        let ctx = AtomArena::new(&arena);
315        let t = Tensor::new(
316            Symbol::new("T"),
317            vec![
318                idx(&ctx, "i", IndexPosition::Upper),
319                idx(&ctx, "j", IndexPosition::Lower),
320            ],
321        );
322        let u = Tensor::new(
323            Symbol::new("U"),
324            vec![
325                idx(&ctx, "j", IndexPosition::Upper),
326                idx(&ctx, "k", IndexPosition::Lower),
327            ],
328        );
329        match contract(&ctx, &t, &u) {
330            Contracted::Product(p) => {
331                assert_eq!(p.factors.len(), 1);
332                assert_eq!(p.factors[0].rank(), 2);
333            }
334            _ => panic!("expected partial contraction product"),
335        }
336    }
337
338    #[test]
339    fn contract_to_scalar_when_no_free_slots() {
340        let arena = crate::Arena::new();
341        let ctx = AtomArena::new(&arena);
342        let t = Tensor::new(Symbol::new("T"), vec![idx(&ctx, "i", IndexPosition::Upper)]);
343        let u = Tensor::new(Symbol::new("U"), vec![idx(&ctx, "i", IndexPosition::Lower)]);
344        match contract(&ctx, &t, &u) {
345            Contracted::Scalar(atom) => {
346                assert!(matches!(atom.node(), AtomNode::Mul(_)));
347            }
348            _ => panic!("expected scalar contraction"),
349        }
350    }
351
352    #[test]
353    fn no_overlap_yields_plain_product() {
354        let arena = crate::Arena::new();
355        let ctx = AtomArena::new(&arena);
356        let t = Tensor::new(Symbol::new("T"), vec![idx(&ctx, "i", IndexPosition::Upper)]);
357        let u = Tensor::new(Symbol::new("U"), vec![idx(&ctx, "j", IndexPosition::Upper)]);
358        match contract(&ctx, &t, &u) {
359            Contracted::Product(p) => assert_eq!(p.factors.len(), 2),
360            _ => panic!("expected plain product"),
361        }
362    }
363
364    #[test]
365    fn antisymmetric_sign_parity() {
366        let arena = crate::Arena::new();
367        let ctx = AtomArena::new(&arena);
368        let e_ab = Tensor::new(
369            Symbol::new("eps"),
370            vec![
371                idx(&ctx, "a", IndexPosition::Lower),
372                idx(&ctx, "b", IndexPosition::Lower),
373            ],
374        )
375        .with_symmetry(Symmetry::Antisymmetric);
376        let e_ba = Tensor::new(
377            Symbol::new("eps"),
378            vec![
379                idx(&ctx, "b", IndexPosition::Lower),
380                idx(&ctx, "a", IndexPosition::Lower),
381            ],
382        )
383        .with_symmetry(Symmetry::Antisymmetric);
384        let s1 = symmetrise_sign(&e_ab);
385        let s2 = symmetrise_sign(&e_ba);
386        assert!(s1 == 1 || s1 == -1);
387        assert!(s2 == 1 || s2 == -1);
388        assert_eq!(s1, -s2);
389    }
390
391    #[test]
392    fn symmetric_sign_is_always_plus() {
393        let arena = crate::Arena::new();
394        let ctx = AtomArena::new(&arena);
395        let g = Tensor::new(
396            Symbol::new("g"),
397            vec![
398                idx(&ctx, "a", IndexPosition::Lower),
399                idx(&ctx, "b", IndexPosition::Lower),
400            ],
401        )
402        .with_symmetry(Symmetry::Symmetric);
403        assert_eq!(symmetrise_sign(&g), 1);
404    }
405
406    #[test]
407    fn to_atom_round_trips_as_function_node() {
408        let arena = crate::Arena::new();
409        let ctx = AtomArena::new(&arena);
410        let t = Tensor::new(
411            Symbol::new("T"),
412            vec![
413                idx(&ctx, "i", IndexPosition::Upper),
414                idx(&ctx, "j", IndexPosition::Lower),
415            ],
416        );
417        let atom = t.to_atom(&ctx);
418        match atom.node() {
419            AtomNode::Fun(name, args) => {
420                assert_eq!(name.as_str(), "T");
421                assert_eq!(args.len(), 2);
422            }
423            _ => panic!("expected Fun node"),
424        }
425    }
426}