Skip to main content

tensor4all_core/
index_ops.rs

1use crate::IndexLike;
2use tinyvec::TinyVec;
3
4const SMALL_CONTRACTION_INLINE: usize = 8;
5const LINEAR_CONTRACTION_SCAN_LIMIT: usize = 64;
6
7/// Small axis list used by contraction preparation.
8pub(crate) type AxisVec = TinyVec<[usize; SMALL_CONTRACTION_INLINE]>;
9/// Index list used by contraction preparation.
10///
11/// `tinyvec`'s inline storage requires `I: Default`, which `IndexLike`
12/// deliberately does not require (`DynIndex` and the test index types have no
13/// cheap default). A plain `Vec` keeps the abstraction available to every
14/// `IndexLike`; the rank-bounded inline win is not reachable for a generic
15/// index type.
16pub(crate) type IndexVec<I> = Vec<I>;
17
18type AxisPairVec = TinyVec<[(usize, usize); SMALL_CONTRACTION_INLINE]>;
19type BoolVec = TinyVec<[bool; SMALL_CONTRACTION_INLINE]>;
20
21/// Error type for index replacement operations.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ReplaceIndsError {
24    /// The symmetry space of the replacement index does not match the original.
25    SpaceMismatch {
26        /// The dimension/size of the original index
27        from_dim: usize,
28        /// The dimension/size of the replacement index
29        to_dim: usize,
30    },
31    /// Duplicate indices found in the collection.
32    DuplicateIndices {
33        /// The position of the first duplicate index
34        first_pos: usize,
35        /// The position of the duplicate index
36        duplicate_pos: usize,
37    },
38}
39
40impl std::fmt::Display for ReplaceIndsError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            ReplaceIndsError::SpaceMismatch { from_dim, to_dim } => {
44                write!(
45                    f,
46                    "Index space mismatch: cannot replace index with dimension {} with index of dimension {}",
47                    from_dim, to_dim
48                )
49            }
50            ReplaceIndsError::DuplicateIndices {
51                first_pos,
52                duplicate_pos,
53            } => {
54                write!(
55                    f,
56                    "Duplicate indices found: index at position {} equals index at position {}",
57                    duplicate_pos, first_pos
58                )
59            }
60        }
61    }
62}
63
64impl std::error::Error for ReplaceIndsError {}
65
66/// Check if a collection of indices contains any duplicate full indices.
67///
68/// # Arguments
69/// * `indices` - Collection of indices to check
70///
71/// # Returns
72/// `Ok(())` if all indices are unique, or `Err(ReplaceIndsError::DuplicateIndices)` if duplicates are found.
73///
74/// # Errors
75///
76/// Returns [`ReplaceIndsError::DuplicateIndices`] when two entries are equal,
77/// naming the first and duplicate positions.
78///
79/// # Example
80/// ```
81/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
82/// use tensor4all_core::index_ops::check_unique_indices;
83///
84/// let i = Index::new_dyn(2);
85/// let j = Index::new_dyn(3);
86/// let indices = vec![i.clone(), j.clone()];
87/// assert!(check_unique_indices(&indices).is_ok());
88///
89/// let duplicate = vec![i.clone(), i.clone()];
90/// assert!(check_unique_indices(&duplicate).is_err());
91/// ```
92pub fn check_unique_indices<I: IndexLike>(indices: &[I]) -> Result<(), ReplaceIndsError> {
93    use std::collections::HashMap;
94    let mut seen: HashMap<&I, usize> = HashMap::with_capacity(indices.len());
95    for (pos, idx) in indices.iter().enumerate() {
96        if let Some(&first_pos) = seen.get(idx) {
97            return Err(ReplaceIndsError::DuplicateIndices {
98                first_pos,
99                duplicate_pos: pos,
100            });
101        }
102        seen.insert(idx, pos);
103    }
104    Ok(())
105}
106
107/// Replace indices in a collection based on full-index matching.
108///
109/// This corresponds to ITensors.jl's `replace_indices` function. It replaces indices
110/// in `indices` that equal any of the `(old, new)` pairs in `replacements`.
111/// The replacement index must have the same dimension as the original.
112///
113/// # Arguments
114/// * `indices` - Collection of indices to modify
115/// * `replacements` - Pairs of `(old_index, new_index)` where indices equal to `old_index` are replaced with `new_index`
116///
117/// # Returns
118/// A new vector with replacements applied, or an error if any replacement has a shape mismatch.
119///
120/// # Errors
121/// Returns `ReplaceIndsError::SpaceMismatch` if any replacement index has a different dimension than the original.
122///
123/// # Example
124/// ```
125/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
126/// use tensor4all_core::index_ops::replace_indices;
127///
128/// let i = Index::new_dyn(2);
129/// let j = Index::new_dyn(3);
130/// let k = Index::new_dyn(4);
131/// let new_j = Index::new_dyn(3);  // Same size as j
132///
133/// let indices = vec![i.clone(), j.clone(), k.clone()];
134/// let replacements = vec![(j.clone(), new_j.clone())];
135///
136/// let replaced = replace_indices(indices, &replacements).unwrap();
137/// assert_eq!(replaced.len(), 3);
138/// assert_eq!(replaced[1].id, new_j.id);
139/// ```
140pub fn replace_indices<I: IndexLike>(
141    indices: Vec<I>,
142    replacements: &[(I, I)],
143) -> Result<Vec<I>, ReplaceIndsError> {
144    // Check for duplicates in input indices
145    check_unique_indices(&indices)?;
146
147    // Build a map from old index to new index for fast lookup.
148    let mut replacement_map = std::collections::HashMap::with_capacity(replacements.len());
149    for (old, new) in replacements {
150        // Validate dimension match
151        if old.dim() != new.dim() {
152            return Err(ReplaceIndsError::SpaceMismatch {
153                from_dim: old.dim(),
154                to_dim: new.dim(),
155            });
156        }
157        replacement_map.insert(old.clone(), new);
158    }
159
160    // Apply replacements
161    let mut result = Vec::with_capacity(indices.len());
162    for idx in indices {
163        if let Some(new_idx) = replacement_map.get(&idx) {
164            result.push((*new_idx).clone());
165        } else {
166            result.push(idx);
167        }
168    }
169
170    // Check for duplicates in result indices
171    check_unique_indices(&result)?;
172    Ok(result)
173}
174
175/// Replace indices in-place based on full-index matching.
176///
177/// This is an in-place variant of `replace_indices` that modifies the input slice directly.
178/// Useful for performance-critical code where you want to avoid allocations.
179///
180/// # Arguments
181/// * `indices` - Mutable slice of indices to modify
182/// * `replacements` - Pairs of `(old_index, new_index)` where indices equal to `old_index` are replaced with `new_index`
183///
184/// # Returns
185/// `Ok(())` on success, or an error if any replacement has a shape mismatch.
186///
187/// # Errors
188/// Returns `ReplaceIndsError::SpaceMismatch` if any replacement index has a different dimension than the original.
189///
190/// # Example
191/// ```
192/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
193/// use tensor4all_core::index_ops::replace_indices_mut;
194///
195/// let i = Index::new_dyn(2);
196/// let j = Index::new_dyn(3);
197/// let k = Index::new_dyn(4);
198/// let new_j = Index::new_dyn(3);
199///
200/// let mut indices = vec![i.clone(), j.clone(), k.clone()];
201/// let replacements = vec![(j.clone(), new_j.clone())];
202///
203/// replace_indices_mut(&mut indices, &replacements).unwrap();
204/// assert_eq!(indices[1].id, new_j.id);
205/// ```
206pub fn replace_indices_mut<I: IndexLike>(
207    indices: &mut [I],
208    replacements: &[(I, I)],
209) -> Result<(), ReplaceIndsError> {
210    // Check for duplicates in input indices
211    check_unique_indices(indices)?;
212
213    // Build a map from old index to new index for fast lookup.
214    let mut replacement_map = std::collections::HashMap::with_capacity(replacements.len());
215    for (old, new) in replacements {
216        // Validate dimension match
217        if old.dim() != new.dim() {
218            return Err(ReplaceIndsError::SpaceMismatch {
219                from_dim: old.dim(),
220                to_dim: new.dim(),
221            });
222        }
223        replacement_map.insert(old.clone(), new);
224    }
225
226    // Stage the replacements so every validation completes before the caller's
227    // slice is modified.
228    let mut result = indices.to_vec();
229    for idx in &mut result {
230        if let Some(new_idx) = replacement_map.get(idx) {
231            *idx = (*new_idx).clone();
232        }
233    }
234
235    check_unique_indices(&result)?;
236    indices.clone_from_slice(&result);
237    Ok(())
238}
239
240/// Find indices that are unique to the first collection (set difference A \ B).
241///
242/// Returns indices that appear in `indices_a` but not in `indices_b` (matched by full index).
243/// This corresponds to ITensors.jl's `uniqueinds` function.
244///
245/// # Arguments
246/// * `indices_a` - First collection of indices
247/// * `indices_b` - Second collection of indices
248///
249/// # Returns
250/// A vector containing indices from `indices_a` that are not in `indices_b`.
251///
252/// # Example
253/// ```
254/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
255/// use tensor4all_core::index_ops::unique_inds;
256///
257/// let i = Index::new_dyn(2);
258/// let j = Index::new_dyn(3);
259/// let k = Index::new_dyn(4);
260///
261/// let indices_a = vec![i.clone(), j.clone()];
262/// let indices_b = vec![j.clone(), k.clone()];
263///
264/// let unique = unique_inds(&indices_a, &indices_b);
265/// assert_eq!(unique.len(), 1);
266/// assert_eq!(unique[0].id, i.id);
267/// ```
268pub fn unique_inds<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> Vec<I> {
269    indices_a
270        .iter()
271        .filter(|idx| !indices_b.iter().any(|other| other == *idx))
272        .cloned()
273        .collect()
274}
275
276/// Find indices that are not common between two collections (symmetric difference).
277///
278/// Returns indices that appear in either `indices_a` or `indices_b` but not in both
279/// (matched by full index). This corresponds to ITensors.jl's `noncommoninds` function.
280///
281/// Time complexity: O(n + m) where n = len(indices_a), m = len(indices_b).
282///
283/// # Arguments
284/// * `indices_a` - First collection of indices
285/// * `indices_b` - Second collection of indices
286///
287/// # Returns
288/// A vector containing indices from both collections that are not common to both.
289/// Order: indices from A first (in original order), then indices from B (in original order).
290///
291/// # Example
292/// ```
293/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
294/// use tensor4all_core::index_ops::noncommon_inds;
295///
296/// let i = Index::new_dyn(2);
297/// let j = Index::new_dyn(3);
298/// let k = Index::new_dyn(4);
299///
300/// let indices_a = vec![i.clone(), j.clone()];
301/// let indices_b = vec![j.clone(), k.clone()];
302///
303/// let noncommon = noncommon_inds(&indices_a, &indices_b);
304/// assert_eq!(noncommon.len(), 2);  // i and k
305/// ```
306pub fn noncommon_inds<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> Vec<I> {
307    // Pre-allocate with estimated capacity (worst case: no common indices)
308    let mut result = Vec::with_capacity(indices_a.len() + indices_b.len());
309
310    // Add indices from A that are not in B
311    result.extend(
312        indices_a
313            .iter()
314            .filter(|idx| !indices_b.iter().any(|other| other == *idx))
315            .cloned(),
316    );
317    // Add indices from B that are not in A
318    result.extend(
319        indices_b
320            .iter()
321            .filter(|idx| !indices_a.iter().any(|other| other == *idx))
322            .cloned(),
323    );
324    result
325}
326
327/// Find the union of two index collections.
328///
329/// Returns all unique indices from both collections (matched by full index).
330/// This corresponds to ITensors.jl's `unioninds` function.
331///
332/// Time complexity: O(n + m) where n = len(indices_a), m = len(indices_b).
333///
334/// # Arguments
335/// * `indices_a` - First collection of indices
336/// * `indices_b` - Second collection of indices
337///
338/// # Returns
339/// A vector containing all unique indices from both collections.
340///
341/// # Example
342/// ```
343/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
344/// use tensor4all_core::index_ops::union_inds;
345///
346/// let i = Index::new_dyn(2);
347/// let j = Index::new_dyn(3);
348/// let k = Index::new_dyn(4);
349///
350/// let indices_a = vec![i.clone(), j.clone()];
351/// let indices_b = vec![j.clone(), k.clone()];
352///
353/// let union = union_inds(&indices_a, &indices_b);
354/// assert_eq!(union.len(), 3);  // i, j, k
355/// ```
356pub fn union_inds<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> Vec<I> {
357    let mut seen: std::collections::HashSet<&I> =
358        std::collections::HashSet::with_capacity(indices_a.len() + indices_b.len());
359    let mut result = Vec::with_capacity(indices_a.len() + indices_b.len());
360
361    for idx in indices_a {
362        if seen.insert(idx) {
363            result.push(idx.clone());
364        }
365    }
366    for idx in indices_b {
367        if seen.insert(idx) {
368            result.push(idx.clone());
369        }
370    }
371    result
372}
373
374/// Check if a collection contains a specific full index.
375///
376/// This corresponds to ITensors.jl's `hasind` function.
377///
378/// # Arguments
379/// * `indices` - Collection of indices to search
380/// * `index` - The index to look for
381///
382/// # Returns
383/// `true` if an index with matching ID is found, `false` otherwise.
384///
385/// # Example
386/// ```
387/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
388/// use tensor4all_core::index_ops::hasind;
389///
390/// let i = Index::new_dyn(2);
391/// let j = Index::new_dyn(3);
392/// let indices = vec![i.clone(), j.clone()];
393///
394/// assert!(hasind(&indices, &i));
395/// assert!(!hasind(&indices, &Index::new_dyn(4)));
396/// ```
397pub fn hasind<I: IndexLike>(indices: &[I], index: &I) -> bool {
398    indices.iter().any(|idx| idx == index)
399}
400
401/// Check if a collection contains all of the specified full indices.
402///
403/// This corresponds to ITensors.jl's `hasinds` function.
404///
405/// # Arguments
406/// * `indices` - Collection of indices to search
407/// * `targets` - The indices to look for
408///
409/// # Returns
410/// `true` if all target indices are found, `false` otherwise.
411///
412/// # Example
413/// ```
414/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
415/// use tensor4all_core::index_ops::has_inds;
416///
417/// let i = Index::new_dyn(2);
418/// let j = Index::new_dyn(3);
419/// let k = Index::new_dyn(4);
420/// let indices = vec![i.clone(), j.clone(), k.clone()];
421///
422/// assert!(has_inds(&indices, &[i.clone(), j.clone()]));
423/// assert!(!has_inds(&indices, &[i.clone(), Index::new_dyn(5)]));
424/// ```
425#[doc(alias = "hasinds")]
426pub fn has_inds<I: IndexLike>(indices: &[I], targets: &[I]) -> bool {
427    targets
428        .iter()
429        .all(|target| indices.iter().any(|idx| idx == target))
430}
431
432/// Check if two collections have any common full indices.
433///
434/// This corresponds to ITensors.jl's `hascommoninds` function.
435///
436/// # Arguments
437/// * `indices_a` - First collection of indices
438/// * `indices_b` - Second collection of indices
439///
440/// # Returns
441/// `true` if there is at least one common index, `false` otherwise.
442///
443/// # Example
444/// ```
445/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
446/// use tensor4all_core::index_ops::has_common_inds;
447///
448/// let i = Index::new_dyn(2);
449/// let j = Index::new_dyn(3);
450/// let k = Index::new_dyn(4);
451///
452/// let indices_a = vec![i.clone(), j.clone()];
453/// let indices_b = vec![j.clone(), k.clone()];
454///
455/// assert!(has_common_inds(&indices_a, &indices_b));
456/// assert!(!has_common_inds(&[i.clone()], &[k.clone()]));
457/// ```
458#[doc(alias = "hascommoninds")]
459pub fn has_common_inds<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> bool {
460    indices_a
461        .iter()
462        .any(|idx| indices_b.iter().any(|other| other == idx))
463}
464
465/// Find common indices between two index collections.
466///
467/// Returns a vector of indices that appear in both `indices_a` and `indices_b`
468/// (set intersection). This is similar to ITensors.jl's `commoninds` function.
469///
470/// Time complexity: O(n + m) where n = len(indices_a), m = len(indices_b).
471///
472/// # Arguments
473/// * `indices_a` - First collection of indices
474/// * `indices_b` - Second collection of indices
475///
476/// # Returns
477/// A vector containing indices that are common to both collections (matched by full index).
478///
479/// # Example
480/// ```
481/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
482/// use tensor4all_core::index_ops::common_inds;
483///
484/// let i = Index::new_dyn(2);
485/// let j = Index::new_dyn(3);
486/// let k = Index::new_dyn(4);
487///
488/// let indices_a = vec![i.clone(), j.clone()];
489/// let indices_b = vec![j.clone(), k.clone()];
490///
491/// let common = common_inds(&indices_a, &indices_b);
492/// assert_eq!(common.len(), 1);
493/// assert_eq!(common[0].id, j.id);
494/// ```
495pub fn common_inds<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> Vec<I> {
496    indices_a
497        .iter()
498        .filter(|idx| indices_b.iter().any(|other| other == *idx))
499        .cloned()
500        .collect()
501}
502
503/// Find contractable indices between two slices and return their positions.
504///
505/// Returns a vector of `(pos_a, pos_b)` tuples where each tuple indicates
506/// that `indices_a[pos_a]` and `indices_b[pos_b]` are contractable
507/// (same ID, same dimension, and compatible ConjState).
508///
509/// # Example
510/// ```
511/// use tensor4all_core::index::DefaultIndex as Index;
512/// use tensor4all_core::index_ops::common_ind_positions;
513///
514/// let i = Index::new_dyn(2);
515/// let j = Index::new_dyn(3);
516/// let k = Index::new_dyn(4);
517///
518/// let indices_a = vec![i.clone(), j.clone()];
519/// let indices_b = vec![j.clone(), k.clone()];
520///
521/// let positions = common_ind_positions(&indices_a, &indices_b);
522/// assert_eq!(positions, vec![(1, 0)]); // j is at position 1 in a, position 0 in b
523/// ```
524pub fn common_ind_positions<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> Vec<(usize, usize)> {
525    common_ind_positions_small(indices_a, indices_b).into_vec()
526}
527
528fn common_ind_positions_small<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> AxisPairVec {
529    let scan_work = indices_a.len().saturating_mul(indices_b.len());
530    if scan_work <= LINEAR_CONTRACTION_SCAN_LIMIT {
531        return common_ind_positions_linear(indices_a, indices_b);
532    }
533    common_ind_positions_hashed(indices_a, indices_b)
534}
535
536fn common_ind_positions_linear<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> AxisPairVec {
537    let mut positions = AxisPairVec::new();
538    for (pos_a, idx_a) in indices_a.iter().enumerate() {
539        for (pos_b, idx_b) in indices_b.iter().enumerate() {
540            if idx_a.is_contractable(idx_b) {
541                positions.push((pos_a, pos_b));
542                break; // Each index in a can match at most one in b
543            }
544        }
545    }
546    positions
547}
548
549fn common_ind_positions_hashed<I: IndexLike>(indices_a: &[I], indices_b: &[I]) -> AxisPairVec {
550    use std::collections::HashMap;
551
552    // Bucket indices_b by their FULL value and probe each `idx_a` under the
553    // key `conj(idx_a)`: the linear scan accepts exactly `b == conj(a)`
554    // (undirected: conj is the identity; directed: is_contractable requires
555    // `conj(a) == b`, even when conj changes the id). Keying by Id, or by
556    // bare value with a same-value probe, would silently drop directed pairs
557    // whose conj partner carries a different id (#615).
558    let mut positions_by_value: HashMap<I, TinyVec<[usize; 2]>> =
559        HashMap::with_capacity(indices_b.len());
560    for (pos_b, idx_b) in indices_b.iter().enumerate() {
561        positions_by_value
562            .entry(idx_b.clone())
563            .or_default()
564            .push(pos_b);
565    }
566
567    let mut positions = AxisPairVec::new();
568    for (pos_a, idx_a) in indices_a.iter().enumerate() {
569        let Some(candidate_positions) = positions_by_value.get(&idx_a.conj()) else {
570            continue;
571        };
572        for &pos_b in candidate_positions {
573            if idx_a.is_contractable(&indices_b[pos_b]) {
574                positions.push((pos_a, pos_b));
575                break; // Each index in a can match at most one in b
576            }
577        }
578    }
579    positions
580}
581
582/// Result of preparing a tensor contraction.
583///
584/// Contains all the information needed to perform the contraction:
585/// - Which axes to contract from each tensor
586/// - The resulting indices after contraction
587#[derive(Debug, Clone)]
588pub(crate) struct ContractionSpec<I: IndexLike> {
589    /// Axes to contract from the first tensor (positions in `indices_a`).
590    pub axes_a: AxisVec,
591    /// Axes to contract from the second tensor (positions in `indices_b`).
592    pub axes_b: AxisVec,
593    /// Indices of the result tensor (non-contracted indices from both tensors).
594    pub result_indices: IndexVec<I>,
595}
596
597/// Error type for contraction preparation.
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub(crate) enum ContractionError {
600    /// No common indices found for contraction.
601    NoCommonIndices,
602    /// Dimension mismatch for a common index.
603    DimensionMismatch {
604        /// Position in the first tensor.
605        pos_a: usize,
606        /// Position in the second tensor.
607        pos_b: usize,
608        /// Dimension in the first tensor.
609        dim_a: usize,
610        /// Dimension in the second tensor.
611        dim_b: usize,
612    },
613    /// Duplicate axis specified in contraction.
614    DuplicateAxis {
615        /// Which tensor has the duplicate ("self" or "other").
616        tensor: &'static str,
617        /// Position of the duplicate axis.
618        pos: usize,
619    },
620    /// Index not found in tensor.
621    IndexNotFound {
622        /// Which tensor the index was not found in.
623        tensor: &'static str,
624    },
625    /// Batch contraction not yet implemented.
626    BatchContractionNotImplemented,
627}
628
629impl std::fmt::Display for ContractionError {
630    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
631        match self {
632            ContractionError::NoCommonIndices => {
633                write!(f, "No common indices found for contraction")
634            }
635            ContractionError::DimensionMismatch {
636                pos_a,
637                pos_b,
638                dim_a,
639                dim_b,
640            } => {
641                write!(
642                    f,
643                    "Dimension mismatch: tensor_a[{}]={} != tensor_b[{}]={}",
644                    pos_a, dim_a, pos_b, dim_b
645                )
646            }
647            ContractionError::DuplicateAxis { tensor, pos } => {
648                write!(f, "Duplicate axis {} in {} tensor", pos, tensor)
649            }
650            ContractionError::IndexNotFound { tensor } => {
651                write!(f, "Index not found in {} tensor", tensor)
652            }
653            ContractionError::BatchContractionNotImplemented => {
654                write!(f, "Batch contraction not yet implemented")
655            }
656        }
657    }
658}
659
660impl std::error::Error for ContractionError {}
661
662/// Prepare contraction data for two tensors that share common indices.
663///
664/// This internal helper finds common indices and computes the axes to contract
665/// together with the resulting non-contracted indices.
666pub(crate) fn prepare_contraction<I: IndexLike>(
667    indices_a: &[I],
668    dims_a: &[usize],
669    indices_b: &[I],
670    dims_b: &[usize],
671) -> Result<ContractionSpec<I>, ContractionError> {
672    // Find common indices and their positions.
673    // If no common indices exist, this becomes an outer product (empty axes).
674    let positions = common_ind_positions_small(indices_a, indices_b);
675
676    let mut axes_a = AxisVec::with_capacity(positions.len());
677    let mut axes_b = AxisVec::with_capacity(positions.len());
678    for &(pos_a, pos_b) in &positions {
679        axes_a.push(pos_a);
680        axes_b.push(pos_b);
681    }
682
683    // Verify dimensions match
684    for &(pos_a, pos_b) in &positions {
685        if dims_a[pos_a] != dims_b[pos_b] {
686            return Err(ContractionError::DimensionMismatch {
687                pos_a,
688                pos_b,
689                dim_a: dims_a[pos_a],
690                dim_b: dims_b[pos_b],
691            });
692        }
693    }
694
695    let result_indices = build_contraction_result_indices(indices_a, &axes_a, indices_b, &axes_b);
696
697    Ok(ContractionSpec {
698        axes_a,
699        axes_b,
700        result_indices,
701    })
702}
703
704/// Prepare contraction data for explicit index pairs (like tensordot).
705///
706/// Unlike `prepare_contraction`, this internal helper takes explicit pairs of
707/// indices to contract, allowing contraction of indices with different IDs.
708pub(crate) fn prepare_contraction_pairs<I: IndexLike>(
709    indices_a: &[I],
710    dims_a: &[usize],
711    indices_b: &[I],
712    dims_b: &[usize],
713    pairs: &[(I, I)],
714) -> Result<ContractionSpec<I>, ContractionError> {
715    if pairs.is_empty() {
716        return Err(ContractionError::NoCommonIndices);
717    }
718
719    // Check for batch contraction (common indices not in pairs). The explicit
720    // pair list identifies axes by full index metadata, not by ID alone.
721    let common_positions = common_ind_positions_small(indices_a, indices_b);
722    for (pos_a, pos_b) in &common_positions {
723        let idx_a = &indices_a[*pos_a];
724        let idx_b = &indices_b[*pos_b];
725        if !pairs
726            .iter()
727            .any(|(contracted_idx, _)| contracted_idx == idx_a)
728            || !pairs
729                .iter()
730                .any(|(_, contracted_idx)| contracted_idx == idx_b)
731        {
732            return Err(ContractionError::BatchContractionNotImplemented);
733        }
734    }
735
736    // Find positions and validate
737    let mut axes_a = AxisVec::with_capacity(pairs.len());
738    let mut axes_b = AxisVec::with_capacity(pairs.len());
739    let mut contracted_a = bool_flags(indices_a.len());
740    let mut contracted_b = bool_flags(indices_b.len());
741
742    for (idx_a, idx_b) in pairs {
743        let pos_a = indices_a
744            .iter()
745            .position(|idx| idx == idx_a)
746            .ok_or(ContractionError::IndexNotFound { tensor: "self" })?;
747
748        let pos_b = indices_b
749            .iter()
750            .position(|idx| idx == idx_b)
751            .ok_or(ContractionError::IndexNotFound { tensor: "other" })?;
752
753        // Verify dimensions match
754        if dims_a[pos_a] != dims_b[pos_b] {
755            return Err(ContractionError::DimensionMismatch {
756                pos_a,
757                pos_b,
758                dim_a: dims_a[pos_a],
759                dim_b: dims_b[pos_b],
760            });
761        }
762
763        // Check for duplicate axes
764        if contracted_a[pos_a] {
765            return Err(ContractionError::DuplicateAxis {
766                tensor: "self",
767                pos: pos_a,
768            });
769        }
770        if contracted_b[pos_b] {
771            return Err(ContractionError::DuplicateAxis {
772                tensor: "other",
773                pos: pos_b,
774            });
775        }
776
777        contracted_a[pos_a] = true;
778        contracted_b[pos_b] = true;
779        axes_a.push(pos_a);
780        axes_b.push(pos_b);
781    }
782
783    let result_indices = build_contraction_result_indices(indices_a, &axes_a, indices_b, &axes_b);
784
785    Ok(ContractionSpec {
786        axes_a,
787        axes_b,
788        result_indices,
789    })
790}
791
792fn bool_flags(len: usize) -> BoolVec {
793    let mut flags = BoolVec::with_capacity(len);
794    flags.resize(len, false);
795    flags
796}
797
798fn build_contraction_result_indices<I: IndexLike>(
799    indices_a: &[I],
800    axes_a: &[usize],
801    indices_b: &[I],
802    axes_b: &[usize],
803) -> IndexVec<I> {
804    let mut contracted_a = bool_flags(indices_a.len());
805    let mut contracted_b = bool_flags(indices_b.len());
806    for &axis in axes_a {
807        contracted_a[axis] = true;
808    }
809    for &axis in axes_b {
810        contracted_b[axis] = true;
811    }
812
813    let result_len = indices_a.len() + indices_b.len() - axes_a.len() - axes_b.len();
814    let mut result_indices = IndexVec::with_capacity(result_len);
815
816    for (i, idx) in indices_a.iter().enumerate() {
817        if !contracted_a[i] {
818            result_indices.push(idx.clone());
819        }
820    }
821
822    for (i, idx) in indices_b.iter().enumerate() {
823        if !contracted_b[i] {
824            result_indices.push(idx.clone());
825        }
826    }
827
828    result_indices
829}
830
831#[cfg(test)]
832mod tests;