Skip to main content

ocas_atom/tensor/
canon.rs

1//! Tensor expression canonicalisation via graph isomorphism.
2//!
3//! Encodes a tensor-product expression (Mul of Fun nodes) into a graph whose
4//! vertex colours represent tensor heads / index slots and whose edges
5//! represent argument positions and contractions.  The graph-isomorphism
6//! engine [`super::graph`] then computes a canonical labelling, and the
7//! result is reconstructed as a normalised tensor expression with renamed
8//! dummy indices and reordered symmetric slots.
9
10use std::collections::HashMap;
11
12use crate::{Atom, AtomArena, AtomNode, Symbol};
13
14use super::graph::{CanonicalForm, Graph};
15use super::spec::TensorRegistry;
16
17/// Error while canonicalising a tensor expression.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TensorCanonError {
20    ContractedMoreThanOnce(Symbol),
21    BadContraction(Symbol),
22    NotATensor(Symbol),
23    InconsistentOpenIndices,
24    UnsupportedPower,
25}
26
27/// Result of canonicalising a tensor expression.
28#[derive(Debug, Clone)]
29pub struct CanonicalTensor<'a> {
30    pub canonical_form: Atom<'a>,
31    pub external_indices: Vec<Atom<'a>>,
32    pub dummy_indices: Vec<Atom<'a>>,
33}
34
35// =========================================================================
36// Public API
37// =========================================================================
38
39/// Canonicalise a tensor expression.
40pub fn canonicalize_tensors<'a>(
41    ctx: &'a AtomArena<'a>,
42    expr: Atom<'a>,
43    registry: &TensorRegistry,
44) -> Result<CanonicalTensor<'a>, TensorCanonError> {
45    match expr.node() {
46        AtomNode::Add(terms) => {
47            let mut canon_terms: Vec<Atom<'a>> = Vec::new();
48            let mut first_external: Option<Vec<Atom<'a>>> = None;
49            let mut all_dummies: Vec<Atom<'a>> = Vec::new();
50
51            for term in terms.iter() {
52                let ct = canonicalize_single_term(ctx, *term, registry)?;
53                match &first_external {
54                    None => first_external = Some(ct.external_indices.clone()),
55                    Some(ext) if *ext != ct.external_indices => {
56                        return Err(TensorCanonError::InconsistentOpenIndices);
57                    }
58                    _ => {}
59                }
60                all_dummies.extend(ct.dummy_indices);
61                canon_terms.push(ct.canonical_form);
62            }
63
64            let canonical_form = if canon_terms.len() == 1 {
65                canon_terms.pop().unwrap()
66            } else {
67                ctx.add(&canon_terms)
68            };
69            Ok(CanonicalTensor {
70                canonical_form,
71                external_indices: first_external.unwrap_or_default(),
72                dummy_indices: all_dummies,
73            })
74        }
75        _ => canonicalize_single_term(ctx, expr, registry),
76    }
77}
78
79fn canonicalize_single_term<'a>(
80    ctx: &'a AtomArena<'a>,
81    expr: Atom<'a>,
82    registry: &TensorRegistry,
83) -> Result<CanonicalTensor<'a>, TensorCanonError> {
84    let (g, head_nodes) = tensor_to_graph(ctx, expr, registry)?;
85    let cf = g.canonize();
86    reconstruct(ctx, &cf, &head_nodes, registry)
87}
88
89// =========================================================================
90// Graph encoding
91// =========================================================================
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
94enum TgNode {
95    Head(u64),
96    Slot(u64),
97    Scalar(u64),
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
101enum TgEdge {
102    HeadToSlot(usize, u8),
103    Contraction(u64),
104}
105
106#[derive(Debug, Clone)]
107#[allow(dead_code)]
108struct HeadInfo {
109    symbol: Symbol,
110    slot_count: usize,
111    head_v: usize,
112    slot_verts: Vec<usize>,
113}
114
115#[allow(clippy::type_complexity)]
116fn tensor_to_graph<'a>(
117    _ctx: &'a AtomArena<'a>,
118    expr: Atom<'a>,
119    registry: &TensorRegistry,
120) -> Result<(Graph<TgNode, usize, TgEdge>, Vec<HeadInfo>), TensorCanonError> {
121    let mut g: Graph<TgNode, usize, TgEdge> = Graph::new();
122    let mut heads: Vec<HeadInfo> = Vec::new();
123    let mut index_uses: HashMap<Atom<'a>, (Vec<usize>, usize)> = HashMap::new();
124
125    match expr.node() {
126        AtomNode::Mul(factors) => {
127            for f in factors.iter() {
128                encode_factor(*f, registry, &mut g, &mut heads, &mut index_uses)?;
129            }
130        }
131        _ => {
132            encode_factor(expr, registry, &mut g, &mut heads, &mut index_uses)?;
133        }
134    }
135
136    for (_label, (slot_verts, count)) in &index_uses {
137        if *count > 2 {
138            return Err(TensorCanonError::ContractedMoreThanOnce(Symbol::new(
139                &_label.to_string(),
140            )));
141        }
142        if *count == 2 {
143            let group = registry.index_group(Symbol::new(&_label.to_string()));
144            g.add_undirected_edge(slot_verts[0], slot_verts[1], TgEdge::Contraction(group));
145        }
146    }
147
148    Ok((g, heads))
149}
150
151fn encode_factor<'a>(
152    factor: Atom<'a>,
153    registry: &TensorRegistry,
154    g: &mut Graph<TgNode, usize, TgEdge>,
155    heads: &mut Vec<HeadInfo>,
156    index_uses: &mut HashMap<Atom<'a>, (Vec<usize>, usize)>,
157) -> Result<(), TensorCanonError> {
158    match factor.node() {
159        AtomNode::Fun(name, args) => {
160            let spec = registry
161                .spec(*name)
162                .ok_or(TensorCanonError::NotATensor(*name))?;
163            let head_v = g.add_node(TgNode::Head(hash(name.as_str())), 0);
164            let mut slot_verts = Vec::with_capacity(args.len());
165
166            for (pos, arg) in args.iter().enumerate() {
167                let label = *arg;
168                let hidden = if spec.is_slot_hidden(pos) { pos } else { 0 };
169                let slot_v = g.add_node(TgNode::Slot(hash(&label.to_string())), hidden);
170                slot_verts.push(slot_v);
171
172                let kind = if spec.is_slot_hidden(pos) {
173                    TgEdge::HeadToSlot(pos, 0)
174                } else {
175                    TgEdge::HeadToSlot(pos, 1)
176                };
177                g.add_directed_edge(head_v, slot_v, kind);
178
179                let entry = index_uses.entry(label).or_insert_with(|| (Vec::new(), 0));
180                entry.0.push(slot_v);
181                entry.1 += 1;
182            }
183
184            heads.push(HeadInfo {
185                symbol: *name,
186                slot_count: args.len(),
187                head_v,
188                slot_verts,
189            });
190        }
191        AtomNode::Pow(_, _) => return Err(TensorCanonError::UnsupportedPower),
192        _ => {
193            let h = hash(&factor.to_string());
194            g.add_node(TgNode::Scalar(h), 0);
195        }
196    }
197    Ok(())
198}
199
200// =========================================================================
201// Reconstruction
202// =========================================================================
203
204#[allow(clippy::type_complexity, clippy::needless_range_loop)]
205fn reconstruct<'a>(
206    ctx: &'a AtomArena<'a>,
207    cf: &CanonicalForm<TgNode, usize, TgEdge>,
208    heads: &[HeadInfo],
209    _registry: &TensorRegistry,
210) -> Result<CanonicalTensor<'a>, TensorCanonError> {
211    let cg = &cf.graph;
212    let n = cg.node_count();
213
214    // Map canonical → original vertex (vertex_map[pos] = original_vertex).
215    let orig_of = &cf.vertex_map;
216
217    // Find head→slot edges and contraction pairs in canonical graph.
218    let mut slot_contraction: HashMap<usize, (usize, u64)> = HashMap::new();
219    for v in 0..n {
220        for ev in cg.edges_of(v) {
221            if !ev.is_directed
222                && let TgEdge::Contraction(g) = ev.data
223            {
224                slot_contraction.insert(v, (ev.neighbour, g));
225            }
226        }
227    }
228
229    // Assign canonical dummy names to each contraction pair.
230    let mut group_counters: HashMap<u64, usize> = HashMap::new();
231    // Key: (min(cv1, cv2), max(cv1, cv2))
232    let mut pair_labels: HashMap<(usize, usize), Atom<'a>> = HashMap::new();
233
234    for v in 0..n {
235        for ev in cg.edges_of(v) {
236            if !ev.is_directed
237                && let TgEdge::Contraction(g) = ev.data
238            {
239                let a = v.min(ev.neighbour);
240                let b = v.max(ev.neighbour);
241                pair_labels.entry((a, b)).or_insert_with(|| {
242                    let cnt = group_counters.entry(g).or_insert(0);
243                    let label = if g == 0 {
244                        ctx.var(&format!("d{}", cnt))
245                    } else {
246                        ctx.var(&format!("d{}_{}", g, cnt))
247                    };
248                    *cnt += 1;
249                    label
250                });
251            }
252        }
253    }
254
255    // Collect canonical heads and their slots.
256    let mut canon_heads: Vec<(usize, &HeadInfo)> = Vec::new();
257    let mut orig_to_head: HashMap<usize, &HeadInfo> = HashMap::new();
258    for h in heads {
259        orig_to_head.insert(h.head_v, h);
260    }
261    for v in 0..n {
262        if let TgNode::Head(_) = cg.node_data(v) {
263            let orig = orig_of[v];
264            if let Some(h) = orig_to_head.get(&orig) {
265                canon_heads.push((v, *h));
266            }
267        }
268    }
269    canon_heads.sort_by_key(|(v, _)| *v);
270
271    // Build factors.
272    let mut factors: Vec<Atom<'a>> = Vec::new();
273    let mut all_dummies: Vec<Atom<'a>> = Vec::new();
274    let mut external_indices: Vec<Atom<'a>> = Vec::new();
275
276    for (can_head, h) in &canon_heads {
277        // Gather slot info.
278        let mut slot_infos: Vec<SlotInfo> = Vec::new();
279        for ev in cg.edges_of(*can_head) {
280            if ev.is_directed
281                && ev.is_outgoing
282                && let TgEdge::HeadToSlot(pos, hidden_flag) = ev.data
283            {
284                let partner = slot_contraction.get(&ev.neighbour).copied();
285                slot_infos.push(SlotInfo {
286                    orig_pos: pos,
287                    hidden: hidden_flag == 0,
288                    canon_slot_v: ev.neighbour,
289                    partner_v: partner.map(|(p, _)| p),
290                });
291            }
292        }
293
294        // Sort: hidden first (by hidden key), then visible (by position).
295        slot_infos.sort_by_key(|s| {
296            if s.hidden {
297                (0, s.orig_pos)
298            } else {
299                (1, s.orig_pos)
300            }
301        });
302
303        let mut args: Vec<Atom<'a>> = Vec::new();
304        for si in &slot_infos {
305            if let Some(pv) = si.partner_v {
306                let a = si.canon_slot_v.min(pv);
307                let b = si.canon_slot_v.max(pv);
308                if let Some(label) = pair_labels.get(&(a, b)) {
309                    args.push(*label);
310                    if !all_dummies.contains(label) {
311                        all_dummies.push(*label);
312                    }
313                } else {
314                    args.push(ctx.var("?"));
315                }
316            } else {
317                // External index.
318                let label = ctx.var(&format!("ext{}", external_indices.len()));
319                args.push(label);
320                if !external_indices.contains(&label) {
321                    external_indices.push(label);
322                }
323            }
324        }
325
326        factors.push(ctx.fun(h.symbol.as_str(), &args));
327    }
328
329    let canonical_form = if factors.is_empty() {
330        ctx.num(1)
331    } else if factors.len() == 1 {
332        factors.pop().unwrap()
333    } else {
334        ctx.mul(&factors)
335    };
336
337    Ok(CanonicalTensor {
338        canonical_form,
339        external_indices,
340        dummy_indices: all_dummies,
341    })
342}
343
344struct SlotInfo {
345    orig_pos: usize,
346    hidden: bool,
347    canon_slot_v: usize,
348    partner_v: Option<usize>,
349}
350
351fn hash(s: &str) -> u64 {
352    use std::hash::Hasher;
353    let mut h = std::collections::hash_map::DefaultHasher::new();
354    std::hash::Hash::hash(&s, &mut h);
355    h.finish()
356}
357
358// =========================================================================
359// Tests
360// =========================================================================
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::AtomArena;
366    use crate::Symbol;
367    use crate::tensor::spec::SymmetrySpec;
368    use ocas_core::arena::Arena;
369
370    #[test]
371    fn canon_single_tensor_no_symmetry() {
372        let arena = Arena::new();
373        let ctx = AtomArena::new(&arena);
374        let mut reg = TensorRegistry::new();
375        reg.register(Symbol::new("T"), SymmetrySpec::none());
376
377        let i = ctx.var("i");
378        let j = ctx.var("j");
379        let t = ctx.fun("T", &[i, j]);
380        let ct = canonicalize_tensors(&ctx, t, &reg).unwrap();
381        let s = ct.canonical_form.to_string();
382        assert!(s.contains("T"), "result: {s}");
383    }
384
385    #[test]
386    fn canon_product_with_contraction() {
387        let arena = Arena::new();
388        let ctx = AtomArena::new(&arena);
389        let mut reg = TensorRegistry::new();
390        reg.register(Symbol::new("T"), SymmetrySpec::none());
391        reg.register(Symbol::new("U"), SymmetrySpec::none());
392
393        let i = ctx.var("i");
394        let j = ctx.var("j");
395        let k = ctx.var("k");
396        let t = ctx.fun("T", &[i, j]);
397        let u = ctx.fun("U", &[j, k]);
398        let prod = ctx.mul(&[t, u]);
399        let ct = canonicalize_tensors(&ctx, prod, &reg).unwrap();
400        let s = ct.canonical_form.to_string();
401        // Should have at least one dummy and two tensors.
402        assert!(s.contains("d0"), "expected dummy d0, got: {s}");
403        assert!(s.contains('T') && s.contains('U'), "got: {s}");
404        assert_eq!(ct.dummy_indices.len(), 1);
405    }
406
407    #[test]
408    fn canon_symmetric_tensor_consistency() {
409        let arena = Arena::new();
410        let ctx = AtomArena::new(&arena);
411        let mut reg = TensorRegistry::new();
412        reg.register(Symbol::new("g"), SymmetrySpec::fully_symmetric(2));
413
414        let a = ctx.var("a");
415        let b = ctx.var("b");
416        let g_ab = ctx.fun("g", &[a, b]);
417        let g_ba = ctx.fun("g", &[b, a]);
418        let ct1 = canonicalize_tensors(&ctx, g_ab, &reg).unwrap();
419        let ct2 = canonicalize_tensors(&ctx, g_ba, &reg).unwrap();
420        // Both should canonicalise to the same form.
421        assert_eq!(
422            ct1.canonical_form.to_string(),
423            ct2.canonical_form.to_string(),
424            "symmetric slots should canonicalise consistently"
425        );
426    }
427}