Skip to main content

tensor4all_core/
index_ops.rs

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