Skip to main content

ocas_atom/tensor/
young.rs

1//! Explicit Young projector for tensor symmetries.
2//!
3//! Expands a tensor expression with a Young tableau symmetry into the sum
4//! `Σ_{σ∈R} Σ_{τ∈C} sgn(τ) · T(τ∘σ(slots))`, where `R` is the row group
5//! (permutations within each row, with sign +1) and `C` the column group
6//! (permutations within each column, signed by their parity).  This is the
7//! classical Young symmetrizer `a_λ · b_λ`, a projector up to a scalar factor.
8
9use crate::{Atom, AtomArena, AtomNode};
10
11/// A Young tableau: `row_lengths` defines the shape (e.g. `[2, 1]` for □□/□).
12/// The projector symmetrises within each row and antisymmetrises within each
13/// column: `c_λ = a_λ · b_λ` with `a_λ = Σ_{σ∈R} σ` and
14/// `b_λ = Σ_{τ∈C} sgn(τ) τ`.
15///
16/// This is an **explicit** expansion (not a BSGS group-theoretic one):
17/// the result is a sum of `∏ r_i! · ∏ c_j!` terms (row/column factorial
18/// products), each with sign ±1.
19#[derive(Debug, Clone)]
20pub struct YoungTableau {
21    /// Number of boxes in each row.
22    pub row_lengths: Vec<usize>,
23}
24
25impl YoungTableau {
26    /// Create a Young tableau from row lengths.
27    /// The total number of boxes must match `rank`.
28    pub fn new(row_lengths: Vec<usize>) -> Self {
29        Self { row_lengths }
30    }
31
32    /// Total number of boxes (= tensor rank).
33    pub fn total_boxes(&self) -> usize {
34        self.row_lengths.iter().sum()
35    }
36}
37
38/// All permutations of a set of box positions, as full position maps.
39///
40/// Each entry is `(map, sign)` where `map[j]` is the destination of box `j`
41/// (boxes outside the set are fixed), and `sign` is the parity of the
42/// permutation restricted to the set.
43fn box_permutations(boxes: &[usize], rank: usize) -> Vec<(Vec<usize>, i64)> {
44    fn build_map(boxes: &[usize], perm: &[usize], rank: usize) -> Vec<usize> {
45        let mut map: Vec<usize> = (0..rank).collect();
46        for (slot, &b) in boxes.iter().enumerate() {
47            map[b] = perm[slot];
48        }
49        map
50    }
51    let m = boxes.len();
52    if m == 0 {
53        return Vec::new();
54    }
55    let mut out = Vec::with_capacity(factorial(m));
56    let mut perm: Vec<usize> = boxes.to_vec();
57    let mut c = vec![0usize; m];
58    let mut sign: i64 = 1;
59    out.push((build_map(boxes, &perm, rank), sign));
60    // Heap's algorithm; each swap flips the parity.
61    let mut i = 1;
62    while i < m {
63        if c[i] < i {
64            if i % 2 == 0 {
65                perm.swap(0, i);
66            } else {
67                perm.swap(c[i], i);
68            }
69            sign = -sign;
70            out.push((build_map(boxes, &perm, rank), sign));
71            c[i] += 1;
72            i = 1;
73        } else {
74            c[i] = 0;
75            i += 1;
76        }
77    }
78    out
79}
80
81fn factorial(n: usize) -> usize {
82    (1..=n).product()
83}
84
85/// Apply a Young projector to a tensor expression.
86///
87/// Given a tensor `T(i1, i2, …, in)` and a tableau of shape λ, this expands it
88/// into the Young symmetrizer `c_λ = a_λ · b_λ`:
89///
90/// `Σ_{σ∈R} Σ_{τ∈C} sgn(τ) · T(i_{τσ(1)}, …, i_{τσ(n)})`
91///
92/// where `R` permutes slots within each row and `C` within each column.  The
93/// result is **not** normalized by a hook-length factor — it is a projector up
94/// to the scalar `c_λ² = (∏r_i!·∏c_j!) / dim(λ) · c_λ`.  For the fully
95/// antisymmetric tableau `[1, 1, …, 1]` this yields the standard alternating
96/// sum; for `[n]` the full symmetrization.
97pub fn young_project<'a>(
98    ctx: &'a AtomArena<'a>,
99    tensor_expr: Atom<'a>,
100    tableau: &YoungTableau,
101) -> Atom<'a> {
102    match tensor_expr.node() {
103        AtomNode::Fun(name, args) => {
104            let rank = tableau.total_boxes();
105            if args.len() != rank {
106                return tensor_expr;
107            }
108
109            // Row groups: contiguous box index ranges.
110            let mut rows: Vec<Vec<usize>> = Vec::new();
111            let mut idx = 0usize;
112            for &len in &tableau.row_lengths {
113                rows.push((idx..idx + len).collect());
114                idx += len;
115            }
116            // Column groups: box `k + c` for each row with `c < len`.
117            let columns = tableau.row_lengths.iter().copied().max().unwrap_or(0);
118            let mut cols: Vec<Vec<usize>> = Vec::new();
119            for c in 0..columns {
120                let mut col = Vec::new();
121                let mut k = 0usize;
122                for &len in &tableau.row_lengths {
123                    if c < len {
124                        col.push(k + c);
125                    }
126                    k += len;
127                }
128                if !col.is_empty() {
129                    cols.push(col);
130                }
131            }
132
133            // Build the row group R (all maps with sign +1).
134            let mut row_stack: Vec<(Vec<usize>, i64)> = vec![((0..rank).collect(), 1)];
135            for row in &rows {
136                let perms = box_permutations(row, rank);
137                let mut next = Vec::new();
138                for (m1, s1) in &row_stack {
139                    for (p, s2) in &perms {
140                        let mut composed = vec![0usize; rank];
141                        for j in 0..rank {
142                            composed[j] = m1[p[j]];
143                        }
144                        next.push((composed, s1 * s2));
145                    }
146                }
147                row_stack = next;
148            }
149            // Build the column group C with parity signs.
150            let mut col_stack: Vec<(Vec<usize>, i64)> = vec![((0..rank).collect(), 1)];
151            for col in &cols {
152                let perms = box_permutations(col, rank);
153                let mut next = Vec::new();
154                for (m1, s1) in &col_stack {
155                    for (p, s2) in &perms {
156                        let mut composed = vec![0usize; rank];
157                        for j in 0..rank {
158                            composed[j] = m1[p[j]];
159                        }
160                        next.push((composed, s1 * s2));
161                    }
162                }
163                col_stack = next;
164            }
165
166            let mut terms: Vec<Atom<'a>> = Vec::new();
167            for (tau, sign_tau) in &col_stack {
168                for (sigma, _) in &row_stack {
169                    // Element `j` moves to `tau[sigma[j]]`; build the inverse
170                    // map `perm[i] = original slot at position i`.
171                    let mut perm = vec![0usize; rank];
172                    for j in 0..rank {
173                        perm[tau[sigma[j]]] = j;
174                    }
175                    let reordered: Vec<Atom<'a>> = perm.iter().map(|&i| args[i]).collect();
176                    if *sign_tau == 1 {
177                        terms.push(ctx.fun(name.as_str(), &reordered));
178                    } else {
179                        terms.push(ctx.mul(&[ctx.num(-1), ctx.fun(name.as_str(), &reordered)]));
180                    }
181                }
182            }
183
184            if terms.is_empty() {
185                ctx.num(0)
186            } else if terms.len() == 1 {
187                terms.pop().unwrap()
188            } else {
189                ctx.add(&terms)
190            }
191        }
192        _ => tensor_expr,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::AtomArena;
200    use ocas_core::arena::Arena;
201
202    #[test]
203    fn antisymmetric_projector_two_slots() {
204        let arena = Arena::new();
205        let ctx = AtomArena::new(&arena);
206        let a = ctx.var("a");
207        let b = ctx.var("b");
208        let f_ab = ctx.fun("f", &[a, b]);
209        // Fully antisymmetric: tableau [1, 1].
210        let tableau = YoungTableau::new(vec![1, 1]);
211        let result = young_project(&ctx, f_ab, &tableau);
212        let s = result.to_string();
213        // Should be f(a,b) - f(b,a).
214        assert!(s.contains('-'), "expected subtraction: {s}");
215    }
216
217    #[test]
218    fn symmetric_projector_two_slots() {
219        let arena = Arena::new();
220        let ctx = AtomArena::new(&arena);
221        let a = ctx.var("a");
222        let b = ctx.var("b");
223        let f_ab = ctx.fun("f", &[a, b]);
224        // Fully symmetric: tableau [2].
225        let tableau = YoungTableau::new(vec![2]);
226        let result = young_project(&ctx, f_ab, &tableau);
227        let s = result.to_string();
228        // Should be f(a,b) + f(b,a).
229        assert!(s.contains('+'), "expected addition: {s}");
230    }
231
232    #[test]
233    fn antisymmetric_three_slots_zero() {
234        let arena = Arena::new();
235        let ctx = AtomArena::new(&arena);
236        let a = ctx.var("a");
237        let b = ctx.var("b");
238        let c = ctx.var("c");
239        let f = ctx.fun("f", &[a, b, c]);
240        let tableau = YoungTableau::new(vec![1, 1, 1]);
241        let result = young_project(&ctx, f, &tableau);
242        let s = result.to_string();
243        // Should be an alternating sum with 6 terms.
244        assert!(s.contains('+'), "expected sum: {s}");
245    }
246
247    #[test]
248    fn identity_preserves_single_slot() {
249        let arena = Arena::new();
250        let ctx = AtomArena::new(&arena);
251        let a = ctx.var("a");
252        let f = ctx.fun("f", &[a]);
253        // Single slot: tableau [1] — identity projector.
254        let result = young_project(&ctx, f, &YoungTableau::new(vec![1]));
255        // Result should just be f(a) itself (identity permutation).
256        assert_eq!(result.to_string(), "f(a)");
257    }
258
259    #[test]
260    fn non_tensor_expression_passthrough() {
261        let arena = Arena::new();
262        let ctx = AtomArena::new(&arena);
263        // Non-Fun expression (variable) → passthrough unchanged.
264        let x = ctx.var("x");
265        let result = young_project(&ctx, x, &YoungTableau::new(vec![2]));
266        assert_eq!(result.to_string(), "x");
267    }
268
269    #[test]
270    fn rank_mismatch_returns_original() {
271        let arena = Arena::new();
272        let ctx = AtomArena::new(&arena);
273        let a = ctx.var("a");
274        let b = ctx.var("b");
275        let f = ctx.fun("f", &[a, b]);
276        // Tableau requires 3 slots but tensor has rank 2 → return original.
277        let result = young_project(&ctx, f, &YoungTableau::new(vec![1, 1, 1]));
278        assert_eq!(result.to_string(), "f(a, b)");
279    }
280
281    #[test]
282    fn total_boxes_returns_rank() {
283        let tableau = YoungTableau::new(vec![2, 1]);
284        assert_eq!(tableau.total_boxes(), 3);
285        let tableau2 = YoungTableau::new(vec![1, 1, 1]);
286        assert_eq!(tableau2.total_boxes(), 3);
287    }
288
289    #[test]
290    fn mixed_shape_does_not_panic() {
291        // Regression: shape [2, 1] previously panicked in sign_of_permutation.
292        // The Young symmetrizer a_λ·b_λ has |R|·|C| = 2!·1! · 2!·1! = 4 terms.
293        let arena = Arena::new();
294        let ctx = AtomArena::new(&arena);
295        let a = ctx.var("a");
296        let b = ctx.var("b");
297        let c = ctx.var("c");
298        let f = ctx.fun("f", &[a, b, c]);
299        let tableau = YoungTableau::new(vec![2, 1]);
300        let result = young_project(&ctx, f, &tableau);
301        let s = result.to_string();
302        // c_λ = (e + (01))·(e − (02)) = f(a,b,c) + f(b,a,c) − f(c,b,a) − (021)·f
303        assert!(s.contains("f(a, b, c)"), "missing identity term: {s}");
304        assert!(s.contains("f(b, a, c)"), "missing row-swap term: {s}");
305        assert!(s.contains("f(c, b, a)"), "missing column-swap term: {s}");
306    }
307
308    #[test]
309    fn two_by_two_tableau() {
310        // Shape [2, 2]: |R| = 2!·2! = 4, |C| = 2!·2! = 4 → 16 terms, no panic.
311        let arena = Arena::new();
312        let ctx = AtomArena::new(&arena);
313        let args: Vec<Atom<'_>> = ["a", "b", "c", "d"].iter().map(|&x| ctx.var(x)).collect();
314        let f = ctx.fun("f", &args);
315        let tableau = YoungTableau::new(vec![2, 2]);
316        let result = young_project(&ctx, f, &tableau);
317        let s = result.to_string();
318        assert!(s.contains("f(a, b, c, d)"), "missing identity term: {s}");
319    }
320}