1use std::collections::HashMap;
11
12use crate::{Atom, AtomArena, AtomNode, Symbol};
13
14use super::graph::{CanonicalForm, Graph};
15use super::spec::TensorRegistry;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TensorCanonError {
20 ContractedMoreThanOnce(Symbol),
21 BadContraction(Symbol),
22 NotATensor(Symbol),
23 InconsistentOpenIndices,
24 UnsupportedPower,
25}
26
27#[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
35pub 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#[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#[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 let orig_of = &cf.vertex_map;
216
217 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 let mut group_counters: HashMap<u64, usize> = HashMap::new();
231 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 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 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 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 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 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#[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, ®).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, ®).unwrap();
400 let s = ct.canonical_form.to_string();
401 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, ®).unwrap();
419 let ct2 = canonicalize_tensors(&ctx, g_ba, ®).unwrap();
420 assert_eq!(
422 ct1.canonical_form.to_string(),
423 ct2.canonical_form.to_string(),
424 "symmetric slots should canonicalise consistently"
425 );
426 }
427}