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