Skip to main content

tensor4all_core/defaults/
contract.rs

1//! Multi-tensor contraction with optimal contraction order.
2//!
3//! This module provides functions to contract multiple tensors efficiently
4//! using einsum optimization via the tensorbackend
5//! (tenferro-backed implementation).
6//!
7//! This module works with concrete types (`DynIndex`, `IdxTensor`) only.
8//!
9//! # Main Functions
10//!
11//! - [`contract`]: Contracts one connected tensor network
12//! - [`contract_with_options`]: Contracts one connected tensor network with retained indices
13//!
14//! # Structured Tensor Handling
15//!
16//! Diagonal and structured tensors contract through their compact payload and
17//! equality metadata. Logical dense materialization is reserved for APIs that
18//! explicitly request dense values; contraction itself preserves compact
19//! representation whenever the result remains structured.
20
21use std::cell::RefCell;
22use std::cmp::Reverse;
23use std::collections::{HashMap, HashSet};
24use std::env;
25use std::time::{Duration, Instant};
26
27use anyhow::Result;
28use petgraph::algo::connected_components;
29use petgraph::prelude::*;
30use tenferro_einsum::{EagerEinsumExt, EinsumSubscripts};
31use tensor4all_tensorbackend::{
32    einsum_native_tensor_reads, einsum_native_tensors_owned, NativeTensorReadInput,
33};
34
35#[cfg(test)]
36use crate::defaults::DynId;
37use crate::defaults::IdxTensorError;
38use crate::defaults::{DynIndex, IdxTensor};
39
40use crate::index_like::IndexLike;
41#[derive(Debug, Clone, Hash, PartialEq, Eq)]
42struct ContractOperandSignature {
43    dims: Vec<usize>,
44    ids: Vec<usize>,
45    is_diag: bool,
46}
47
48#[derive(Debug, Clone, Hash, PartialEq, Eq)]
49struct ContractSignature {
50    operands: Vec<ContractOperandSignature>,
51    output_ids: Vec<usize>,
52    output_dims: Vec<usize>,
53}
54
55#[derive(Debug, Default, Clone)]
56struct ContractProfileEntry {
57    calls: usize,
58    total_time: Duration,
59}
60
61thread_local! {
62    static CONTRACT_PROFILE_STATE: RefCell<HashMap<ContractSignature, ContractProfileEntry>> =
63        RefCell::new(HashMap::new());
64}
65
66fn contract_profile_enabled() -> bool {
67    env::var("T4A_PROFILE_CONTRACT").is_ok()
68}
69
70fn record_contract_profile(signature: ContractSignature, elapsed: Duration) {
71    if !contract_profile_enabled() {
72        return;
73    }
74    CONTRACT_PROFILE_STATE.with(|state| {
75        let mut state = state.borrow_mut();
76        let entry = state.entry(signature).or_default();
77        entry.calls += 1;
78        entry.total_time += elapsed;
79    });
80}
81
82/// Reset the aggregated multi-tensor contraction profile.
83pub fn reset_contract_profile() {
84    CONTRACT_PROFILE_STATE.with(|state| state.borrow_mut().clear());
85}
86
87/// Print and clear the aggregated multi-tensor contraction profile.
88pub fn print_and_reset_contract_profile() {
89    if !contract_profile_enabled() {
90        return;
91    }
92    CONTRACT_PROFILE_STATE.with(|state| {
93        let mut entries: Vec<_> = state
94            .borrow()
95            .iter()
96            .map(|(k, v)| (k.clone(), v.clone()))
97            .collect();
98        state.borrow_mut().clear();
99        entries.sort_by_key(|(_, entry)| Reverse(entry.total_time));
100
101        eprintln!("=== contract Profile ===");
102        for (idx, (signature, entry)) in entries.into_iter().take(20).enumerate() {
103            let operands = signature
104                .operands
105                .iter()
106                .map(|operand| {
107                    format!(
108                        "dims={:?} ids={:?}{}",
109                        operand.dims,
110                        operand.ids,
111                        if operand.is_diag { " diag" } else { "" }
112                    )
113                })
114                .collect::<Vec<_>>()
115                .join(" ; ");
116            eprintln!(
117                "#{idx:02} calls={} total={:.3}s per_call={:.3}us output_dims={:?} output_ids={:?}",
118                entry.calls,
119                entry.total_time.as_secs_f64(),
120                entry.total_time.as_secs_f64() * 1e6 / entry.calls as f64,
121                signature.output_dims,
122                signature.output_ids,
123            );
124            eprintln!("     {operands}");
125        }
126    });
127}
128
129// ============================================================================
130// Public API
131// ============================================================================
132
133/// Options for multi-tensor contraction.
134///
135/// Use this to choose which shared indices should be retained in the output
136/// instead of summed over.
137///
138/// # Examples
139///
140/// ```
141/// use tensor4all_core::{ContractionOptions, DynIndex};
142///
143/// let batch = DynIndex::new_dyn(2);
144/// let retain = [batch.clone()];
145/// let options = ContractionOptions::new().with_retain_indices(&retain);
146///
147/// assert_eq!(options.retain_indices, &[batch]);
148/// ```
149#[derive(Clone, Copy, Debug)]
150pub struct ContractionOptions<'a> {
151    /// Indices that should remain in the result even if they appear more than once.
152    pub retain_indices: &'a [DynIndex],
153}
154
155impl<'a> ContractionOptions<'a> {
156    /// Create contraction options with no retained indices.
157    pub fn new() -> Self {
158        Self {
159            retain_indices: &[],
160        }
161    }
162
163    /// Set the indices that should be retained in the output.
164    pub fn with_retain_indices(mut self, retain_indices: &'a [DynIndex]) -> Self {
165        self.retain_indices = retain_indices;
166        self
167    }
168}
169
170impl Default for ContractionOptions<'_> {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176/// Options for pairwise tensor contraction.
177///
178/// The conjugation flags are semantically equivalent to contracting
179/// `lhs.conj()` or `rhs.conj()`, but allow implementations to pass conjugation
180/// to the backend without materializing a conjugated tensor.
181///
182/// # Examples
183///
184/// ```
185/// use num_complex::Complex64;
186/// use tensor4all_core::{
187///     contract_pair, contract_pair_with_operand_options, DynIndex,
188///     PairwiseContractionOptions, IdxTensor,
189/// };
190///
191/// let i = DynIndex::new_dyn(2);
192/// let lhs = IdxTensor::from_dense(
193///     vec![i.clone()],
194///     vec![Complex64::new(1.0, 2.0), Complex64::new(3.0, -1.0)],
195/// ).unwrap();
196/// let rhs = IdxTensor::from_dense(
197///     vec![i],
198///     vec![Complex64::new(2.0, 0.5), Complex64::new(-1.0, 4.0)],
199/// ).unwrap();
200///
201/// let options = PairwiseContractionOptions::new().with_lhs_conj(true);
202/// let flagged = contract_pair_with_operand_options(&lhs, &rhs, options).unwrap();
203/// let materialized = contract_pair(&lhs.conj(), &rhs).unwrap();
204///
205/// assert!((flagged.sum().unwrap() - materialized.sum().unwrap()).abs() < 1e-12);
206/// ```
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
208pub struct PairwiseContractionOptions {
209    /// Whether to conjugate the left operand before contraction.
210    pub lhs_conj: bool,
211    /// Whether to conjugate the right operand before contraction.
212    pub rhs_conj: bool,
213}
214
215impl PairwiseContractionOptions {
216    /// Create pairwise contraction options with no operand conjugation.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use tensor4all_core::PairwiseContractionOptions;
222    ///
223    /// let options = PairwiseContractionOptions::new();
224    /// assert!(!options.lhs_conj);
225    /// assert!(!options.rhs_conj);
226    /// ```
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Set whether the left operand is conjugated during contraction.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use tensor4all_core::PairwiseContractionOptions;
237    ///
238    /// let options = PairwiseContractionOptions::new().with_lhs_conj(true);
239    /// assert!(options.lhs_conj);
240    /// assert!(!options.rhs_conj);
241    /// ```
242    pub fn with_lhs_conj(mut self, lhs_conj: bool) -> Self {
243        self.lhs_conj = lhs_conj;
244        self
245    }
246
247    /// Set whether the right operand is conjugated during contraction.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use tensor4all_core::PairwiseContractionOptions;
253    ///
254    /// let options = PairwiseContractionOptions::new().with_rhs_conj(true);
255    /// assert!(!options.lhs_conj);
256    /// assert!(options.rhs_conj);
257    /// ```
258    pub fn with_rhs_conj(mut self, rhs_conj: bool) -> Self {
259        self.rhs_conj = rhs_conj;
260        self
261    }
262
263    pub(crate) fn has_conj(self) -> bool {
264        self.lhs_conj || self.rhs_conj
265    }
266}
267
268/// Contract a connected tensor network with the default semantics.
269///
270/// This is the normal public entry point for N-ary tensor contraction. It
271/// contracts all common contractable indices and requires the input tensors to
272/// form one connected tensor graph. Disconnected inputs are rejected so missing
273/// links do not silently become outer products.
274///
275/// Use explicit [`outer_product`] calls when an outer product of disconnected
276/// components is intentional.
277/// # Errors
278///
279/// Returns an error when the network is disconnected (a disconnected-network
280/// /// failure), when indices are incompatible (a shape or index mismatch), or
281/// /// when the contraction reports a failure (a backend failure).
282///
283pub fn contract(tensors: &[&IdxTensor]) -> std::result::Result<IdxTensor, IdxTensorError> {
284    contract_with_options(tensors, ContractionOptions::new())
285}
286
287/// Contract a connected tensor network with advanced options.
288/// # Errors
289///
290/// Returns an error when the network is disconnected (a disconnected-network
291/// /// failure), when indices are incompatible (a shape or index mismatch), or
292/// /// when the contraction reports a failure (a backend failure).
293///
294pub fn contract_with_options(
295    tensors: &[&IdxTensor],
296    options: ContractionOptions<'_>,
297) -> std::result::Result<IdxTensor, IdxTensorError> {
298    contract_with_options_impl(tensors, options).map_err(IdxTensorError::from)
299}
300
301/// Contract owned tensors with the default connected-network semantics.
302/// # Errors
303///
304/// Returns an error when the network is disconnected (a disconnected-network
305/// /// failure), when indices are incompatible (a shape or index mismatch), or
306/// /// when the contraction reports a failure (a backend failure).
307///
308pub fn contract_owned(tensors: Vec<IdxTensor>) -> std::result::Result<IdxTensor, IdxTensorError> {
309    contract_owned_with_options(tensors, ContractionOptions::new())
310}
311
312/// Contract owned tensors with advanced connected-network options.
313/// # Errors
314///
315/// Returns an error when the network is disconnected (a disconnected-network
316/// /// failure), when indices are incompatible (a shape or index mismatch), or
317/// /// when the contraction reports a failure (a backend failure).
318///
319pub fn contract_owned_with_options(
320    tensors: Vec<IdxTensor>,
321    options: ContractionOptions<'_>,
322) -> std::result::Result<IdxTensor, IdxTensorError> {
323    let tensor_refs = tensors.iter().collect::<Vec<_>>();
324    let components =
325        find_tensor_connected_components_with_retained(&tensor_refs, options.retain_indices);
326    if components.len() > 1 {
327        return Err(IdxTensorError::from(anyhow::anyhow!(
328            "Tensors form disconnected components; use explicit outer_product operations for an intentional disconnected product"
329        )));
330    }
331    drop(tensor_refs);
332    contract_owned_with_options_impl(tensors, options).map_err(IdxTensorError::from)
333}
334
335/// Contract two tensors with the default pairwise semantics.
336///
337/// This is the concrete `IdxTensor` entry point for binary contraction. It
338/// contracts all common indices and preserves the pairwise structured fast
339/// paths used by [`TensorContractionLike::contract_pair`].
340/// # Errors
341///
342/// Returns an error when the pair is disconnected or has incompatible indices
343/// /// (a shape or index mismatch), or when the contraction reports a failure (a
344/// /// backend failure).
345///
346pub fn contract_pair(
347    lhs: &IdxTensor,
348    rhs: &IdxTensor,
349) -> std::result::Result<IdxTensor, IdxTensorError> {
350    lhs.try_contract_pairwise_default_with_options(rhs, PairwiseContractionOptions::new())
351        .map_err(IdxTensorError::from)
352}
353
354/// Contract two tensors with operand-level conjugation options.
355///
356/// This has the same index semantics as [`contract_pair`], with optional
357/// conjugation applied to either operand before matching and contracting common
358/// indices. Implementations may pass conjugation to the backend to avoid
359/// materializing conjugated payloads.
360///
361/// # Errors
362///
363/// Returns an error when the pair is disconnected or has incompatible indices
364/// /// (a shape or index mismatch), or when the contraction reports a failure (a
365/// /// backend failure).
366///
367/// # Examples
368///
369/// ```
370/// use num_complex::Complex64;
371/// use tensor4all_core::{
372///     contract_pair, contract_pair_with_operand_options, DynIndex,
373///     PairwiseContractionOptions, IdxTensor,
374/// };
375///
376/// let i = DynIndex::new_dyn(2);
377/// let lhs = IdxTensor::from_dense(
378///     vec![i.clone()],
379///     vec![Complex64::new(1.0, 1.0), Complex64::new(0.0, 2.0)],
380/// ).unwrap();
381/// let rhs = IdxTensor::from_dense(
382///     vec![i],
383///     vec![Complex64::new(2.0, 0.0), Complex64::new(3.0, -1.0)],
384/// ).unwrap();
385///
386/// let flagged = contract_pair_with_operand_options(
387///     &lhs,
388///     &rhs,
389///     PairwiseContractionOptions::new().with_lhs_conj(true),
390/// ).unwrap();
391/// let materialized = contract_pair(&lhs.conj(), &rhs).unwrap();
392///
393/// assert!((flagged.sum().unwrap() - materialized.sum().unwrap()).abs() < 1e-12);
394/// ```
395pub fn contract_pair_with_operand_options(
396    lhs: &IdxTensor,
397    rhs: &IdxTensor,
398    options: PairwiseContractionOptions,
399) -> std::result::Result<IdxTensor, IdxTensorError> {
400    lhs.try_contract_pairwise_default_with_options(rhs, options)
401        .map_err(IdxTensorError::from)
402}
403
404/// Contract two tensors with explicit contraction options.
405/// # Errors
406///
407/// Returns an error when the pair is disconnected or has incompatible indices
408/// /// (a shape or index mismatch), or when the contraction reports a failure (a
409/// /// backend failure).
410///
411pub fn contract_pair_with_options(
412    lhs: &IdxTensor,
413    rhs: &IdxTensor,
414    options: ContractionOptions<'_>,
415) -> std::result::Result<IdxTensor, IdxTensorError> {
416    contract_with_options(&[lhs, rhs], options)
417}
418
419/// Contract two tensors along explicitly specified index pairs.
420/// # Errors
421///
422/// Returns an error when the contracted indices are incompatible (a shape or
423/// /// index mismatch) or the contraction reports a failure (a backend failure).
424///
425pub fn tensordot(
426    lhs: &IdxTensor,
427    rhs: &IdxTensor,
428    pairs: &[(DynIndex, DynIndex)],
429) -> std::result::Result<IdxTensor, IdxTensorError> {
430    lhs.try_tensordot_pairwise_explicit(rhs, pairs)
431        .map_err(IdxTensorError::from)
432}
433
434/// Compute the outer product of two tensors.
435///
436/// This is an explicit tensor product, not a dense-only operation. Compact
437/// structured storage is preserved when the operand layouts allow it.
438/// # Errors
439///
440/// Returns an error when the two tensors share contractable indices (a
441/// /// shared-index mismatch) or the construction reports a failure (a backend
442/// /// failure).
443///
444pub fn outer_product(
445    lhs: &IdxTensor,
446    rhs: &IdxTensor,
447) -> std::result::Result<IdxTensor, IdxTensorError> {
448    lhs.try_outer_product_pairwise(rhs)
449        .map_err(IdxTensorError::from)
450}
451
452/// Contract multiple owned tensors into a single tensor.
453///
454/// This is the consuming implementation for [`contract_owned_with_options`]. It
455/// preserves the same contraction semantics while allowing eligible non-AD
456/// dense inputs to use tenferro's owned eager einsum executor. When any input
457/// tracks gradients, or when compact structured metadata needs the borrowed
458/// path, this function falls back to the shared borrowed execution so semantics
459/// and reverse-mode AD remain intact.
460fn contract_owned_with_options_impl(
461    tensors: Vec<IdxTensor>,
462    options: ContractionOptions<'_>,
463) -> Result<IdxTensor> {
464    match tensors.len() {
465        0 => Err(anyhow::anyhow!("No tensors to contract")),
466        _ => {
467            let tensor_refs = tensors.iter().collect::<Vec<_>>();
468            validate_retained_indices_exist(&tensor_refs, options.retain_indices)?;
469
470            if tensors.len() == 1 {
471                drop(tensor_refs);
472                let Some(tensor) = tensors.into_iter().next() else {
473                    return Err(anyhow::anyhow!("No tensors to contract"));
474                };
475                return Ok(tensor);
476            }
477
478            let has_structured_storage = tensor_refs
479                .iter()
480                .map(|tensor| has_dense_axis_classes(tensor).map(|dense| !dense))
481                .collect::<Result<Vec<_>>>()?
482                .into_iter()
483                .any(|structured| structured);
484            let requires_borrowed_path =
485                tensor_refs.iter().any(|tensor| tensor.tracks_grad()) || has_structured_storage;
486            if requires_borrowed_path {
487                return contract_with_options(&tensor_refs, options).map_err(anyhow::Error::from);
488            }
489
490            let components = find_tensor_connected_components_with_retained(
491                &tensor_refs,
492                options.retain_indices,
493            );
494            if components.len() > 1 {
495                return Err(anyhow::anyhow!(
496                    "Tensors form disconnected components; use explicit outer_product operations for an intentional disconnected product"
497                ));
498            }
499
500            let plan = build_contraction_plan(&tensor_refs, options)?;
501            drop(tensor_refs);
502            let native_operands = tensors
503                .into_iter()
504                .enumerate()
505                .map(|(tensor_idx, tensor)| {
506                    Ok((
507                        tensor.as_inner()?.duplicate_value()?,
508                        plan.input_ids[tensor_idx].clone(),
509                    ))
510                })
511                .collect::<Result<Vec<_>>>()?;
512            let result_native = einsum_native_tensors_owned(native_operands, &plan.output_ids)?;
513            IdxTensor::from_native_with_axis_classes(
514                plan.result_indices,
515                result_native,
516                plan.result_axis_classes,
517            )
518        }
519    }
520}
521
522fn has_dense_axis_classes(tensor: &IdxTensor) -> Result<bool> {
523    Ok(tensor
524        .axis_classes()
525        .iter()
526        .copied()
527        .eq(0..tensor.indices().len()))
528}
529
530fn contract_with_options_impl(
531    tensors: &[&IdxTensor],
532    options: ContractionOptions<'_>,
533) -> Result<IdxTensor> {
534    match tensors.len() {
535        0 => Err(anyhow::anyhow!("No tensors to contract")),
536        _ => {
537            validate_retained_indices_exist(tensors, options.retain_indices)?;
538            if tensors.len() == 1 {
539                return Ok((*tensors[0]).clone());
540            }
541
542            // Check connectivity first
543            let components =
544                find_tensor_connected_components_with_retained(tensors, options.retain_indices);
545            if components.len() > 1 {
546                return Err(anyhow::anyhow!(
547                    "Disconnected tensor network: {} components found",
548                    components.len()
549                ));
550            }
551
552            let has_structured_storage = tensors
553                .iter()
554                .map(|tensor| has_dense_axis_classes(tensor).map(|dense| !dense))
555                .collect::<Result<Vec<_>>>()?
556                .into_iter()
557                .any(|structured| structured);
558            let has_grad = tensors.iter().any(|tensor| tensor.tracks_grad());
559            if has_structured_storage || has_grad {
560                let plan = build_contraction_plan(tensors, options)?;
561                return IdxTensor::contract_structured_payloads_nary(
562                    tensors,
563                    plan.result_indices,
564                    plan.input_ids,
565                    plan.output_ids,
566                );
567            }
568
569            // Connectivity verified - skip check in impl
570            contract_impl(tensors, options)
571        }
572    }
573}
574
575// ============================================================================
576// Union-Find for Diag axis grouping
577// ============================================================================
578
579/// Union-Find data structure for grouping axis IDs.
580///
581/// Used to merge diagonal axes from Diag tensors so that they share
582/// the same representative ID when passed to einsum.
583#[derive(Debug, Clone)]
584#[cfg(test)]
585pub(crate) struct AxisUnionFind {
586    /// Maps each ID to its parent. If parent[id] == id, it's a root.
587    parent: HashMap<DynId, DynId>,
588    /// Rank for union by rank optimization.
589    rank: HashMap<DynId, usize>,
590}
591
592#[cfg(test)]
593impl AxisUnionFind {
594    /// Create a new empty union-find structure.
595    pub fn new() -> Self {
596        Self {
597            parent: HashMap::new(),
598            rank: HashMap::new(),
599        }
600    }
601
602    /// Add an ID to the structure (as its own set).
603    pub fn make_set(&mut self, id: DynId) {
604        use std::collections::hash_map::Entry;
605        if let Entry::Vacant(e) = self.parent.entry(id) {
606            e.insert(id);
607            self.rank.insert(id, 0);
608        }
609    }
610
611    /// Find the representative (root) of the set containing `id`.
612    /// Uses path compression for efficiency.
613    pub fn find(&mut self, id: DynId) -> DynId {
614        self.make_set(id);
615        if self.parent[&id] != id {
616            let root = self.find(self.parent[&id]);
617            self.parent.insert(id, root);
618        }
619        self.parent[&id]
620    }
621
622    /// Union the sets containing `a` and `b`.
623    /// Uses union by rank for efficiency.
624    pub fn union(&mut self, a: DynId, b: DynId) {
625        let root_a = self.find(a);
626        let root_b = self.find(b);
627
628        if root_a == root_b {
629            return;
630        }
631
632        let rank_a = self.rank[&root_a];
633        let rank_b = self.rank[&root_b];
634
635        if rank_a < rank_b {
636            self.parent.insert(root_a, root_b);
637        } else if rank_a > rank_b {
638            self.parent.insert(root_b, root_a);
639        } else {
640            self.parent.insert(root_b, root_a);
641            if let Some(rank) = self.rank.get_mut(&root_a) {
642                *rank += 1;
643            }
644        }
645    }
646
647    /// Remap an ID to its representative.
648    pub fn remap(&mut self, id: DynId) -> DynId {
649        self.find(id)
650    }
651
652    /// Remap a slice of IDs to their representatives.
653    pub fn remap_ids(&mut self, ids: &[DynId]) -> Vec<DynId> {
654        ids.iter().map(|id| self.find(*id)).collect()
655    }
656}
657
658#[cfg(test)]
659impl Default for AxisUnionFind {
660    fn default() -> Self {
661        Self::new()
662    }
663}
664
665// ============================================================================
666// Axis helper builders
667// ============================================================================
668
669/// Remap tensor indices using the union-find structure.
670///
671/// Returns a vector of remapped IDs for each tensor, suitable for passing
672/// to einsum. The original tensors are not modified.
673#[cfg(test)]
674pub(crate) fn remap_tensor_ids(tensors: &[&IdxTensor], uf: &mut AxisUnionFind) -> Vec<Vec<DynId>> {
675    tensors
676        .iter()
677        .map(|t| t.indices.iter().map(|idx| uf.find(*idx.id())).collect())
678        .collect()
679}
680
681/// Remap output IDs using the union-find structure.
682#[cfg(test)]
683pub(crate) fn remap_output_ids(output: &[DynIndex], uf: &mut AxisUnionFind) -> Vec<DynId> {
684    output.iter().map(|idx| uf.find(*idx.id())).collect()
685}
686
687/// Collect dimension sizes for remapped IDs.
688///
689/// For unified IDs (from Diag tensors), all axes must have the same dimension,
690/// so we just take the first occurrence.
691#[cfg(test)]
692pub(crate) fn collect_sizes(
693    tensors: &[&IdxTensor],
694    uf: &mut AxisUnionFind,
695) -> HashMap<DynId, usize> {
696    let mut sizes = HashMap::new();
697
698    for tensor in tensors {
699        let dims = tensor.dims();
700        for (idx, &dim) in tensor.indices.iter().zip(dims.iter()) {
701            let rep = uf.find(*idx.id());
702            sizes.entry(rep).or_insert(dim);
703        }
704    }
705
706    sizes
707}
708
709// ============================================================================
710// Contraction implementation
711// ============================================================================
712
713/// Internal implementation of multi-tensor contraction.
714///
715/// Structured operands use compact payload einsum labels, preserving equality
716/// metadata without materializing logical dense tensors. Dense operands continue
717/// to use the native backend path.
718///
719/// The result keeps the common eager dtype across `f32`, `f64`, `c32`, and
720/// `c64` operands, using the backend's normal mixed-dtype promotion rules.
721fn contract_impl(tensors: &[&IdxTensor], options: ContractionOptions<'_>) -> Result<IdxTensor> {
722    // 1. Build the contraction plan from internal labels.
723    let plan = build_contraction_plan(tensors, options)?;
724
725    // Note: Connectivity check is done by caller.
726    // via find_tensor_connected_components before calling this function
727
728    // 3. Build sizes from unique internal IDs.
729    let mut sizes: HashMap<usize, usize> = HashMap::new();
730    for (tensor_idx, tensor) in tensors.iter().enumerate() {
731        let dims = tensor.dims();
732        for (pos, &dim) in dims.iter().enumerate() {
733            let internal_id = plan.input_ids[tensor_idx][pos];
734            match sizes.entry(internal_id) {
735                std::collections::hash_map::Entry::Vacant(entry) => {
736                    entry.insert(dim);
737                }
738                std::collections::hash_map::Entry::Occupied(entry) => {
739                    if *entry.get() != dim {
740                        return Err(anyhow::anyhow!(
741                            "Internal label shape mismatch: label {} has dimensions {} and {}",
742                            internal_id,
743                            entry.get(),
744                            dim
745                        ));
746                    }
747                }
748            }
749        }
750    }
751
752    let profile_signature = contract_profile_enabled().then(|| ContractSignature {
753        operands: tensors
754            .iter()
755            .enumerate()
756            .map(|(tensor_idx, tensor)| ContractOperandSignature {
757                dims: tensor.dims().to_vec(),
758                ids: plan.input_ids[tensor_idx].clone(),
759                is_diag: tensor.is_diag(),
760            })
761            .collect(),
762        output_ids: plan.output_ids.clone(),
763        output_dims: plan.output_ids.iter().map(|id| sizes[id]).collect(),
764    });
765    let profile_started = contract_profile_enabled().then(Instant::now);
766
767    let result = execute_contraction_plan(tensors, &plan, !options.retain_indices.is_empty())?;
768    if let (Some(signature), Some(started)) = (profile_signature, profile_started) {
769        record_contract_profile(signature, started.elapsed());
770    }
771    Ok(result)
772}
773
774fn execute_contraction_plan(
775    tensors: &[&IdxTensor],
776    plan: &ContractionPlan,
777    has_retained_indices: bool,
778) -> Result<IdxTensor> {
779    let any_grad = tensors.iter().any(|tensor| tensor.tracks_grad());
780    let first_dtype = tensors[0].as_inner()?.dtype();
781    let same_dtype = tensors
782        .iter()
783        .map(|tensor| Ok(tensor.as_inner()?.dtype() == first_dtype))
784        .collect::<Result<Vec<_>>>()?
785        .into_iter()
786        .all(|same| same);
787    let has_non_dense_axis_classes = tensors
788        .iter()
789        .map(|tensor| {
790            Ok(tensor
791                .axis_classes()
792                .iter()
793                .copied()
794                .enumerate()
795                .any(|(axis, class)| axis != class))
796        })
797        .collect::<Result<Vec<_>>>()?
798        .into_iter()
799        .any(|non_dense| non_dense);
800
801    if any_grad && same_dtype && has_non_dense_axis_classes && !has_retained_indices {
802        // Structured payload AD still relies on the existing pairwise structured
803        // path until structured N-ary planning is implemented.
804        let mut iter = tensors.iter();
805        let Some(first) = iter.next() else {
806            return Err(anyhow::anyhow!("No tensors to contract"));
807        };
808        let mut result = (*first).clone();
809        for tensor in iter {
810            result = contract_pair(&result, tensor)?;
811        }
812        return Ok(result);
813    }
814
815    if any_grad {
816        let operands = tensors
817            .iter()
818            .map(|tensor| tensor.as_inner())
819            .collect::<Result<Vec<_>>>()?;
820        let subscripts = build_einsum_subscripts_from_usize_ids(&plan.input_ids, &plan.output_ids)?;
821        let result = operands.as_slice().einsum_subscripts(&subscripts)?;
822        return IdxTensor::from_inner_with_axis_classes(
823            plan.result_indices.clone(),
824            result,
825            plan.result_axis_classes.clone(),
826        );
827    }
828
829    let native_operands = tensors
830        .iter()
831        .enumerate()
832        .map(|(tensor_idx, tensor)| {
833            Ok((
834                NativeTensorReadInput::Borrowed(tensor.as_inner()?.tensor_read()),
835                plan.input_ids[tensor_idx].as_slice(),
836            ))
837        })
838        .collect::<Result<Vec<_>>>()?;
839    let operand_refs = native_operands
840        .iter()
841        .map(|(tensor, ids)| (tensor, *ids))
842        .collect::<Vec<_>>();
843    let result_native = einsum_native_tensor_reads(&operand_refs, &plan.output_ids)?;
844    IdxTensor::from_native_with_axis_classes(
845        plan.result_indices.clone(),
846        result_native,
847        plan.result_axis_classes.clone(),
848    )
849}
850
851fn build_einsum_subscripts_from_usize_ids(
852    input_ids: &[Vec<usize>],
853    output_ids: &[usize],
854) -> Result<EinsumSubscripts> {
855    let inputs = input_ids
856        .iter()
857        .map(|ids| {
858            ids.iter()
859                .map(|&id| {
860                    u32::try_from(id)
861                        .map_err(|_| anyhow::anyhow!("einsum label {id} exceeds u32 range"))
862                })
863                .collect::<Result<Vec<_>>>()
864        })
865        .collect::<Result<Vec<_>>>()?;
866    let output = output_ids
867        .iter()
868        .map(|&id| {
869            u32::try_from(id).map_err(|_| anyhow::anyhow!("einsum label {id} exceeds u32 range"))
870        })
871        .collect::<Result<Vec<_>>>()?;
872    let input_refs = inputs.iter().map(Vec::as_slice).collect::<Vec<_>>();
873    Ok(EinsumSubscripts::new(&input_refs, &output))
874}
875
876/// A contraction plan with internal labels and result ordering.
877#[derive(Debug, Clone)]
878struct ContractionPlan {
879    input_ids: Vec<Vec<usize>>,
880    output_ids: Vec<usize>,
881    result_indices: Vec<DynIndex>,
882    result_axis_classes: Vec<usize>,
883}
884
885fn build_contraction_plan(
886    tensors: &[&IdxTensor],
887    options: ContractionOptions<'_>,
888) -> Result<ContractionPlan> {
889    let retained_indices: HashSet<DynIndex> = options.retain_indices.iter().cloned().collect();
890    let (input_ids, internal_id_to_original) = build_internal_ids(tensors, &retained_indices)?;
891
892    let mut counts: HashMap<usize, usize> = HashMap::new();
893    for ids in &input_ids {
894        for &internal_id in ids {
895            *counts.entry(internal_id).or_insert(0) += 1;
896        }
897    }
898    let mut output_ids = Vec::new();
899    let mut seen_output = HashSet::new();
900    let mut found_retained = HashSet::new();
901
902    for (tensor_idx, tensor) in tensors.iter().enumerate() {
903        for (axis, idx) in tensor.indices.iter().enumerate() {
904            let internal_id = input_ids[tensor_idx][axis];
905            let should_output = counts[&internal_id] == 1 || retained_indices.contains(idx);
906            if should_output && seen_output.insert(internal_id) {
907                output_ids.push(internal_id);
908            }
909            if retained_indices.contains(idx) {
910                found_retained.insert(idx.clone());
911            }
912        }
913    }
914
915    for retained in retained_indices {
916        if !found_retained.contains(&retained) {
917            return Err(anyhow::anyhow!(
918                "Retained index {:?} does not appear in the input tensors",
919                retained
920            ));
921        }
922    }
923
924    let result_indices: Vec<DynIndex> = output_ids
925        .iter()
926        .map(|&internal_id| {
927            let (tensor_idx, pos) = internal_id_to_original[&internal_id];
928            tensors[tensor_idx].indices[pos].clone()
929        })
930        .collect();
931    validate_unique_output_indices(&result_indices)?;
932    let result_axis_classes =
933        output_axis_classes(tensors, &input_ids, &output_ids, &internal_id_to_original)?;
934
935    Ok(ContractionPlan {
936        input_ids,
937        output_ids,
938        result_indices,
939        result_axis_classes,
940    })
941}
942
943fn validate_retained_indices_exist(
944    tensors: &[&IdxTensor],
945    retain_indices: &[DynIndex],
946) -> Result<()> {
947    for retain in retain_indices {
948        let found = tensors
949            .iter()
950            .any(|tensor| tensor.indices().iter().any(|idx| idx == retain));
951        if !found {
952            return Err(anyhow::anyhow!(
953                "Retained index {:?} does not appear in the input tensors",
954                retain
955            ));
956        }
957    }
958    Ok(())
959}
960
961fn validate_unique_output_indices(indices: &[DynIndex]) -> Result<()> {
962    let mut seen = HashSet::new();
963    for idx in indices {
964        if !seen.insert(idx.clone()) {
965            return Err(anyhow::anyhow!(
966                "Contraction result would contain duplicate output indices"
967            ));
968        }
969    }
970    Ok(())
971}
972
973fn output_axis_classes(
974    tensors: &[&IdxTensor],
975    ixs: &[Vec<usize>],
976    output: &[usize],
977    internal_id_to_original: &HashMap<usize, (usize, usize)>,
978) -> Result<Vec<usize>> {
979    fn find(parent: &mut [usize], value: usize) -> usize {
980        if parent[value] != value {
981            parent[value] = find(parent, parent[value]);
982        }
983        parent[value]
984    }
985
986    fn union(parent: &mut [usize], lhs: usize, rhs: usize) {
987        let lhs_root = find(parent, lhs);
988        let rhs_root = find(parent, rhs);
989        if lhs_root != rhs_root {
990            parent[rhs_root] = lhs_root;
991        }
992    }
993
994    let mut class_offsets = Vec::with_capacity(tensors.len());
995    let mut next_node = 0usize;
996    for tensor in tensors {
997        class_offsets.push(next_node);
998        let payload_rank = tensor
999            .axis_classes()
1000            .iter()
1001            .copied()
1002            .max()
1003            .map(|value| value + 1)
1004            .unwrap_or(0);
1005        next_node += payload_rank;
1006    }
1007    let mut parent: Vec<usize> = (0..next_node).collect();
1008    let mut axes_by_internal_id: HashMap<usize, Vec<usize>> = HashMap::new();
1009
1010    for (tensor_idx, tensor) in tensors.iter().enumerate() {
1011        for (axis, &internal_id) in ixs[tensor_idx].iter().enumerate() {
1012            let class_id = tensor.axis_classes()[axis];
1013            let node = class_offsets[tensor_idx] + class_id;
1014            axes_by_internal_id
1015                .entry(internal_id)
1016                .or_default()
1017                .push(node);
1018        }
1019    }
1020
1021    for nodes in axes_by_internal_id.values() {
1022        if let Some((&first, rest)) = nodes.split_first() {
1023            for &node in rest {
1024                union(&mut parent, first, node);
1025            }
1026        }
1027    }
1028
1029    let mut root_to_class = HashMap::new();
1030    let mut next_class = 0usize;
1031    output
1032        .iter()
1033        .map(|internal_id| {
1034            let (tensor_idx, axis) = internal_id_to_original[internal_id];
1035            let class_id = tensors[tensor_idx].axis_classes()[axis];
1036            let node = class_offsets[tensor_idx] + class_id;
1037            let root = find(&mut parent, node);
1038            Ok(*root_to_class.entry(root).or_insert_with(|| {
1039                let class = next_class;
1040                next_class += 1;
1041                class
1042            }))
1043        })
1044        .collect::<Result<Vec<_>>>()
1045}
1046
1047/// Build internal IDs for numeric contraction.
1048///
1049/// Uses the union-find to merge IDs that have already been proven equivalent by
1050/// the caller. Diagonal logical-axis metadata is intentionally handled outside
1051/// this numeric labeling step.
1052///
1053/// Returns: (ixs, internal_id_to_original)
1054#[allow(clippy::type_complexity)]
1055fn build_internal_ids(
1056    tensors: &[&IdxTensor],
1057    retained_indices: &HashSet<DynIndex>,
1058) -> Result<(Vec<Vec<usize>>, HashMap<usize, (usize, usize)>)> {
1059    let mut next_id = 0usize;
1060    let mut index_to_internal: HashMap<DynIndex, usize> = HashMap::new();
1061    let mut retained_index_to_internal: HashMap<DynIndex, usize> = HashMap::new();
1062    let mut assigned: HashMap<(usize, usize), usize> = HashMap::new();
1063    let mut internal_id_to_original: HashMap<usize, (usize, usize)> = HashMap::new();
1064
1065    for ti in 0..tensors.len() {
1066        for tj in (ti + 1)..tensors.len() {
1067            for (pi, idx_i) in tensors[ti].indices.iter().enumerate() {
1068                for (pj, idx_j) in tensors[tj].indices.iter().enumerate() {
1069                    if idx_i.is_contractable(idx_j) {
1070                        let key_i = (ti, pi);
1071                        let key_j = (tj, pj);
1072
1073                        match (assigned.get(&key_i).copied(), assigned.get(&key_j).copied()) {
1074                            (None, None) => {
1075                                let internal_id = if let Some(&id) = index_to_internal.get(idx_i) {
1076                                    id
1077                                } else {
1078                                    let id = next_id;
1079                                    next_id += 1;
1080                                    index_to_internal.insert(idx_i.clone(), id);
1081                                    internal_id_to_original.insert(id, key_i);
1082                                    id
1083                                };
1084                                assigned.insert(key_i, internal_id);
1085                                assigned.insert(key_j, internal_id);
1086                                if idx_i != idx_j {
1087                                    index_to_internal.insert(idx_j.clone(), internal_id);
1088                                }
1089                            }
1090                            (Some(id), None) => {
1091                                assigned.insert(key_j, id);
1092                                index_to_internal.insert(idx_j.clone(), id);
1093                            }
1094                            (None, Some(id)) => {
1095                                assigned.insert(key_i, id);
1096                                index_to_internal.insert(idx_i.clone(), id);
1097                            }
1098                            (Some(_id_i), Some(_id_j)) => {
1099                                // Both already assigned
1100                            }
1101                        }
1102                    }
1103                }
1104            }
1105        }
1106    }
1107
1108    // Assign IDs for unassigned indices (external indices)
1109    for (tensor_idx, tensor) in tensors.iter().enumerate() {
1110        for (pos, idx) in tensor.indices.iter().enumerate() {
1111            let key = (tensor_idx, pos);
1112            if let std::collections::hash_map::Entry::Vacant(e) = assigned.entry(key) {
1113                let internal_id = if retained_indices.contains(idx) {
1114                    if let Some(&id) = retained_index_to_internal.get(idx) {
1115                        id
1116                    } else {
1117                        let id = next_id;
1118                        next_id += 1;
1119                        retained_index_to_internal.insert(idx.clone(), id);
1120                        internal_id_to_original.insert(id, key);
1121                        id
1122                    }
1123                } else {
1124                    let id = next_id;
1125                    next_id += 1;
1126                    internal_id_to_original.insert(id, key);
1127                    id
1128                };
1129                e.insert(internal_id);
1130            }
1131        }
1132    }
1133
1134    // Build ixs
1135    let ixs: Vec<Vec<usize>> = tensors
1136        .iter()
1137        .enumerate()
1138        .map(|(tensor_idx, tensor)| {
1139            (0..tensor.indices.len())
1140                .map(|pos| assigned[&(tensor_idx, pos)])
1141                .collect()
1142        })
1143        .collect();
1144
1145    Ok((ixs, internal_id_to_original))
1146}
1147
1148// ============================================================================
1149// Helper functions for connected component detection
1150// ============================================================================
1151
1152/// Check if two tensors have any contractable indices.
1153fn has_contractable_indices(a: &IdxTensor, b: &IdxTensor) -> bool {
1154    a.indices
1155        .iter()
1156        .any(|idx_a| b.indices.iter().any(|idx_b| idx_a.is_contractable(idx_b)))
1157}
1158
1159/// Find connected components of tensors based on contractable indices.
1160///
1161/// Uses petgraph for O(V+E) connected component detection.
1162#[allow(dead_code)]
1163fn find_tensor_connected_components(tensors: &[&IdxTensor]) -> Vec<Vec<usize>> {
1164    find_tensor_connected_components_with_retained(tensors, &[])
1165}
1166
1167fn find_tensor_connected_components_with_retained(
1168    tensors: &[&IdxTensor],
1169    retain_indices: &[DynIndex],
1170) -> Vec<Vec<usize>> {
1171    let n = tensors.len();
1172    if n == 0 {
1173        return vec![];
1174    }
1175    if n == 1 {
1176        return vec![vec![0]];
1177    }
1178
1179    // Build undirected graph
1180    let mut graph = UnGraph::<(), ()>::new_undirected();
1181    let nodes: Vec<_> = (0..n).map(|_| graph.add_node(())).collect();
1182
1183    for i in 0..n {
1184        for j in (i + 1)..n {
1185            if has_contractable_indices(tensors[i], tensors[j]) {
1186                graph.add_edge(nodes[i], nodes[j], ());
1187            }
1188        }
1189    }
1190
1191    if !retain_indices.is_empty() {
1192        for i in 0..n {
1193            for j in (i + 1)..n {
1194                if shares_retained_index(tensors[i], tensors[j], retain_indices) {
1195                    graph.add_edge(nodes[i], nodes[j], ());
1196                }
1197            }
1198        }
1199    }
1200
1201    // Find connected components using petgraph
1202    let num_components = connected_components(&graph);
1203
1204    if num_components == 1 {
1205        return vec![(0..n).collect()];
1206    }
1207
1208    // Multiple components - group by component ID
1209    use petgraph::visit::Dfs;
1210    let mut visited = vec![false; n];
1211    let mut components = Vec::new();
1212
1213    for start in 0..n {
1214        if !visited[start] {
1215            let mut component = Vec::new();
1216            let mut dfs = Dfs::new(&graph, nodes[start]);
1217            while let Some(node) = dfs.next(&graph) {
1218                let idx = node.index();
1219                if !visited[idx] {
1220                    visited[idx] = true;
1221                    component.push(idx);
1222                }
1223            }
1224            component.sort();
1225            components.push(component);
1226        }
1227    }
1228
1229    components.sort_by_key(|c| c[0]);
1230    components
1231}
1232
1233fn shares_retained_index(a: &IdxTensor, b: &IdxTensor, retain_indices: &[DynIndex]) -> bool {
1234    retain_indices.iter().any(|retain| {
1235        a.indices().iter().any(|idx_a| idx_a == retain)
1236            && b.indices().iter().any(|idx_b| idx_b == retain)
1237    })
1238}
1239
1240#[cfg(test)]
1241mod tests;