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//! - [`PreparedContraction`]: Reuses N-ary or retained-call labels for compatible repeated calls
14//!
15//! # Structured Tensor Handling
16//!
17//! Diagonal and structured tensors contract through their compact payload and
18//! equality metadata. Logical dense materialization is reserved for APIs that
19//! explicitly request dense values; contraction itself preserves compact
20//! representation whenever the result remains structured.
21
22use std::cell::RefCell;
23use std::cmp::Reverse;
24use std::collections::{HashMap, HashSet};
25use std::env;
26use std::time::{Duration, Instant};
27
28use super::eager_session::{with_eager_session, with_engine};
29use anyhow::Result;
30use petgraph::algo::connected_components;
31use petgraph::prelude::*;
32use tenferro::TensorValue;
33use tenferro_einsum::{EagerSessionEinsumExt, EinsumSubscripts};
34use tensor4all_tensorbackend::{
35    einsum_native_tensor_reads, einsum_native_tensors_owned, NativeTensorReadInput,
36};
37
38#[cfg(test)]
39use crate::defaults::DynId;
40use crate::defaults::IdxTensorError;
41use crate::defaults::{DynIndex, IdxTensor};
42
43use crate::index_like::IndexLike;
44#[derive(Debug, Clone, Hash, PartialEq, Eq)]
45struct ContractOperandSignature {
46    dims: Vec<usize>,
47    ids: Vec<usize>,
48    is_diag: bool,
49}
50
51#[derive(Debug, Clone, Hash, PartialEq, Eq)]
52struct ContractSignature {
53    operands: Vec<ContractOperandSignature>,
54    output_ids: Vec<usize>,
55    output_dims: Vec<usize>,
56}
57
58#[derive(Debug, Default, Clone)]
59struct ContractProfileEntry {
60    calls: usize,
61    total_time: Duration,
62}
63
64thread_local! {
65    static CONTRACT_PROFILE_STATE: RefCell<HashMap<ContractSignature, ContractProfileEntry>> =
66        RefCell::new(HashMap::new());
67}
68
69fn contract_profile_enabled() -> bool {
70    env::var("T4A_PROFILE_CONTRACT").is_ok()
71}
72
73fn record_contract_profile(signature: ContractSignature, elapsed: Duration) {
74    if !contract_profile_enabled() {
75        return;
76    }
77    CONTRACT_PROFILE_STATE.with(|state| {
78        let mut state = state.borrow_mut();
79        let entry = state.entry(signature).or_default();
80        entry.calls += 1;
81        entry.total_time += elapsed;
82    });
83}
84
85/// Reset the aggregated multi-tensor contraction profile.
86pub fn reset_contract_profile() {
87    CONTRACT_PROFILE_STATE.with(|state| state.borrow_mut().clear());
88}
89
90/// Print and clear the aggregated multi-tensor contraction profile.
91pub fn print_and_reset_contract_profile() {
92    if !contract_profile_enabled() {
93        return;
94    }
95    CONTRACT_PROFILE_STATE.with(|state| {
96        let mut entries: Vec<_> = state
97            .borrow()
98            .iter()
99            .map(|(k, v)| (k.clone(), v.clone()))
100            .collect();
101        state.borrow_mut().clear();
102        entries.sort_by_key(|(_, entry)| Reverse(entry.total_time));
103
104        eprintln!("=== contract Profile ===");
105        for (idx, (signature, entry)) in entries.into_iter().take(20).enumerate() {
106            let operands = signature
107                .operands
108                .iter()
109                .map(|operand| {
110                    format!(
111                        "dims={:?} ids={:?}{}",
112                        operand.dims,
113                        operand.ids,
114                        if operand.is_diag { " diag" } else { "" }
115                    )
116                })
117                .collect::<Vec<_>>()
118                .join(" ; ");
119            eprintln!(
120                "#{idx:02} calls={} total={:.3}s per_call={:.3}us output_dims={:?} output_ids={:?}",
121                entry.calls,
122                entry.total_time.as_secs_f64(),
123                entry.total_time.as_secs_f64() * 1e6 / entry.calls as f64,
124                signature.output_dims,
125                signature.output_ids,
126            );
127            eprintln!("     {operands}");
128        }
129    });
130}
131
132// ============================================================================
133// Public API
134// ============================================================================
135
136/// Options for multi-tensor contraction.
137///
138/// Use this to choose which shared indices should be retained in the output
139/// instead of summed over.
140///
141/// # Examples
142///
143/// ```
144/// use tensor4all_core::{ContractionOptions, DynIndex};
145///
146/// let batch = DynIndex::new_dyn(2);
147/// let retain = [batch.clone()];
148/// let options = ContractionOptions::new().with_retain_indices(&retain);
149///
150/// assert_eq!(options.retain_indices, &[batch]);
151/// ```
152#[derive(Clone, Copy, Debug)]
153pub struct ContractionOptions<'a> {
154    /// Indices that should remain in the result even if they appear more than once.
155    pub retain_indices: &'a [DynIndex],
156}
157
158impl<'a> ContractionOptions<'a> {
159    /// Create contraction options with no retained indices.
160    pub fn new() -> Self {
161        Self {
162            retain_indices: &[],
163        }
164    }
165
166    /// Set the indices that should be retained in the output.
167    pub fn with_retain_indices(mut self, retain_indices: &'a [DynIndex]) -> Self {
168        self.retain_indices = retain_indices;
169        self
170    }
171}
172
173impl Default for ContractionOptions<'_> {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179/// A caller-owned plan for repeated contractions with fixed index metadata.
180///
181/// Prepare this once when repeated operands keep the same ordered indices,
182/// dimensions, and axis classes but their values, dtypes, or gradient state may
183/// change. Planning reuse applies to N-ary or retained-index execution. A binary
184/// call without retained indices preserves the faster pairwise path and does not
185/// consume the stored N-ary labels; use [`contract_pair`] directly for that case.
186/// Fresh index identities require a fresh plan.
187///
188/// # Examples
189///
190/// ```
191/// use tensor4all_core::{ContractionOptions, DynIndex, IdxTensor, PreparedContraction};
192///
193/// let i = DynIndex::new_dyn(2);
194/// let j = DynIndex::new_dyn(2);
195/// let k = DynIndex::new_dyn(2);
196/// let a = IdxTensor::from_dense(vec![i.clone(), k.clone()], vec![1.0, 2.0, 3.0, 4.0])?;
197/// let b = IdxTensor::from_dense(vec![k, j.clone()], vec![5.0, 6.0, 7.0, 8.0])?;
198/// let c = IdxTensor::from_dense(vec![j], vec![1.0, 2.0])?;
199/// let plan = PreparedContraction::new(&[&a, &b, &c], ContractionOptions::new())?;
200/// let result = plan.execute(&[&a, &b, &c])?;
201/// assert_eq!(result.indices(), &[i]);
202/// assert_eq!(result.to_vec::<f64>()?, vec![85.0, 126.0]);
203/// # Ok::<(), Box<dyn std::error::Error>>(())
204/// ```
205#[derive(Clone)]
206pub struct PreparedContraction {
207    expected_indices: Vec<Vec<DynIndex>>,
208    expected_dims: Vec<Vec<usize>>,
209    expected_axis_classes: Vec<Vec<usize>>,
210    plan: ContractionPlan,
211    has_retained_indices: bool,
212}
213
214impl std::fmt::Debug for PreparedContraction {
215    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        formatter
217            .debug_struct("PreparedContraction")
218            .field("operand_count", &self.expected_indices.len())
219            .field("result_rank", &self.plan.result_indices.len())
220            .field("has_retained_indices", &self.has_retained_indices)
221            .finish_non_exhaustive()
222    }
223}
224
225impl PreparedContraction {
226    /// Prepare index matching, label assignment, and result ordering.
227    ///
228    /// # Arguments
229    ///
230    /// * `tensors` - Representative operands whose ordered index metadata defines
231    ///   the execution contract.
232    /// * `options` - Retained indices to preserve in every execution result.
233    ///
234    /// # Returns
235    ///
236    /// An immutable caller-owned plan reusable with compatible operands.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`IdxTensorError`] when no operands are supplied, retained indices
241    /// are absent, the index relationships do not form the requested connected
242    /// network, or the result would contain duplicate output indices.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use tensor4all_core::{ContractionOptions, DynIndex, IdxTensor, PreparedContraction};
248    ///
249    /// let left = DynIndex::new_dyn(2);
250    /// let right = DynIndex::new_dyn(2);
251    /// let a = IdxTensor::from_dense(vec![left.clone()], vec![1.0, 2.0])?;
252    /// let b = IdxTensor::from_dense(
253    ///     vec![left, right.clone()],
254    ///     vec![3.0, 0.0, 0.0, 4.0],
255    /// )?;
256    /// let c = IdxTensor::from_dense(vec![right], vec![5.0, 6.0])?;
257    /// let plan = PreparedContraction::new(&[&a, &b, &c], ContractionOptions::new())?;
258    /// assert_eq!(plan.execute(&[&a, &b, &c])?.to_vec::<f64>()?, vec![63.0]);
259    /// # Ok::<(), Box<dyn std::error::Error>>(())
260    /// ```
261    pub fn new(
262        tensors: &[&IdxTensor],
263        options: ContractionOptions<'_>,
264    ) -> std::result::Result<Self, IdxTensorError> {
265        Self::new_impl(tensors, options).map_err(IdxTensorError::from)
266    }
267
268    fn new_impl(tensors: &[&IdxTensor], options: ContractionOptions<'_>) -> Result<Self> {
269        if tensors.is_empty() {
270            return Err(anyhow::anyhow!("No tensors to contract"));
271        }
272        validate_retained_indices_exist(tensors, options.retain_indices)?;
273        if tensors.len() > 1 {
274            let components =
275                find_tensor_connected_components_with_retained(tensors, options.retain_indices);
276            if components.len() > 1 {
277                return Err(anyhow::anyhow!(
278                    "Disconnected tensor network: {} components found",
279                    components.len()
280                ));
281            }
282        }
283        let plan = build_contraction_plan(tensors, options)?;
284        Ok(Self {
285            expected_indices: tensors
286                .iter()
287                .map(|tensor| tensor.indices().to_vec())
288                .collect(),
289            expected_dims: tensors.iter().map(|tensor| tensor.dims()).collect(),
290            expected_axis_classes: tensors
291                .iter()
292                .map(|tensor| tensor.axis_classes().to_vec())
293                .collect(),
294            plan,
295            has_retained_indices: !options.retain_indices.is_empty(),
296        })
297    }
298
299    /// Execute this plan with compatible operands.
300    ///
301    /// Operand values, dtypes, and gradient state may differ from preparation;
302    /// ordered full indices, dimensions, and axis classes must match exactly.
303    ///
304    /// # Returns
305    ///
306    /// The contracted tensor in the result-index order fixed at preparation.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`IdxTensorError::ShapeMismatch`] before backend execution when
311    /// operand count, indices, dimensions, or axis classes differ. Storage,
312    /// dtype-promotion, AD, or backend execution failures retain their ordinary
313    /// [`IdxTensorError`] diagnostics.
314    ///
315    /// # Examples
316    ///
317    /// ```
318    /// use tensor4all_core::{ContractionOptions, DynIndex, IdxTensor, PreparedContraction};
319    ///
320    /// let left = DynIndex::new_dyn(2);
321    /// let right = DynIndex::new_dyn(2);
322    /// let a = IdxTensor::from_dense(vec![left.clone()], vec![1.0, 2.0])?;
323    /// let b = IdxTensor::from_dense(
324    ///     vec![left, right.clone()],
325    ///     vec![3.0, 0.0, 0.0, 4.0],
326    /// )?;
327    /// let c = IdxTensor::from_dense(vec![right.clone()], vec![5.0, 6.0])?;
328    /// let plan = PreparedContraction::new(&[&a, &b, &c], ContractionOptions::new())?;
329    /// let updated = IdxTensor::from_dense(vec![right], vec![1.0, 1.0])?;
330    /// assert_eq!(plan.execute(&[&a, &b, &updated])?.to_vec::<f64>()?, vec![11.0]);
331    /// # Ok::<(), Box<dyn std::error::Error>>(())
332    /// ```
333    pub fn execute(
334        &self,
335        tensors: &[&IdxTensor],
336    ) -> std::result::Result<IdxTensor, IdxTensorError> {
337        self.validate_operands(tensors)?;
338        self.execute_impl(tensors).map_err(IdxTensorError::from)
339    }
340
341    fn validate_operands(&self, tensors: &[&IdxTensor]) -> std::result::Result<(), IdxTensorError> {
342        if tensors.len() != self.expected_indices.len() {
343            return Err(IdxTensorError::ShapeMismatch {
344                operation: "prepared contraction",
345                expected: format!("{} operands", self.expected_indices.len()),
346                actual: format!("{} operands", tensors.len()),
347            });
348        }
349        for (operand, tensor) in tensors.iter().enumerate() {
350            let dims = tensor.dims();
351            if dims != self.expected_dims[operand] {
352                return Err(IdxTensorError::ShapeMismatch {
353                    operation: "prepared contraction",
354                    expected: format!(
355                        "operand {operand} dimensions {:?}",
356                        self.expected_dims[operand]
357                    ),
358                    actual: format!("operand {operand} dimensions {dims:?}"),
359                });
360            }
361            if tensor.indices() != self.expected_indices[operand] {
362                return Err(IdxTensorError::ShapeMismatch {
363                    operation: "prepared contraction",
364                    expected: format!(
365                        "operand {operand} indices {:?}",
366                        self.expected_indices[operand]
367                    ),
368                    actual: format!("operand {operand} indices {:?}", tensor.indices()),
369                });
370            }
371            if tensor.axis_classes() != self.expected_axis_classes[operand] {
372                return Err(IdxTensorError::ShapeMismatch {
373                    operation: "prepared contraction",
374                    expected: format!(
375                        "operand {operand} axis classes {:?}",
376                        self.expected_axis_classes[operand]
377                    ),
378                    actual: format!("operand {operand} axis classes {:?}", tensor.axis_classes()),
379                });
380            }
381        }
382        Ok(())
383    }
384
385    fn execute_impl(&self, tensors: &[&IdxTensor]) -> Result<IdxTensor> {
386        if tensors.len() == 1 {
387            return Ok((*tensors[0]).clone());
388        }
389        if tensors.len() == 2 && !self.has_retained_indices {
390            return tensors[0].try_contract_pairwise_default_with_options(
391                tensors[1],
392                PairwiseContractionOptions::new(),
393            );
394        }
395        let has_structured_storage = tensors
396            .iter()
397            .map(|tensor| has_dense_axis_classes(tensor).map(|dense| !dense))
398            .collect::<Result<Vec<_>>>()?
399            .into_iter()
400            .any(|structured| structured);
401        let has_grad = tensors.iter().any(|tensor| tensor.tracks_grad());
402        if has_structured_storage || has_grad {
403            return IdxTensor::contract_structured_payloads_nary(
404                tensors,
405                self.plan.result_indices.clone(),
406                self.plan.input_ids.clone(),
407                self.plan.output_ids.clone(),
408            );
409        }
410        execute_contraction_plan(tensors, &self.plan, self.has_retained_indices)
411    }
412}
413
414/// Options for pairwise tensor contraction.
415///
416/// The conjugation flags are semantically equivalent to contracting
417/// `lhs.conj()` or `rhs.conj()`, but allow implementations to pass conjugation
418/// to the backend without materializing a conjugated tensor.
419///
420/// # Examples
421///
422/// ```
423/// use num_complex::Complex64;
424/// use tensor4all_core::{
425///     contract_pair, contract_pair_with_operand_options, DynIndex,
426///     PairwiseContractionOptions, IdxTensor,
427/// };
428///
429/// let i = DynIndex::new_dyn(2);
430/// let lhs = IdxTensor::from_dense(
431///     vec![i.clone()],
432///     vec![Complex64::new(1.0, 2.0), Complex64::new(3.0, -1.0)],
433/// ).unwrap();
434/// let rhs = IdxTensor::from_dense(
435///     vec![i],
436///     vec![Complex64::new(2.0, 0.5), Complex64::new(-1.0, 4.0)],
437/// ).unwrap();
438///
439/// let options = PairwiseContractionOptions::new().with_lhs_conj(true);
440/// let flagged = contract_pair_with_operand_options(&lhs, &rhs, options).unwrap();
441/// let materialized = contract_pair(&lhs.conj(), &rhs).unwrap();
442///
443/// assert!((flagged.sum().unwrap() - materialized.sum().unwrap()).abs() < 1e-12);
444/// ```
445#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
446pub struct PairwiseContractionOptions {
447    /// Whether to conjugate the left operand before contraction.
448    pub lhs_conj: bool,
449    /// Whether to conjugate the right operand before contraction.
450    pub rhs_conj: bool,
451}
452
453impl PairwiseContractionOptions {
454    /// Create pairwise contraction options with no operand conjugation.
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// use tensor4all_core::PairwiseContractionOptions;
460    ///
461    /// let options = PairwiseContractionOptions::new();
462    /// assert!(!options.lhs_conj);
463    /// assert!(!options.rhs_conj);
464    /// ```
465    pub fn new() -> Self {
466        Self::default()
467    }
468
469    /// Set whether the left operand is conjugated during contraction.
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// use tensor4all_core::PairwiseContractionOptions;
475    ///
476    /// let options = PairwiseContractionOptions::new().with_lhs_conj(true);
477    /// assert!(options.lhs_conj);
478    /// assert!(!options.rhs_conj);
479    /// ```
480    pub fn with_lhs_conj(mut self, lhs_conj: bool) -> Self {
481        self.lhs_conj = lhs_conj;
482        self
483    }
484
485    /// Set whether the right operand is conjugated during contraction.
486    ///
487    /// # Examples
488    ///
489    /// ```
490    /// use tensor4all_core::PairwiseContractionOptions;
491    ///
492    /// let options = PairwiseContractionOptions::new().with_rhs_conj(true);
493    /// assert!(!options.lhs_conj);
494    /// assert!(options.rhs_conj);
495    /// ```
496    pub fn with_rhs_conj(mut self, rhs_conj: bool) -> Self {
497        self.rhs_conj = rhs_conj;
498        self
499    }
500
501    pub(crate) fn has_conj(self) -> bool {
502        self.lhs_conj || self.rhs_conj
503    }
504}
505
506/// Contract a connected tensor network with the default semantics.
507///
508/// This is the normal public entry point for N-ary tensor contraction. It
509/// contracts all common contractable indices and requires the input tensors to
510/// form one connected tensor graph. Disconnected inputs are rejected so missing
511/// links do not silently become outer products.
512///
513/// Use explicit [`outer_product`] calls when an outer product of disconnected
514/// components is intentional.
515/// # Errors
516///
517/// Returns [`IdxTensorError::Operation`] when the network is disconnected,
518/// when indices do not match, or when the backend contraction fails.
519///
520pub fn contract(tensors: &[&IdxTensor]) -> std::result::Result<IdxTensor, IdxTensorError> {
521    contract_with_options(tensors, ContractionOptions::new())
522}
523
524/// Contract a connected tensor network with advanced options.
525/// # Errors
526///
527/// Returns [`IdxTensorError::Operation`] when the network is disconnected,
528/// when indices do not match, or when the backend contraction fails.
529///
530pub fn contract_with_options(
531    tensors: &[&IdxTensor],
532    options: ContractionOptions<'_>,
533) -> std::result::Result<IdxTensor, IdxTensorError> {
534    contract_with_options_impl(tensors, options).map_err(IdxTensorError::from)
535}
536
537/// Contract owned tensors with the default connected-network semantics.
538/// # Errors
539///
540/// Returns [`IdxTensorError::Operation`] when the network is disconnected,
541/// when indices do not match, or when the backend contraction fails.
542///
543pub fn contract_owned(tensors: Vec<IdxTensor>) -> std::result::Result<IdxTensor, IdxTensorError> {
544    contract_owned_with_options(tensors, ContractionOptions::new())
545}
546
547/// Contract owned tensors with advanced connected-network options.
548/// # Errors
549///
550/// Returns [`IdxTensorError::Operation`] when the network is disconnected,
551/// when indices do not match, or when the backend contraction fails.
552///
553pub fn contract_owned_with_options(
554    tensors: Vec<IdxTensor>,
555    options: ContractionOptions<'_>,
556) -> std::result::Result<IdxTensor, IdxTensorError> {
557    let tensor_refs = tensors.iter().collect::<Vec<_>>();
558    let components =
559        find_tensor_connected_components_with_retained(&tensor_refs, options.retain_indices);
560    if components.len() > 1 {
561        return Err(IdxTensorError::from(anyhow::anyhow!(
562            "Tensors form disconnected components; use explicit outer_product operations for an intentional disconnected product"
563        )));
564    }
565    drop(tensor_refs);
566    contract_owned_with_options_impl(tensors, options).map_err(IdxTensorError::from)
567}
568
569/// Contract two tensors with the default pairwise semantics.
570///
571/// This is the concrete `IdxTensor` entry point for binary contraction. It
572/// contracts all common indices and preserves the pairwise structured fast
573/// paths used by [`crate::TensorContractionLike::contract_pair`].
574/// # Errors
575///
576/// Returns [`IdxTensorError::Operation`] when the pair has incompatible
577/// indices or when the backend contraction fails.
578///
579pub fn contract_pair(
580    lhs: &IdxTensor,
581    rhs: &IdxTensor,
582) -> std::result::Result<IdxTensor, IdxTensorError> {
583    lhs.try_contract_pairwise_default_with_options(rhs, PairwiseContractionOptions::new())
584        .map_err(IdxTensorError::from)
585}
586
587/// Contract two tensors with operand-level conjugation options.
588///
589/// This has the same index semantics as [`contract_pair`], with optional
590/// conjugation applied to either operand before matching and contracting common
591/// indices. Implementations may pass conjugation to the backend to avoid
592/// materializing conjugated payloads.
593///
594/// # Errors
595///
596/// Returns [`IdxTensorError::Operation`] when the pair has incompatible
597/// indices or when the backend contraction fails.
598///
599/// # Examples
600///
601/// ```
602/// use num_complex::Complex64;
603/// use tensor4all_core::{
604///     contract_pair, contract_pair_with_operand_options, DynIndex,
605///     PairwiseContractionOptions, IdxTensor,
606/// };
607///
608/// let i = DynIndex::new_dyn(2);
609/// let lhs = IdxTensor::from_dense(
610///     vec![i.clone()],
611///     vec![Complex64::new(1.0, 1.0), Complex64::new(0.0, 2.0)],
612/// ).unwrap();
613/// let rhs = IdxTensor::from_dense(
614///     vec![i],
615///     vec![Complex64::new(2.0, 0.0), Complex64::new(3.0, -1.0)],
616/// ).unwrap();
617///
618/// let flagged = contract_pair_with_operand_options(
619///     &lhs,
620///     &rhs,
621///     PairwiseContractionOptions::new().with_lhs_conj(true),
622/// ).unwrap();
623/// let materialized = contract_pair(&lhs.conj(), &rhs).unwrap();
624///
625/// assert!((flagged.sum().unwrap() - materialized.sum().unwrap()).abs() < 1e-12);
626/// ```
627pub fn contract_pair_with_operand_options(
628    lhs: &IdxTensor,
629    rhs: &IdxTensor,
630    options: PairwiseContractionOptions,
631) -> std::result::Result<IdxTensor, IdxTensorError> {
632    lhs.try_contract_pairwise_default_with_options(rhs, options)
633        .map_err(IdxTensorError::from)
634}
635
636/// Contract two tensors with explicit contraction options.
637/// # Errors
638///
639/// Returns [`IdxTensorError::Operation`] when the pair has incompatible
640/// indices or when the backend contraction fails.
641///
642pub fn contract_pair_with_options(
643    lhs: &IdxTensor,
644    rhs: &IdxTensor,
645    options: ContractionOptions<'_>,
646) -> std::result::Result<IdxTensor, IdxTensorError> {
647    contract_with_options(&[lhs, rhs], options)
648}
649
650/// Contract two tensors along explicitly specified index pairs.
651/// # Errors
652///
653/// Returns [`IdxTensorError::Operation`] when the contracted indices do not
654/// match or when the backend contraction fails.
655///
656pub fn tensordot(
657    lhs: &IdxTensor,
658    rhs: &IdxTensor,
659    pairs: &[(DynIndex, DynIndex)],
660) -> std::result::Result<IdxTensor, IdxTensorError> {
661    lhs.try_tensordot_pairwise_explicit(rhs, pairs)
662        .map_err(IdxTensorError::from)
663}
664
665/// Compute the outer product of two tensors.
666///
667/// This is an explicit tensor product, not a dense-only operation. Compact
668/// structured storage is preserved when the operand layouts allow it.
669/// # Errors
670///
671/// Returns [`IdxTensorError::Operation`] when the two tensors share a
672/// contractable index or when the backend construction fails.
673///
674pub fn outer_product(
675    lhs: &IdxTensor,
676    rhs: &IdxTensor,
677) -> std::result::Result<IdxTensor, IdxTensorError> {
678    lhs.try_outer_product_pairwise(rhs)
679        .map_err(IdxTensorError::from)
680}
681
682/// Contract multiple owned tensors into a single tensor.
683///
684/// This is the consuming implementation for [`contract_owned_with_options`]. It
685/// preserves the same contraction semantics while allowing eligible non-AD
686/// dense inputs to use tenferro's owned eager einsum executor. When any input
687/// tracks gradients, or when compact structured metadata needs the borrowed
688/// path, this function falls back to the shared borrowed execution so semantics
689/// and reverse-mode AD remain intact.
690fn contract_owned_with_options_impl(
691    tensors: Vec<IdxTensor>,
692    options: ContractionOptions<'_>,
693) -> Result<IdxTensor> {
694    match tensors.len() {
695        0 => Err(anyhow::anyhow!("No tensors to contract")),
696        _ => {
697            let tensor_refs = tensors.iter().collect::<Vec<_>>();
698            validate_retained_indices_exist(&tensor_refs, options.retain_indices)?;
699
700            if tensors.len() == 1 {
701                drop(tensor_refs);
702                let Some(tensor) = tensors.into_iter().next() else {
703                    return Err(anyhow::anyhow!("No tensors to contract"));
704                };
705                return Ok(tensor);
706            }
707
708            let has_structured_storage = tensor_refs
709                .iter()
710                .map(|tensor| has_dense_axis_classes(tensor).map(|dense| !dense))
711                .collect::<Result<Vec<_>>>()?
712                .into_iter()
713                .any(|structured| structured);
714            let requires_borrowed_path =
715                tensor_refs.iter().any(|tensor| tensor.tracks_grad()) || has_structured_storage;
716            if requires_borrowed_path {
717                return contract_with_options(&tensor_refs, options).map_err(anyhow::Error::from);
718            }
719
720            let components = find_tensor_connected_components_with_retained(
721                &tensor_refs,
722                options.retain_indices,
723            );
724            if components.len() > 1 {
725                return Err(anyhow::anyhow!(
726                    "Tensors form disconnected components; use explicit outer_product operations for an intentional disconnected product"
727                ));
728            }
729
730            let plan = build_contraction_plan(&tensor_refs, options)?;
731            drop(tensor_refs);
732            let native_operands = tensors
733                .into_iter()
734                .enumerate()
735                .map(|(tensor_idx, tensor)| {
736                    Ok((
737                        {
738                            let inner = tensor.as_inner()?;
739                            with_engine(inner.runtime(), None, |engine| {
740                                inner.duplicate_value(engine)
741                            })?
742                        },
743                        plan.input_ids[tensor_idx].clone(),
744                    ))
745                })
746                .collect::<Result<Vec<_>>>()?;
747            let result_native = einsum_native_tensors_owned(native_operands, &plan.output_ids)?;
748            IdxTensor::from_untracked_native_with_axis_classes(
749                plan.result_indices,
750                result_native,
751                plan.result_axis_classes,
752            )
753        }
754    }
755}
756
757fn has_dense_axis_classes(tensor: &IdxTensor) -> Result<bool> {
758    Ok(tensor
759        .axis_classes()
760        .iter()
761        .copied()
762        .eq(0..tensor.indices().len()))
763}
764
765fn contract_with_options_impl(
766    tensors: &[&IdxTensor],
767    options: ContractionOptions<'_>,
768) -> Result<IdxTensor> {
769    match tensors.len() {
770        0 => Err(anyhow::anyhow!("No tensors to contract")),
771        _ => {
772            validate_retained_indices_exist(tensors, options.retain_indices)?;
773            if tensors.len() == 1 {
774                return Ok((*tensors[0]).clone());
775            }
776
777            // Check connectivity first
778            let components =
779                find_tensor_connected_components_with_retained(tensors, options.retain_indices);
780            if components.len() > 1 {
781                return Err(anyhow::anyhow!(
782                    "Disconnected tensor network: {} components found",
783                    components.len()
784                ));
785            }
786
787            if tensors.len() == 2 && options.retain_indices.is_empty() {
788                return tensors[0].try_contract_pairwise_default_with_options(
789                    tensors[1],
790                    PairwiseContractionOptions::new(),
791                );
792            }
793
794            let has_structured_storage = tensors
795                .iter()
796                .map(|tensor| has_dense_axis_classes(tensor).map(|dense| !dense))
797                .collect::<Result<Vec<_>>>()?
798                .into_iter()
799                .any(|structured| structured);
800            let has_grad = tensors.iter().any(|tensor| tensor.tracks_grad());
801            if has_structured_storage || has_grad {
802                let plan = build_contraction_plan(tensors, options)?;
803                return IdxTensor::contract_structured_payloads_nary(
804                    tensors,
805                    plan.result_indices,
806                    plan.input_ids,
807                    plan.output_ids,
808                );
809            }
810
811            // Connectivity verified - skip check in impl
812            contract_impl(tensors, options)
813        }
814    }
815}
816
817// ============================================================================
818// Union-Find for Diag axis grouping
819// ============================================================================
820
821/// Union-Find data structure for grouping axis IDs.
822///
823/// Used to merge diagonal axes from Diag tensors so that they share
824/// the same representative ID when passed to einsum.
825#[derive(Debug, Clone)]
826#[cfg(test)]
827pub(crate) struct AxisUnionFind {
828    /// Maps each ID to its parent. If parent[id] == id, it's a root.
829    parent: HashMap<DynId, DynId>,
830    /// Rank for union by rank optimization.
831    rank: HashMap<DynId, usize>,
832}
833
834#[cfg(test)]
835impl AxisUnionFind {
836    /// Create a new empty union-find structure.
837    pub fn new() -> Self {
838        Self {
839            parent: HashMap::new(),
840            rank: HashMap::new(),
841        }
842    }
843
844    /// Add an ID to the structure (as its own set).
845    pub fn make_set(&mut self, id: DynId) {
846        use std::collections::hash_map::Entry;
847        if let Entry::Vacant(e) = self.parent.entry(id) {
848            e.insert(id);
849            self.rank.insert(id, 0);
850        }
851    }
852
853    /// Find the representative (root) of the set containing `id`.
854    /// Uses path compression for efficiency.
855    pub fn find(&mut self, id: DynId) -> DynId {
856        self.make_set(id);
857        if self.parent[&id] != id {
858            let root = self.find(self.parent[&id]);
859            self.parent.insert(id, root);
860        }
861        self.parent[&id]
862    }
863
864    /// Union the sets containing `a` and `b`.
865    /// Uses union by rank for efficiency.
866    pub fn union(&mut self, a: DynId, b: DynId) {
867        let root_a = self.find(a);
868        let root_b = self.find(b);
869
870        if root_a == root_b {
871            return;
872        }
873
874        let rank_a = self.rank[&root_a];
875        let rank_b = self.rank[&root_b];
876
877        if rank_a < rank_b {
878            self.parent.insert(root_a, root_b);
879        } else if rank_a > rank_b {
880            self.parent.insert(root_b, root_a);
881        } else {
882            self.parent.insert(root_b, root_a);
883            if let Some(rank) = self.rank.get_mut(&root_a) {
884                *rank += 1;
885            }
886        }
887    }
888
889    /// Remap an ID to its representative.
890    pub fn remap(&mut self, id: DynId) -> DynId {
891        self.find(id)
892    }
893
894    /// Remap a slice of IDs to their representatives.
895    pub fn remap_ids(&mut self, ids: &[DynId]) -> Vec<DynId> {
896        ids.iter().map(|id| self.find(*id)).collect()
897    }
898}
899
900#[cfg(test)]
901impl Default for AxisUnionFind {
902    fn default() -> Self {
903        Self::new()
904    }
905}
906
907// ============================================================================
908// Axis helper builders
909// ============================================================================
910
911/// Remap tensor indices using the union-find structure.
912///
913/// Returns a vector of remapped IDs for each tensor, suitable for passing
914/// to einsum. The original tensors are not modified.
915#[cfg(test)]
916pub(crate) fn remap_tensor_ids(tensors: &[&IdxTensor], uf: &mut AxisUnionFind) -> Vec<Vec<DynId>> {
917    tensors
918        .iter()
919        .map(|t| t.indices.iter().map(|idx| uf.find(*idx.id())).collect())
920        .collect()
921}
922
923/// Remap output IDs using the union-find structure.
924#[cfg(test)]
925pub(crate) fn remap_output_ids(output: &[DynIndex], uf: &mut AxisUnionFind) -> Vec<DynId> {
926    output.iter().map(|idx| uf.find(*idx.id())).collect()
927}
928
929/// Collect dimension sizes for remapped IDs.
930///
931/// For unified IDs (from Diag tensors), all axes must have the same dimension,
932/// so we just take the first occurrence.
933#[cfg(test)]
934pub(crate) fn collect_sizes(
935    tensors: &[&IdxTensor],
936    uf: &mut AxisUnionFind,
937) -> HashMap<DynId, usize> {
938    let mut sizes = HashMap::new();
939
940    for tensor in tensors {
941        let dims = tensor.dims();
942        for (idx, &dim) in tensor.indices.iter().zip(dims.iter()) {
943            let rep = uf.find(*idx.id());
944            sizes.entry(rep).or_insert(dim);
945        }
946    }
947
948    sizes
949}
950
951// ============================================================================
952// Contraction implementation
953// ============================================================================
954
955/// Internal implementation of multi-tensor contraction.
956///
957/// Structured operands use compact payload einsum labels, preserving equality
958/// metadata without materializing logical dense tensors. Dense operands continue
959/// to use the native backend path.
960///
961/// The result keeps the common eager dtype across `f32`, `f64`, `c32`, and
962/// `c64` operands, using the backend's normal mixed-dtype promotion rules.
963fn contract_impl(tensors: &[&IdxTensor], options: ContractionOptions<'_>) -> Result<IdxTensor> {
964    // 1. Build the contraction plan from internal labels.
965    let plan = build_contraction_plan(tensors, options)?;
966
967    // Note: Connectivity check is done by caller.
968    // via find_tensor_connected_components before calling this function
969
970    // 3. Build sizes from unique internal IDs.
971    let mut sizes: HashMap<usize, usize> = HashMap::new();
972    for (tensor_idx, tensor) in tensors.iter().enumerate() {
973        let dims = tensor.dims();
974        for (pos, &dim) in dims.iter().enumerate() {
975            let internal_id = plan.input_ids[tensor_idx][pos];
976            match sizes.entry(internal_id) {
977                std::collections::hash_map::Entry::Vacant(entry) => {
978                    entry.insert(dim);
979                }
980                std::collections::hash_map::Entry::Occupied(entry) => {
981                    if *entry.get() != dim {
982                        return Err(anyhow::anyhow!(
983                            "Internal label shape mismatch: label {} has dimensions {} and {}",
984                            internal_id,
985                            entry.get(),
986                            dim
987                        ));
988                    }
989                }
990            }
991        }
992    }
993
994    let profile_signature = contract_profile_enabled().then(|| ContractSignature {
995        operands: tensors
996            .iter()
997            .enumerate()
998            .map(|(tensor_idx, tensor)| ContractOperandSignature {
999                dims: tensor.dims().to_vec(),
1000                ids: plan.input_ids[tensor_idx].clone(),
1001                is_diag: tensor.is_diag(),
1002            })
1003            .collect(),
1004        output_ids: plan.output_ids.clone(),
1005        output_dims: plan.output_ids.iter().map(|id| sizes[id]).collect(),
1006    });
1007    let profile_started = contract_profile_enabled().then(Instant::now);
1008
1009    let result = execute_contraction_plan(tensors, &plan, !options.retain_indices.is_empty())?;
1010    if let (Some(signature), Some(started)) = (profile_signature, profile_started) {
1011        record_contract_profile(signature, started.elapsed());
1012    }
1013    Ok(result)
1014}
1015
1016/// Contract a resident structured/diagonal pair through the eager plan path.
1017///
1018/// Pairwise fast paths borrow native host payloads, which device-resident
1019/// operands cannot provide. This builds the same 2-tensor plan the N-ary
1020/// executor uses (diagonal-aware) and runs it in the operands' owning
1021/// runtime. Conjugation flags materialize first via resident eager ops.
1022#[cfg(feature = "tenferro-cuda")]
1023pub(crate) fn contract_pair_via_plan(
1024    lhs: &IdxTensor,
1025    rhs: &IdxTensor,
1026    options: PairwiseContractionOptions,
1027) -> Result<IdxTensor> {
1028    let lhs_owned;
1029    let rhs_owned;
1030    let (lhs, rhs) = match (options.lhs_conj, options.rhs_conj) {
1031        (false, false) => (lhs, rhs),
1032        (true, false) => {
1033            lhs_owned = lhs.conj();
1034            (&lhs_owned, rhs)
1035        }
1036        (false, true) => {
1037            rhs_owned = rhs.conj();
1038            (lhs, &rhs_owned)
1039        }
1040        (true, true) => {
1041            lhs_owned = lhs.conj();
1042            rhs_owned = rhs.conj();
1043            (&lhs_owned, &rhs_owned)
1044        }
1045    };
1046    let tensors = [lhs, rhs];
1047    let plan = build_contraction_plan(&tensors, ContractionOptions::new())?;
1048    execute_contraction_plan(&tensors, &plan, false)
1049}
1050
1051fn execute_contraction_plan(
1052    tensors: &[&IdxTensor],
1053    plan: &ContractionPlan,
1054    has_retained_indices: bool,
1055) -> Result<IdxTensor> {
1056    // Device-resident operands cannot enter the native host session below;
1057    // run them through the owning-runtime eager einsum path (the same
1058    // dispatch the tracked path uses). Host operands keep the native path
1059    // unchanged. Mixed host/device sets keep failing loudly in the native
1060    // path instead of silently migrating.
1061    #[cfg(feature = "tenferro-cuda")]
1062    if !tensors.is_empty() && tensors.iter().all(|tensor| tensor.is_cuda_resident()) {
1063        let operands = tensors
1064            .iter()
1065            .map(|tensor| tensor.as_inner())
1066            .collect::<Result<Vec<_>>>()?;
1067        let subscripts = build_einsum_subscripts_from_usize_ids(&plan.input_ids, &plan.output_ids)?;
1068        let runtime = operands
1069            .first()
1070            .map(|operand| operand.runtime())
1071            .ok_or_else(|| anyhow::anyhow!("No tensors to contract"))?;
1072        let result = with_eager_session(runtime, None, |session| {
1073            session.einsum_subscripts(&operands, &subscripts)
1074        })?;
1075        return IdxTensor::from_inner_with_axis_classes(
1076            plan.result_indices.clone(),
1077            result,
1078            plan.result_axis_classes.clone(),
1079        );
1080    }
1081    let any_grad = tensors.iter().any(|tensor| tensor.tracks_grad());
1082    if any_grad {
1083        let first_dtype = tensors[0].as_inner()?.dtype();
1084        let same_dtype = tensors
1085            .iter()
1086            .map(|tensor| Ok(tensor.as_inner()?.dtype() == first_dtype))
1087            .collect::<Result<Vec<_>>>()?
1088            .into_iter()
1089            .all(|same| same);
1090        let has_non_dense_axis_classes = tensors.iter().any(|tensor| {
1091            tensor
1092                .axis_classes()
1093                .iter()
1094                .copied()
1095                .enumerate()
1096                .any(|(axis, class)| axis != class)
1097        });
1098
1099        if same_dtype && has_non_dense_axis_classes && !has_retained_indices {
1100            // Structured payload AD still relies on the existing pairwise structured
1101            // path until structured N-ary planning is implemented.
1102            let mut iter = tensors.iter();
1103            let Some(first) = iter.next() else {
1104                return Err(anyhow::anyhow!("No tensors to contract"));
1105            };
1106            let mut result = (*first).clone();
1107            for tensor in iter {
1108                result = contract_pair(&result, tensor)?;
1109            }
1110            return Ok(result);
1111        }
1112
1113        let operands = tensors
1114            .iter()
1115            .map(|tensor| tensor.as_inner())
1116            .collect::<Result<Vec<_>>>()?;
1117        let subscripts = build_einsum_subscripts_from_usize_ids(&plan.input_ids, &plan.output_ids)?;
1118        let runtime = operands
1119            .first()
1120            .map(|operand| operand.runtime())
1121            .ok_or_else(|| anyhow::anyhow!("No tensors to contract"))?;
1122        let result = with_eager_session(runtime, None, |session| {
1123            session.einsum_subscripts(&operands, &subscripts)
1124        })?;
1125        return IdxTensor::from_inner_with_axis_classes(
1126            plan.result_indices.clone(),
1127            result,
1128            plan.result_axis_classes.clone(),
1129        );
1130    }
1131
1132    let native_operands = tensors
1133        .iter()
1134        .enumerate()
1135        .map(|(tensor_idx, tensor)| {
1136            Ok((
1137                NativeTensorReadInput::Borrowed(tensor.as_inner()?.tensor_read()),
1138                plan.input_ids[tensor_idx].as_slice(),
1139            ))
1140        })
1141        .collect::<Result<Vec<_>>>()?;
1142    let operand_refs = native_operands
1143        .iter()
1144        .map(|(tensor, ids)| (tensor, *ids))
1145        .collect::<Vec<_>>();
1146    let result_native = einsum_native_tensor_reads(&operand_refs, &plan.output_ids)?;
1147    // Wrap the result in the operands' common eager runtime when they share
1148    // one, so explicit-context tensors stay in their context instead of
1149    // falling back to the process-global default. Mixed runtimes keep the
1150    // legacy default wrap; rejecting them is #623 scope, not this seam's.
1151    let mut common: Option<std::sync::Arc<tenferro_ad::EagerRuntime>> = None;
1152    let mut mixed = false;
1153    for tensor in tensors {
1154        if let Some(runtime) = tensor.eager_runtime() {
1155            match &common {
1156                None => common = Some(runtime),
1157                Some(first) => {
1158                    if first.id() != runtime.id() {
1159                        mixed = true;
1160                    }
1161                }
1162            }
1163        }
1164    }
1165    match (common, mixed) {
1166        (Some(runtime), false) => {
1167            let inner = tenferro_ad::extension::adopt_untracked_eager_value(
1168                runtime,
1169                TensorValue::from_tensor(result_native),
1170            )?;
1171            IdxTensor::from_inner_with_axis_classes(
1172                plan.result_indices.clone(),
1173                inner,
1174                plan.result_axis_classes.clone(),
1175            )
1176        }
1177        _ => IdxTensor::from_untracked_native_with_axis_classes(
1178            plan.result_indices.clone(),
1179            result_native,
1180            plan.result_axis_classes.clone(),
1181        ),
1182    }
1183}
1184
1185fn build_einsum_subscripts_from_usize_ids(
1186    input_ids: &[Vec<usize>],
1187    output_ids: &[usize],
1188) -> Result<EinsumSubscripts> {
1189    let inputs = input_ids
1190        .iter()
1191        .map(|ids| {
1192            ids.iter()
1193                .map(|&id| {
1194                    u32::try_from(id)
1195                        .map_err(|_| anyhow::anyhow!("einsum label {id} exceeds u32 range"))
1196                })
1197                .collect::<Result<Vec<_>>>()
1198        })
1199        .collect::<Result<Vec<_>>>()?;
1200    let output = output_ids
1201        .iter()
1202        .map(|&id| {
1203            u32::try_from(id).map_err(|_| anyhow::anyhow!("einsum label {id} exceeds u32 range"))
1204        })
1205        .collect::<Result<Vec<_>>>()?;
1206    let input_refs = inputs.iter().map(Vec::as_slice).collect::<Vec<_>>();
1207    Ok(EinsumSubscripts::new(&input_refs, &output))
1208}
1209
1210/// A contraction plan with internal labels and result ordering.
1211#[derive(Debug, Clone)]
1212pub(crate) struct ContractionPlan {
1213    pub(crate) input_ids: Vec<Vec<usize>>,
1214    pub(crate) output_ids: Vec<usize>,
1215    pub(crate) result_indices: Vec<DynIndex>,
1216    pub(crate) result_axis_classes: Vec<usize>,
1217}
1218
1219/// Plan an N-ary contraction for the explicit-session route.
1220///
1221/// Runs the compatibility route's own validation (retained-index existence,
1222/// connectivity, empty input) and planning, so a session contraction has the
1223/// same index semantics and refuses the same networks. A single operand needs
1224/// no plan; the caller returns its clone exactly as the compatibility route
1225/// does.
1226pub(crate) fn session_contraction_plan(
1227    tensors: &[&IdxTensor],
1228    options: ContractionOptions<'_>,
1229) -> Result<Option<ContractionPlan>> {
1230    if tensors.is_empty() {
1231        return Err(anyhow::anyhow!("No tensors to contract"));
1232    }
1233    validate_retained_indices_exist(tensors, options.retain_indices)?;
1234    if tensors.len() == 1 {
1235        return Ok(None);
1236    }
1237    let components =
1238        find_tensor_connected_components_with_retained(tensors, options.retain_indices);
1239    if components.len() > 1 {
1240        return Err(anyhow::anyhow!(
1241            "Disconnected tensor network: {} components found",
1242            components.len()
1243        ));
1244    }
1245    build_contraction_plan(tensors, options).map(Some)
1246}
1247
1248pub(crate) fn build_contraction_plan(
1249    tensors: &[&IdxTensor],
1250    options: ContractionOptions<'_>,
1251) -> Result<ContractionPlan> {
1252    let retained_indices: HashSet<DynIndex> = options.retain_indices.iter().cloned().collect();
1253    let (input_ids, internal_id_to_original) = build_internal_ids(tensors, &retained_indices)?;
1254
1255    let mut counts: HashMap<usize, usize> = HashMap::new();
1256    for ids in &input_ids {
1257        for &internal_id in ids {
1258            *counts.entry(internal_id).or_insert(0) += 1;
1259        }
1260    }
1261    let mut output_ids = Vec::new();
1262    let mut seen_output = HashSet::new();
1263    let mut found_retained = HashSet::new();
1264
1265    for (tensor_idx, tensor) in tensors.iter().enumerate() {
1266        for (axis, idx) in tensor.indices.iter().enumerate() {
1267            let internal_id = input_ids[tensor_idx][axis];
1268            let should_output = counts[&internal_id] == 1 || retained_indices.contains(idx);
1269            if should_output && seen_output.insert(internal_id) {
1270                output_ids.push(internal_id);
1271            }
1272            if retained_indices.contains(idx) {
1273                found_retained.insert(idx.clone());
1274            }
1275        }
1276    }
1277
1278    for retained in retained_indices {
1279        if !found_retained.contains(&retained) {
1280            return Err(anyhow::anyhow!(
1281                "Retained index {:?} does not appear in the input tensors",
1282                retained
1283            ));
1284        }
1285    }
1286
1287    let result_indices: Vec<DynIndex> = output_ids
1288        .iter()
1289        .map(|&internal_id| {
1290            let (tensor_idx, pos) = internal_id_to_original[&internal_id];
1291            tensors[tensor_idx].indices[pos].clone()
1292        })
1293        .collect();
1294    validate_unique_output_indices(&result_indices)?;
1295    let result_axis_classes =
1296        output_axis_classes(tensors, &input_ids, &output_ids, &internal_id_to_original)?;
1297
1298    Ok(ContractionPlan {
1299        input_ids,
1300        output_ids,
1301        result_indices,
1302        result_axis_classes,
1303    })
1304}
1305
1306fn validate_retained_indices_exist(
1307    tensors: &[&IdxTensor],
1308    retain_indices: &[DynIndex],
1309) -> Result<()> {
1310    for retain in retain_indices {
1311        let found = tensors
1312            .iter()
1313            .any(|tensor| tensor.indices().iter().any(|idx| idx == retain));
1314        if !found {
1315            return Err(anyhow::anyhow!(
1316                "Retained index {:?} does not appear in the input tensors",
1317                retain
1318            ));
1319        }
1320    }
1321    Ok(())
1322}
1323
1324fn validate_unique_output_indices(indices: &[DynIndex]) -> Result<()> {
1325    let mut seen = HashSet::new();
1326    for idx in indices {
1327        if !seen.insert(idx.clone()) {
1328            return Err(anyhow::anyhow!(
1329                "Contraction result would contain duplicate output indices"
1330            ));
1331        }
1332    }
1333    Ok(())
1334}
1335
1336fn output_axis_classes(
1337    tensors: &[&IdxTensor],
1338    ixs: &[Vec<usize>],
1339    output: &[usize],
1340    internal_id_to_original: &HashMap<usize, (usize, usize)>,
1341) -> Result<Vec<usize>> {
1342    fn find(parent: &mut [usize], value: usize) -> usize {
1343        if parent[value] != value {
1344            parent[value] = find(parent, parent[value]);
1345        }
1346        parent[value]
1347    }
1348
1349    fn union(parent: &mut [usize], lhs: usize, rhs: usize) {
1350        let lhs_root = find(parent, lhs);
1351        let rhs_root = find(parent, rhs);
1352        if lhs_root != rhs_root {
1353            parent[rhs_root] = lhs_root;
1354        }
1355    }
1356
1357    let mut class_offsets = Vec::with_capacity(tensors.len());
1358    let mut next_node = 0usize;
1359    for tensor in tensors {
1360        class_offsets.push(next_node);
1361        let payload_rank = tensor
1362            .axis_classes()
1363            .iter()
1364            .copied()
1365            .max()
1366            .map(|value| value + 1)
1367            .unwrap_or(0);
1368        next_node += payload_rank;
1369    }
1370    let mut parent: Vec<usize> = (0..next_node).collect();
1371    let mut axes_by_internal_id: HashMap<usize, Vec<usize>> = HashMap::new();
1372
1373    for (tensor_idx, tensor) in tensors.iter().enumerate() {
1374        for (axis, &internal_id) in ixs[tensor_idx].iter().enumerate() {
1375            let class_id = tensor.axis_classes()[axis];
1376            let node = class_offsets[tensor_idx] + class_id;
1377            axes_by_internal_id
1378                .entry(internal_id)
1379                .or_default()
1380                .push(node);
1381        }
1382    }
1383
1384    for nodes in axes_by_internal_id.values() {
1385        if let Some((&first, rest)) = nodes.split_first() {
1386            for &node in rest {
1387                union(&mut parent, first, node);
1388            }
1389        }
1390    }
1391
1392    let mut root_to_class = HashMap::new();
1393    let mut next_class = 0usize;
1394    output
1395        .iter()
1396        .map(|internal_id| {
1397            let (tensor_idx, axis) = internal_id_to_original[internal_id];
1398            let class_id = tensors[tensor_idx].axis_classes()[axis];
1399            let node = class_offsets[tensor_idx] + class_id;
1400            let root = find(&mut parent, node);
1401            Ok(*root_to_class.entry(root).or_insert_with(|| {
1402                let class = next_class;
1403                next_class += 1;
1404                class
1405            }))
1406        })
1407        .collect::<Result<Vec<_>>>()
1408}
1409
1410/// Build internal IDs for numeric contraction.
1411///
1412/// Uses the union-find to merge IDs that have already been proven equivalent by
1413/// the caller. Diagonal logical-axis metadata is intentionally handled outside
1414/// this numeric labeling step.
1415///
1416/// Returns: (ixs, internal_id_to_original)
1417#[allow(clippy::type_complexity)]
1418fn build_internal_ids(
1419    tensors: &[&IdxTensor],
1420    retained_indices: &HashSet<DynIndex>,
1421) -> Result<(Vec<Vec<usize>>, HashMap<usize, (usize, usize)>)> {
1422    let mut next_id = 0usize;
1423    let mut index_to_internal: HashMap<DynIndex, usize> = HashMap::new();
1424    let mut retained_index_to_internal: HashMap<DynIndex, usize> = HashMap::new();
1425    let mut assigned: HashMap<(usize, usize), usize> = HashMap::new();
1426    let mut internal_id_to_original: HashMap<usize, (usize, usize)> = HashMap::new();
1427
1428    for ti in 0..tensors.len() {
1429        for tj in (ti + 1)..tensors.len() {
1430            for (pi, idx_i) in tensors[ti].indices.iter().enumerate() {
1431                for (pj, idx_j) in tensors[tj].indices.iter().enumerate() {
1432                    if idx_i.is_contractable(idx_j) {
1433                        let key_i = (ti, pi);
1434                        let key_j = (tj, pj);
1435
1436                        match (assigned.get(&key_i).copied(), assigned.get(&key_j).copied()) {
1437                            (None, None) => {
1438                                let internal_id = if let Some(&id) = index_to_internal.get(idx_i) {
1439                                    id
1440                                } else {
1441                                    let id = next_id;
1442                                    next_id += 1;
1443                                    index_to_internal.insert(idx_i.clone(), id);
1444                                    internal_id_to_original.insert(id, key_i);
1445                                    id
1446                                };
1447                                assigned.insert(key_i, internal_id);
1448                                assigned.insert(key_j, internal_id);
1449                                if idx_i != idx_j {
1450                                    index_to_internal.insert(idx_j.clone(), internal_id);
1451                                }
1452                            }
1453                            (Some(id), None) => {
1454                                assigned.insert(key_j, id);
1455                                index_to_internal.insert(idx_j.clone(), id);
1456                            }
1457                            (None, Some(id)) => {
1458                                assigned.insert(key_i, id);
1459                                index_to_internal.insert(idx_i.clone(), id);
1460                            }
1461                            (Some(_id_i), Some(_id_j)) => {
1462                                // Both already assigned
1463                            }
1464                        }
1465                    }
1466                }
1467            }
1468        }
1469    }
1470
1471    // Assign IDs for unassigned indices (external indices)
1472    for (tensor_idx, tensor) in tensors.iter().enumerate() {
1473        for (pos, idx) in tensor.indices.iter().enumerate() {
1474            let key = (tensor_idx, pos);
1475            if let std::collections::hash_map::Entry::Vacant(e) = assigned.entry(key) {
1476                let internal_id = if retained_indices.contains(idx) {
1477                    if let Some(&id) = retained_index_to_internal.get(idx) {
1478                        id
1479                    } else {
1480                        let id = next_id;
1481                        next_id += 1;
1482                        retained_index_to_internal.insert(idx.clone(), id);
1483                        internal_id_to_original.insert(id, key);
1484                        id
1485                    }
1486                } else {
1487                    let id = next_id;
1488                    next_id += 1;
1489                    internal_id_to_original.insert(id, key);
1490                    id
1491                };
1492                e.insert(internal_id);
1493            }
1494        }
1495    }
1496
1497    // Build ixs
1498    let ixs: Vec<Vec<usize>> = tensors
1499        .iter()
1500        .enumerate()
1501        .map(|(tensor_idx, tensor)| {
1502            (0..tensor.indices.len())
1503                .map(|pos| assigned[&(tensor_idx, pos)])
1504                .collect()
1505        })
1506        .collect();
1507
1508    Ok((ixs, internal_id_to_original))
1509}
1510
1511// ============================================================================
1512// Helper functions for connected component detection
1513// ============================================================================
1514
1515/// Check if two tensors have any contractable indices.
1516fn has_contractable_indices(a: &IdxTensor, b: &IdxTensor) -> bool {
1517    a.indices
1518        .iter()
1519        .any(|idx_a| b.indices.iter().any(|idx_b| idx_a.is_contractable(idx_b)))
1520}
1521
1522/// Find connected components of tensors based on contractable indices.
1523///
1524/// Uses petgraph for O(V+E) connected component detection.
1525#[cfg(test)]
1526fn find_tensor_connected_components(tensors: &[&IdxTensor]) -> Vec<Vec<usize>> {
1527    find_tensor_connected_components_with_retained(tensors, &[])
1528}
1529
1530fn find_tensor_connected_components_with_retained(
1531    tensors: &[&IdxTensor],
1532    retain_indices: &[DynIndex],
1533) -> Vec<Vec<usize>> {
1534    let n = tensors.len();
1535    if n == 0 {
1536        return vec![];
1537    }
1538    if n == 1 {
1539        return vec![vec![0]];
1540    }
1541
1542    // Build undirected graph
1543    let mut graph = UnGraph::<(), ()>::new_undirected();
1544    let nodes: Vec<_> = (0..n).map(|_| graph.add_node(())).collect();
1545
1546    for i in 0..n {
1547        for j in (i + 1)..n {
1548            if has_contractable_indices(tensors[i], tensors[j]) {
1549                graph.add_edge(nodes[i], nodes[j], ());
1550            }
1551        }
1552    }
1553
1554    if !retain_indices.is_empty() {
1555        for i in 0..n {
1556            for j in (i + 1)..n {
1557                if shares_retained_index(tensors[i], tensors[j], retain_indices) {
1558                    graph.add_edge(nodes[i], nodes[j], ());
1559                }
1560            }
1561        }
1562    }
1563
1564    // Find connected components using petgraph
1565    let num_components = connected_components(&graph);
1566
1567    if num_components == 1 {
1568        return vec![(0..n).collect()];
1569    }
1570
1571    // Multiple components - group by component ID
1572    use petgraph::visit::Dfs;
1573    let mut visited = vec![false; n];
1574    let mut components = Vec::new();
1575
1576    for start in 0..n {
1577        if !visited[start] {
1578            let mut component = Vec::new();
1579            let mut dfs = Dfs::new(&graph, nodes[start]);
1580            while let Some(node) = dfs.next(&graph) {
1581                let idx = node.index();
1582                if !visited[idx] {
1583                    visited[idx] = true;
1584                    component.push(idx);
1585                }
1586            }
1587            component.sort();
1588            components.push(component);
1589        }
1590    }
1591
1592    components.sort_by_key(|c| c[0]);
1593    components
1594}
1595
1596fn shares_retained_index(a: &IdxTensor, b: &IdxTensor, retain_indices: &[DynIndex]) -> bool {
1597    retain_indices.iter().any(|retain| {
1598        a.indices().iter().any(|idx_a| idx_a == retain)
1599            && b.indices().iter().any(|idx_b| idx_b == retain)
1600    })
1601}
1602
1603#[cfg(test)]
1604mod tests;