Skip to main content

mdarray_linalg/
contract.rs

1//! Tensor contraction and matrix multiplication
2//!
3//!```rust
4//!use mdarray::tensor;
5//!use mdarray_linalg::prelude::*;
6//!use mdarray_linalg::Naive;
7//!
8//!let a = tensor![[1., 2.], [3., 4.]];
9//!let b = tensor![[5., 6.], [7., 8.]];
10//!
11//!// Standard matrix multiplication
12//!let expected_matmul = tensor![[19., 22.], [43., 50.]];
13//!let result = Naive.matmul(&a, &b).eval();
14//!assert_eq!(result, expected_matmul);
15//!
16//!// Matrix multiplication with scalar factor
17//!let result_scaled = Naive.matmul(&a, &b).scale(2.0).eval();
18//!assert_eq!(result_scaled, expected_matmul.map(|x| x * 2.0));
19//!
20//!// Full contraction
21//!let expected_all = 70.0;
22//!let result_all = Naive.contract_all(&a, &b);
23//!assert_eq!(result_all, expected_all);
24//!
25//!// Contract last n axes of a with first n axes of b
26//!let expected_n = tensor![[19., 22.], [43., 50.]].into_dyn();
27//!let result_contract_n = Naive.contract_n(&a, &b, 1).eval();
28//!assert_eq!(result_contract_n, expected_n);
29//!
30//!// Contract specific axes (equivalent to matmul: contract axis 1 of a with axis 0 of b)
31//!let expected_pairs = tensor![[19., 22.], [43., 50.]].into_dyn();
32//!let result_specific = Naive
33//!    .contract_pairs(&a, &b, &[1], &[0])
34//!    .eval();
35//!assert_eq!(result_specific, expected_pairs);
36//!```
37
38use std::iter::Sum;
39use std::ops::AddAssign;
40
41use mdarray::{Array, Dim, DynRank, Layout, Shape, Slice, View};
42use num_complex::ComplexFloat;
43use num_traits::{MulAdd, One, Zero};
44
45/// Tensor contraction and related operations
46pub trait Contract<T> {
47    /// Matrix multiplication.
48    ///
49    /// ```rust
50    /// use mdarray::tensor;
51    /// use mdarray_linalg::{Naive, prelude::*};
52    ///
53    /// let a = tensor![[1., 2.], [3., 4.]];
54    /// let b = tensor![[5., 6.], [7., 8.]];
55    /// assert_eq!(Naive.matmul(&a, &b).eval(), tensor![[19., 22.], [43., 50.]]);
56    /// ```
57    fn matmul<'a, D0, D1, D2, La, Lb>(
58        &self,
59        a: &'a Slice<T, (D0, D1), La>,
60        b: &'a Slice<T, (D1, D2), Lb>,
61    ) -> impl MatmulBuilder<'a, T, D0, D1, D2, La, Lb>
62    where
63        D0: Dim,
64        D1: Dim,
65        D2: Dim,
66        La: Layout,
67        Lb: Layout;
68
69    /// Contracts all axes of `a` with all axes of `b`.
70    ///
71    /// This is the full reduction case, i.e. a scalar result.
72    fn contract_all<'a, Sa, Sb, La, Lb>(
73        &self,
74        a: &'a Slice<T, Sa, La>,
75        b: &'a Slice<T, Sb, Lb>,
76    ) -> T
77    where
78        T: 'a,
79        Sa: Shape,
80        Sb: Shape,
81        La: Layout,
82        Lb: Layout;
83
84    /// Contracts the last `n` axes of `a` with the first `n` axes of `b`.
85    ///
86    /// For matrices, `contract_n(1)` is standard matrix multiplication.
87    fn contract_n<'a, Sa, Sb, La, Lb>(
88        &self,
89        a: &'a Slice<T, Sa, La>,
90        b: &'a Slice<T, Sb, Lb>,
91        n: usize,
92    ) -> impl ContractBuilder<'a, T, Sa, Sb, La, Lb>
93    where
94        T: 'a,
95        Sa: Shape,
96        Sb: Shape,
97        La: Layout,
98        Lb: Layout;
99
100    /// Contracts explicit pairs of axes.
101    ///
102    /// This is the structured contraction API.
103    /// `contract_pairs(&a, &b, &[1], &[0])` is matrix multiplication for 2D inputs.
104    fn contract_pairs<'a, Sa, Sb, La, Lb>(
105        &self,
106        a: &'a Slice<T, Sa, La>,
107        b: &'a Slice<T, Sb, Lb>,
108        axes_a: &'a [usize],
109        axes_b: &'a [usize],
110    ) -> impl ContractBuilder<'a, T, Sa, Sb, La, Lb>
111    where
112        T: 'a,
113        Sa: Shape,
114        Sb: Shape,
115        La: Layout,
116        Lb: Layout;
117
118    /// Fully general contraction of two tensors, à la einsum.
119    ///
120    /// ```rust
121    /// use mdarray::array;
122    /// use mdarray_linalg::{Naive, prelude::*};
123    ///
124    /// let a = array![[1., 2.], [3., 4.]].into_dyn();
125    /// let b = array![[5., 6.], [7., 8.]].into_dyn();
126    /// let c = Naive.contract(&a, &b, &[0, 1], &[1, 2], &[0, 2]).eval();
127    /// assert_eq!(c, array![[19., 22.], [43., 50.]].into_dyn());
128    /// ```
129    ///
130    /// *New* indices in `indices_a` and `indices_b` *must* be subsequent integers starting with 0.
131    /// For example, having `[0, 1, 1, 2]` or `[0, 1, 0, 2]` for `indices_a` is OK, but `[0, 2, 2, 3]` is not.
132    /// Note that this is not limiting in any way.  Any legal einsum can be specified in this way.
133    ///
134    /// Note that this is a low-level operation.  The above restrictions allow to avoid runtime checks.
135    /// We will add a more user-friendly higher-level wrapper.
136    fn contract<'a, Sa, Sb, La, Lb>(
137        &self,
138        a: &'a Slice<T, Sa, La>,
139        b: &'a Slice<T, Sb, Lb>,
140        indices_a: &'a [u8],
141        indices_b: &'a [u8],
142        indices_c: &'a [u8],
143    ) -> impl ContractBuilder<'a, T, Sa, Sb, La, Lb>
144    where
145        T: 'a,
146        Sa: Shape,
147        Sb: Shape,
148        La: Layout,
149        Lb: Layout;
150}
151
152/// Builder interface for configuring matrix-matrix operations
153pub trait MatmulBuilder<'a, T, D0, D1, D2, La, Lb>
154where
155    T: 'a,
156    D0: Dim,
157    D1: Dim,
158    D2: Dim,
159    La: 'a + Layout,
160    Lb: 'a + Layout,
161{
162    /// Multiplies the result by a scalar factor.
163    fn scale(self, factor: T) -> Self;
164
165    /// Returns a new owned tensor containing the result.
166    fn eval(self) -> Array<T, (D0, D2)>;
167
168    /// Overwrites the provided slice with the result.
169    fn write<Lc: Layout>(self, c: &mut Slice<T, (D0, D2), Lc>);
170
171    /// Adds the result to the provided slice.
172    fn add_to<Lc: Layout>(self, c: &mut Slice<T, (D0, D2), Lc>);
173
174    /// Adds the result to the provided slice after scaling the slice by `beta`
175    /// (i.e. C := beta * C + result).
176    fn add_to_scaled<Lc: Layout>(self, c: &mut Slice<T, (D0, D2), Lc>, beta: T);
177}
178
179/// Builder interface for configuring tensor contraction operations
180pub trait ContractBuilder<'a, T, Sa, Sb, La, Lb>
181where
182    T: 'a,
183    La: Layout,
184    Lb: Layout,
185{
186    /// Multiplies the result by a scalar factor.
187    fn scale(self, factor: T) -> Self;
188
189    /// Returns a new owned tensor containing the result.
190    fn eval(self) -> Array<T, DynRank>;
191
192    /// Overwrites the provided slice with the result.
193    fn write<Sc: Shape, Lc: Layout>(self, c: &mut Slice<T, Sc, Lc>);
194
195    /// Adds the result to the provided slice.
196    fn add_to<Sc: Shape, Lc: Layout>(self, c: &mut Slice<T, Sc, Lc>);
197
198    /// Adds the result to the provided slice after scaling the slice by `beta`
199    /// (i.e. C := beta * C + result).
200    fn add_to_scaled<Sc: Shape, Lc: Layout>(self, c: &mut Slice<T, Sc, Lc>, beta: T);
201}
202
203// The following exported items are unstable backend-implementation helpers.
204// They are hidden from generated documentation and may be redesigned before
205// the public API stabilizes.
206#[doc(hidden)]
207#[derive(Clone)]
208pub enum Axes<'a> {
209    All,
210    LastFirst { k: usize },
211    Specific(&'a [usize], &'a [usize]),
212    SpecificOwned(Vec<usize>, Vec<usize>),
213}
214
215#[doc(hidden)]
216pub struct ContractAxes {
217    pub keep_size_a: usize,
218    pub keep_size_b: usize,
219    pub contract_size: usize,
220    pub keep_shape_a: Vec<usize>,
221    pub keep_shape_b: Vec<usize>,
222    pub order_a: Vec<usize>,
223    pub order_b: Vec<usize>,
224}
225
226/// Resolves the axis partition for a tensor contraction, avoiding
227/// allocations when axes are already provided as slices
228/// (`Axes::Specific`).
229#[doc(hidden)]
230pub fn extract_axes<T, Sa, Sb, La, Lb>(
231    axes: Axes,
232    a: &Slice<T, Sa, La>,
233    b: &Slice<T, Sb, Lb>,
234) -> ContractAxes
235where
236    T: Zero + ComplexFloat + MulAdd<Output = T>,
237    La: Layout,
238    Lb: Layout,
239    Sa: Shape,
240    Sb: Shape,
241{
242    let rank_a = a.rank();
243    let rank_b = b.rank();
244
245    let axes_a_storage: Option<Vec<usize>>;
246    let axes_b_storage: Option<Vec<usize>>;
247
248    let (axes_a, axes_b): (&[usize], &[usize]) = match axes {
249        Axes::All => {
250            axes_a_storage = Some((0..rank_a).collect());
251            axes_b_storage = Some((0..rank_b).collect());
252            (
253                axes_a_storage.as_deref().unwrap(),
254                axes_b_storage.as_deref().unwrap(),
255            )
256        }
257        Axes::LastFirst { k } => {
258            axes_a_storage = Some(((rank_a - k)..rank_a).collect());
259            axes_b_storage = Some((0..k).collect());
260            (
261                axes_a_storage.as_deref().unwrap(),
262                axes_b_storage.as_deref().unwrap(),
263            )
264        }
265        Axes::Specific(ax_a, ax_b) => (ax_a, ax_b),
266        Axes::SpecificOwned(ax_a, ax_b) => {
267            axes_a_storage = Some(ax_a);
268            axes_b_storage = Some(ax_b);
269            (
270                axes_a_storage.as_deref().unwrap(),
271                axes_b_storage.as_deref().unwrap(),
272            )
273        }
274    };
275
276    assert_eq!(
277        axes_a.len(),
278        axes_b.len(),
279        "Axis count mismatch: {} (tensor A) vs {} (tensor B)",
280        axes_a.len(),
281        axes_b.len()
282    );
283
284    let mut contract_size = 1;
285    for (&a_ax, &b_ax) in axes_a.iter().zip(axes_b) {
286        assert_eq!(
287            a.dim(a_ax),
288            b.dim(b_ax),
289            "Dimension mismatch at contraction: A[axis {}] = {} ≠ B[axis {}] = {}",
290            a_ax,
291            a.dim(a_ax),
292            b_ax,
293            b.dim(b_ax)
294        );
295        contract_size *= a.dim(a_ax);
296    }
297
298    let mut keep_shape_a = Vec::new();
299    let mut keep_size_a = 1;
300    let mut order_a = Vec::with_capacity(rank_a);
301
302    for i in 0..rank_a {
303        if !axes_a.contains(&i) {
304            keep_shape_a.push(a.dim(i));
305            keep_size_a *= a.dim(i);
306            order_a.push(i);
307        }
308    }
309    order_a.extend_from_slice(axes_a);
310
311    let mut keep_shape_b = Vec::new();
312    let mut keep_size_b = 1;
313    let mut order_b = Vec::with_capacity(rank_b);
314    order_b.extend_from_slice(axes_b);
315
316    for i in 0..rank_b {
317        if !axes_b.contains(&i) {
318            keep_shape_b.push(b.dim(i));
319            keep_size_b *= b.dim(i);
320            order_b.push(i);
321        }
322    }
323
324    ContractAxes {
325        keep_size_a,
326        keep_size_b,
327        contract_size,
328        keep_shape_a,
329        keep_shape_b,
330        order_a,
331        order_b,
332    }
333}
334
335#[doc(hidden)]
336#[macro_export]
337macro_rules! prepare_contraction {
338    ($axes:expr, $a:expr, $b:expr) => {{
339        let ContractAxes {
340            keep_size_a,
341            keep_size_b,
342            contract_size,
343            keep_shape_a,
344            keep_shape_b,
345            order_a,
346            order_b,
347            ..
348        } = extract_axes($axes, $a, $b);
349
350        let trans_a = $a.permute(order_a).to_tensor();
351        let a_2d = trans_a.reshape([keep_size_a, contract_size]).to_tensor();
352
353        let trans_b = $b.permute(order_b).to_tensor();
354        let b_2d = trans_b.reshape([contract_size, keep_size_b]).to_tensor(); // TODO remove this useless copy
355
356        (a_2d, b_2d, keep_shape_a, keep_shape_b)
357    }};
358}
359
360#[doc(hidden)]
361#[macro_export]
362macro_rules! finish_contraction {
363    ($ab:expr, $keep_shape_a:expr, $keep_shape_b:expr) => {{
364        let mut keep_shape_a = $keep_shape_a;
365        let keep_shape_b = $keep_shape_b;
366
367        if keep_shape_a.is_empty() && keep_shape_b.is_empty() {
368            mdarray::Array::from_elem((), $ab.into_scalar()).into_dyn()
369        } else if keep_shape_a.is_empty() {
370            $ab.view(0, ..)
371                .reshape(keep_shape_b)
372                .to_owned()
373                .into_dyn()
374                .into()
375        } else if keep_shape_b.is_empty() {
376            $ab.view(.., 0)
377                .reshape(keep_shape_a)
378                .to_owned()
379                .into_dyn()
380                .into()
381        } else {
382            keep_shape_a.extend(keep_shape_b);
383            $ab.reshape(keep_shape_a).to_owned().into_dyn().into()
384        }
385    }};
386}
387
388/// Helper for implementing contraction through matrix multiplication.
389/// Backends that implement `Contract` directly should call the macros
390/// `prepare_contraction!` and `finish_contraction!` themselves.
391#[doc(hidden)]
392pub fn _contract<T, La, Lb, Sa, Sb>(
393    bd: impl Contract<T>,
394    a: &Slice<T, Sa, La>,
395    b: &Slice<T, Sb, Lb>,
396    axes: Axes,
397    alpha: T,
398) -> Array<T, DynRank>
399where
400    T: Zero + ComplexFloat + MulAdd<Output = T>,
401    La: Layout,
402    Lb: Layout,
403    Sa: Shape,
404    Sb: Shape,
405{
406    if let Some(shape) = _empty_contract_shape(a, b, &axes) {
407        return Array::from_elem(shape, T::zero());
408    }
409
410    let (a_2d, b_2d, keep_shape_a, keep_shape_b) = prepare_contraction!(axes, a, b);
411
412    let ab = bd.matmul(&a_2d, &b_2d).scale(alpha).eval();
413
414    finish_contraction!(ab, keep_shape_a, keep_shape_b)
415}
416
417// Retain the guard against mdarray 0.8.0's empty strided-expression iteration
418// bug (fixed in 0.8.1). Check before any copy, materialization or backend call.
419// Empty inputs give either an empty output or a nonempty sum of zero terms.
420#[doc(hidden)]
421pub fn _empty_contract_shape<T, Sa, Sb, La, Lb>(
422    a: &Slice<T, Sa, La>,
423    b: &Slice<T, Sb, Lb>,
424    axes: &Axes<'_>,
425) -> Option<Vec<usize>>
426where
427    T: Zero + ComplexFloat + MulAdd<Output = T>,
428    Sa: Shape,
429    Sb: Shape,
430    La: Layout,
431    Lb: Layout,
432{
433    if !a.is_empty() && !b.is_empty() {
434        return None;
435    }
436    // Still validate contracted dimensions, even when the result is empty.
437    let axes = extract_axes(axes.clone(), a, b);
438    let mut shape = axes.keep_shape_a;
439    shape.extend(axes.keep_shape_b);
440    Some(shape)
441}
442
443#[doc(hidden)]
444pub fn _empty_einsum_shape<T, Sa, Sb, La, Lb>(
445    a: &Slice<T, Sa, La>,
446    b: &Slice<T, Sb, Lb>,
447    indices_a: &[u8],
448    indices_b: &[u8],
449    indices_c: &[u8],
450) -> Option<Vec<usize>>
451where
452    Sa: Shape,
453    Sb: Shape,
454    La: Layout,
455    Lb: Layout,
456{
457    if !a.is_empty() && !b.is_empty() {
458        return None;
459    }
460    assert_eq!(
461        indices_a.len(),
462        a.rank(),
463        "einsum indices_a length must match A rank"
464    );
465    assert_eq!(
466        indices_b.len(),
467        b.rank(),
468        "einsum indices_b length must match B rank"
469    );
470
471    let mut dimensions = std::collections::HashMap::new();
472    for (label, dim) in indices_a
473        .iter()
474        .copied()
475        .zip((0..a.rank()).map(|i| a.dim(i)))
476        .chain(
477            indices_b
478                .iter()
479                .copied()
480                .zip((0..b.rank()).map(|i| b.dim(i))),
481        )
482    {
483        if let Some(previous) = dimensions.insert(label, dim) {
484            assert_eq!(previous, dim, "dimension mismatch for einsum label {label}");
485        }
486    }
487    let mut seen = std::collections::HashSet::new();
488    Some(
489        indices_c
490            .iter()
491            .map(|label| {
492                assert!(seen.insert(*label), "duplicate output label {label}");
493                *dimensions
494                    .get(label)
495                    .expect("output label not present in inputs")
496            })
497            .collect(),
498    )
499}
500
501fn _empty_hypercontract_shape<T, Sa, Sb, La, Lb>(
502    a: &Slice<T, Sa, La>,
503    b: &Slice<T, Sb, Lb>,
504    axes_a: &[usize],
505    axes_b: &[usize],
506) -> Option<Vec<usize>>
507where
508    Sa: Shape,
509    Sb: Shape,
510    La: Layout,
511    Lb: Layout,
512{
513    if !a.is_empty() && !b.is_empty() {
514        return None;
515    }
516    let mut dimensions = std::collections::HashMap::new();
517    let mut shape = Vec::new();
518    for (label, dim) in axes_a
519        .iter()
520        .copied()
521        .zip((0..a.rank()).map(|i| a.dim(i)))
522        .chain(axes_b.iter().copied().zip((0..b.rank()).map(|i| b.dim(i))))
523    {
524        if label == FREE_AXIS {
525            shape.push(dim);
526        } else if let Some(previous) = dimensions.insert(label, dim) {
527            assert_eq!(
528                previous, dim,
529                "dimension mismatch for contraction label {label}"
530            );
531        }
532    }
533    Some(shape)
534}
535
536#[doc(hidden)]
537pub const FREE_AXIS: usize = usize::MAX;
538
539/// General contraction on labeled axes.
540///
541/// `axes_a` and `axes_b` have one entry per original axis. Equal values belong
542/// to the same contraction/diagonalization hyper-edge. `FREE_AXIS` marks axes
543/// that remain in the output.
544#[doc(hidden)]
545pub fn _hypercontract<T>(
546    bd: impl Contract<T>,
547    a: View<'_, T, DynRank>,
548    b: View<'_, T, DynRank>,
549    axes_a: &[usize],
550    axes_b: &[usize],
551) -> Array<T, DynRank>
552where
553    T: Copy + Zero + One + Sum + AddAssign + MulAdd<Output = T> + ComplexFloat,
554{
555    assert_eq!(
556        axes_a.len(),
557        a.rank(),
558        "hypercontract axes_a length ({}) must match A rank ({})",
559        axes_a.len(),
560        a.rank()
561    );
562    assert_eq!(
563        axes_b.len(),
564        b.rank(),
565        "hypercontract axes_b length ({}) must match B rank ({})",
566        axes_b.len(),
567        b.rank()
568    );
569
570    if let Some(shape) = _empty_hypercontract_shape(&a, &b, axes_a, axes_b) {
571        return Array::from_elem(shape, T::zero());
572    }
573
574    let edges = axes_to_hyperedges(axes_a, axes_b);
575
576    // Owned buffers for A and B. Allocated only when a transformation
577    // (diagonal extraction or summation) actually modifies the tensor
578    let mut a_owned: Option<Array<T, DynRank>> = None;
579    let mut b_owned: Option<Array<T, DynRank>> = None;
580
581    // map_x[i] = current position of original axis i in the transformed tensor.
582    // Initialised to the identity: no transformation has occurred yet.
583    let mut map_a: Vec<usize> = (0..a.shape().dims().len()).collect();
584    let mut map_b: Vec<usize> = (0..b.shape().dims().len()).collect();
585
586    // Contraction axis pairs accumulated from (Some, Some) edges,
587    // consumed in one shot by the final contraction.
588    let mut axes_a: Vec<usize> = Vec::new();
589    let mut axes_b: Vec<usize> = Vec::new();
590
591    for edge in &edges {
592        let (idx_a, idx_b) = edge;
593
594        match (idx_a, idx_b) {
595            // Delta attached to A only: diagonal-sum on A, no contraction with B.
596            (Some(axes), None) => {
597                let view = a_owned
598                    .as_ref()
599                    .map(|o| o.expr())
600                    .unwrap_or_else(|| a.clone());
601                a_owned = Some(apply_hypersum(&view, axes, &mut map_a));
602            }
603            // Delta attached to B only: diagonal-sum on B, no contraction with A.
604            (None, Some(axes)) => {
605                let view = b_owned
606                    .as_ref()
607                    .map(|o| o.expr())
608                    .unwrap_or_else(|| b.clone());
609                b_owned = Some(apply_hypersum(&view, axes, &mut map_b));
610            }
611            // Delta bridges A and B: prepare one contraction axis on each side.
612            // The actual dot-product is deferred to the final contraction below.
613            (Some(axes_a_idx), Some(axes_b_idx)) => {
614                let ax_a = {
615                    let view = a_owned
616                        .as_ref()
617                        .map(|o| o.expr())
618                        .unwrap_or_else(|| a.clone());
619                    let (arr, ax) = extract_hyperdiag(view, axes_a_idx, &mut map_a);
620                    a_owned = Some(arr);
621                    ax
622                };
623                let ax_b = {
624                    let view = b_owned
625                        .as_ref()
626                        .map(|o| o.expr())
627                        .unwrap_or_else(|| b.clone());
628                    let (arr, ax) = extract_hyperdiag(view, axes_b_idx, &mut map_b);
629                    b_owned = Some(arr);
630                    ax
631                };
632                axes_a.push(ax_a);
633                axes_b.push(ax_b);
634            }
635
636            (None, None) => {}
637        }
638    }
639
640    // Final contraction: contracts all (Some, Some) edge axes simultaneously.
641    let final_a = a_owned
642        .as_ref()
643        .map(|o| o.expr())
644        .unwrap_or_else(|| a.clone());
645    let final_b = b_owned
646        .as_ref()
647        .map(|o| o.expr())
648        .unwrap_or_else(|| b.clone());
649
650    _contract(
651        bd,
652        &final_a,
653        &final_b,
654        Axes::SpecificOwned(axes_a, axes_b),
655        T::one(),
656    )
657}
658
659/// Generalized diagonal extraction along an arbitrary set of axes (zero-copy)
660#[doc(hidden)]
661pub fn hyperdiagonal<'a, T, L: Layout>(
662    a: View<'a, T, DynRank, L>,
663    axes: &[usize],
664) -> View<'a, T, DynRank, mdarray::Strided> {
665    let mut axes_sorted = axes.to_vec();
666    axes_sorted.sort_unstable();
667    axes_sorted.dedup();
668
669    let dims = a.shape().dims();
670    let rank = dims.len();
671
672    for &ax in &axes_sorted {
673        assert!(ax < rank, "axis ({ax}) out of bounds for rank {rank}");
674    }
675
676    // All diagonal axes must have the same size.
677    let m = dims[*axes_sorted.first().unwrap()];
678    for &ax in &axes_sorted {
679        let n = dims[ax];
680        assert!(
681            m == n,
682            "all diagonal axes must have equal size, got {m} and {n}"
683        );
684    }
685
686    // Build output shape: drop all diagonal axes, ...
687    let mut out_dims: Vec<usize> = Vec::with_capacity(rank - axes_sorted.len() + 1);
688    let mut out_strides: Vec<isize> = Vec::with_capacity(rank - axes_sorted.len() + 1);
689
690    for (i, item) in dims.iter().enumerate() {
691        if axes_sorted.binary_search(&i).is_ok() {
692            continue;
693        }
694        out_dims.push(*item);
695        out_strides.push(a.stride(i));
696    }
697
698    // ... and append the diagonal axis as the last one.
699    out_dims.push(m);
700    out_strides.push(axes_sorted.iter().map(|&ax| a.stride(ax)).sum());
701
702    let mapping = mdarray::StridedMapping::new(Shape::from_dims(&out_dims), &out_strides);
703
704    // SAFETY:
705    // - `a.as_ptr()` is valid and live for `'a` (inherited from input view).
706    // - Let upper = Σ_{i<r} (dims[i] - 1) * stride(i) be the maximum offset
707    //   reachable in `a`'s allocation. Any index over the output view is
708    //   (a_0, ..., a_{r-|axes|-1}, k) with a_j < dims[j] for j ∉ axes_set
709    //   and k < m = dims[ax] for all ax ∈ axes_set. The offset decomposes as:
710    //   Σ_{j ∉ axes_set} a_j * stride(j)  +  k * Σ_{ax ∈ axes_set} stride(ax)
711    //   Each term is bounded by (dims[j]-1)*stride(j), so the total ≤ upper.
712    unsafe { View::new_unchecked(a.as_ptr(), mapping) }
713}
714
715/// Sum a tensor over an arbitrary subset of its axes.
716/// Equivalent to a sequence of `np.sum(a, axis=s)` calls (with appropriate
717/// index renumbering after each removal), but performed in a single pass.
718#[doc(hidden)]
719pub fn hypersum<T, L: Layout>(a: &View<'_, T, DynRank, L>, axes: &[usize]) -> Array<T, DynRank>
720where
721    T: std::iter::Sum + Copy + Zero + std::ops::AddAssign,
722{
723    let mut axes_sorted = axes.to_vec();
724    axes_sorted.sort_unstable();
725    axes_sorted.dedup();
726
727    let dims = a.shape().dims();
728    let rank = dims.len();
729
730    for &ax in &axes_sorted {
731        assert!(ax < rank, "axis ({ax}) out of bounds for rank {rank}");
732    }
733
734    let out_dims: Vec<usize> = (0..rank)
735        .filter(|i| axes_sorted.binary_search(i).is_err())
736        .map(|i| dims[i])
737        .collect();
738
739    let mut out = Array::from_elem(out_dims, T::zero());
740
741    for idx in odometer(dims) {
742        let out_idx: Vec<usize> = (0..rank)
743            .filter(|i| axes_sorted.binary_search(i).is_err())
744            .map(|i| idx[i])
745            .collect();
746        out[out_idx.as_slice()] += a[idx.as_slice()];
747    }
748
749    out
750}
751
752/// Row-major multi-index iterator over a tensor of the given shape.
753///
754/// Yields every index tuple (i₀, i₁, …, i_{r−1}) in lexicographic order
755/// (last axis varies fastest — C-contiguous order).
756fn odometer(dims: &[usize]) -> impl Iterator<Item = Vec<usize>> + '_ {
757    let total: usize = dims.iter().product();
758    let mut idx = vec![0usize; dims.len()];
759    let mut first = true;
760
761    (0..total).map(move |_| {
762        if first {
763            first = false;
764        } else {
765            for i in (0..dims.len()).rev() {
766                idx[i] += 1;
767                if idx[i] < dims[i] {
768                    break;
769                }
770                idx[i] = 0;
771            }
772        }
773        idx.clone()
774    })
775}
776
777/// Recompute the axis-position map after `hyperdiagonal` has been applied.
778fn update_axis_map(axis_map: &[usize], diag_axes: &[usize], ndim_after: usize) -> Vec<usize> {
779    let removed: Vec<usize> = {
780        let mut v = diag_axes.to_vec();
781        v.sort_unstable();
782        v
783    };
784    let new_diag_pos = ndim_after - 1;
785
786    axis_map
787        .iter()
788        .map(|&cur| {
789            if diag_axes.contains(&cur) {
790                new_diag_pos
791            } else {
792                let shift = removed.iter().filter(|&&r| r < cur).count();
793                cur - shift
794            }
795        })
796        .collect()
797}
798
799/// Process a `(Some, None)` or `(None, Some)` edge: diagonal-sum on one tensor.
800///
801/// This corresponds to a Kronecker delta that is only connected to one side of
802/// the network.
803fn apply_hypersum<T, L: Layout>(
804    view: &View<'_, T, DynRank, L>,
805    idx: &[usize],
806    axis_map: &mut Vec<usize>,
807) -> Array<T, DynRank>
808where
809    T: Copy + Zero + std::iter::Sum + std::ops::AddAssign,
810{
811    // Translate original axis indices to their current positions.
812    let cur_axes: Vec<usize> = idx.iter().map(|&a| axis_map[a]).collect();
813
814    if cur_axes.len() == 1 {
815        let ax = cur_axes[0];
816        let result = hypersum(view, &[ax]);
817        for cur in axis_map.iter_mut() {
818            if *cur > ax {
819                *cur -= 1;
820            }
821        }
822        result
823    } else {
824        let diag = hyperdiagonal(view.clone(), &cur_axes);
825
826        let diag_ax = diag.shape().dims().len() - 1;
827        *axis_map = update_axis_map(axis_map, &cur_axes, diag.shape().dims().len());
828
829        let result = hypersum(&diag.into_dyn(), &[diag_ax]);
830        for cur in axis_map.iter_mut() {
831            if *cur > diag_ax {
832                *cur -= 1;
833            }
834        }
835        result
836    }
837}
838
839/// Process a `(Some, Some)` edge: prepare the contraction axis.
840///
841/// For a delta that bridges A and B, we do *not* contract immediately. Instead
842/// we reduce the multi-axis delta to a single axis (by extracting the
843/// generalized diagonal when needed) and return its current position so that
844/// `hypercontract` can pass it to the final contraction.
845fn extract_hyperdiag<T, L: Layout>(
846    view: View<'_, T, DynRank, L>,
847    idx: &[usize],
848    axis_map: &mut Vec<usize>,
849) -> (Array<T, DynRank>, usize)
850where
851    T: Copy,
852{
853    // Translate original axis indices to their current positions.
854    let cur_axes: Vec<usize> = idx.iter().map(|&a| axis_map[a]).collect();
855
856    if cur_axes.len() == 1 {
857        // Single-axis edge: no diagonal to extract, axis_map is unchanged.
858        let ax = cur_axes[0];
859        (view.to_owned().into(), ax)
860    } else {
861        let diag = hyperdiagonal(view, &cur_axes);
862        let diag_ax = diag.shape().dims().len() - 1;
863        *axis_map = update_axis_map(axis_map, &cur_axes, diag.shape().dims().len());
864        (diag.to_owned().into(), diag_ax)
865    }
866}
867
868fn axes_to_hyperedges(
869    axes_a: &[usize],
870    axes_b: &[usize],
871) -> Vec<(Option<Vec<usize>>, Option<Vec<usize>>)> {
872    let mut remap: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
873    let mut edges: Vec<(Option<Vec<usize>>, Option<Vec<usize>>)> = Vec::new();
874
875    for (axis, &label) in axes_a.iter().enumerate() {
876        if label == FREE_AXIS {
877            continue;
878        }
879
880        let edge = *remap.entry(label).or_insert_with(|| {
881            edges.push((None, None));
882            edges.len() - 1
883        });
884        edges[edge].0.get_or_insert_with(Vec::new).push(axis);
885    }
886
887    for (axis, &label) in axes_b.iter().enumerate() {
888        if label == FREE_AXIS {
889            continue;
890        }
891
892        let edge = *remap.entry(label).or_insert_with(|| {
893            edges.push((None, None));
894            edges.len() - 1
895        });
896        edges[edge].1.get_or_insert_with(Vec::new).push(axis);
897    }
898
899    edges
900}
901
902#[doc(hidden)]
903pub fn einsum_to_contract_axes(
904    indices_a: &[u8],
905    indices_b: &[u8],
906    indices_c: &[u8],
907) -> (Vec<usize>, Vec<usize>) {
908    let free: std::collections::HashSet<u8> = indices_c.iter().copied().collect();
909
910    let axes_a = indices_a
911        .iter()
912        .map(|&label| {
913            if free.contains(&label) {
914                FREE_AXIS
915            } else {
916                label as usize
917            }
918        })
919        .collect();
920
921    let axes_b = indices_b
922        .iter()
923        .map(|&label| {
924            if free.contains(&label) {
925                FREE_AXIS
926            } else {
927                label as usize
928            }
929        })
930        .collect();
931
932    (axes_a, axes_b)
933}