Skip to main content

tensor4all_core/defaults/
contract_session.rs

1use std::sync::Arc;
2
3use tenferro::{Tensor as NativeTensor, TensorScalar};
4
5use crate::defaults::contract::PairwiseContractionOptions;
6use crate::defaults::idx_tensor::{IdxTensor, IdxTensorError, SessionDenseOperand};
7use crate::index_like::IndexLike;
8use crate::index_ops::prepare_contraction;
9
10/// Operation name the refusals and errors of this entry point carry.
11///
12/// Exceptions are the errors that carry a more specific origin: a deferred storage failure keeps
13/// [`IdxTensorError::Storage`], and a planning or publication failure keeps its own operation name.
14const OP: &str = "contract_pair_on_session";
15
16/// Contract two dense host tensors on a caller-held explicit concrete session.
17///
18/// The planning is the compatibility route's own (`operand_indices_for_contraction` plus
19/// `prepare_contraction`), so the result's indices - id, prime level and tags - are the ones
20/// [`contract_pair_with_operand_options`](crate::contract_pair_with_operand_options) produces. The
21/// execution is the caller's session ([`Session::contraction`](tensor4all_tensorbackend::explicit::Session::contraction),
22/// which also promotes mixed `f64`/`c64` operands on that session), and the result is published as dense
23/// host storage, so neither the process-global session nor an ambient eager runtime is entered and no
24/// graph is compiled.
25///
26/// Operands are adapted by representation rather than by materialization: dense host storage goes through
27/// the existing host/native bridge, and an untracked eager host value through its borrowed read
28/// representation, which the session consumes itself.
29///
30/// # Errors
31///
32/// Returns a typed [`IdxTensorError`] for an operand whose representation this slice does not serve - an
33/// AD-tracked value, a device-resident operand, or a diagonal, structured or compact payload - for a
34/// non-floating dtype, for an operand whose index list repeats an index, for incompatible
35/// index spaces (the planner's shape and dimension errors), when the session rejects the contraction, or
36/// when the result cannot be published. Every floating dtype (`f32`, `f64`, `c32`, `c64`) is served and the
37/// result keeps the session's dtype. A deferred storage error is returned as
38/// [`IdxTensorError::Storage`] with its original source.
39///
40/// # Examples
41///
42/// ```
43/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
44/// use tensor4all_core::{contract_pair_on_session, IdxTensor, PairwiseContractionOptions};
45/// use tensor4all_tensorbackend::CpuExecutionContext;
46/// use tensor4all_tensorbackend::CpuBackend;
47///
48/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
49/// let i = Index::new_dyn(2);
50/// let j = Index::new_dyn(3);
51/// let k = Index::new_dyn(2);
52/// let lhs: IdxTensor =
53///     IdxTensor::from_dense(vec![i.clone(), k.clone()], vec![1.0_f64, 2.0, 3.0, 4.0])?;
54/// let rhs: IdxTensor = IdxTensor::from_dense(
55///     vec![k.clone(), j.clone()],
56///     vec![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0],
57/// )?;
58///
59/// let product = context.with_concrete_session(|session| {
60///     contract_pair_on_session(session, &lhs, &rhs, PairwiseContractionOptions::new())
61/// })??;
62/// assert_eq!(product.indices().len(), 2);
63/// // Column-major over `[i, j]`: each `j` column sums the two `k` entries of that `i` row.
64/// assert_eq!(product.to_vec::<f64>()?, vec![4.0, 6.0, 4.0, 6.0, 4.0, 6.0]);
65/// # Ok::<(), Box<dyn std::error::Error>>(())
66/// ```
67pub fn contract_pair_on_session(
68    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
69    lhs: &IdxTensor,
70    rhs: &IdxTensor,
71    options: PairwiseContractionOptions,
72) -> Result<IdxTensor, IdxTensorError> {
73    // Malformed index lists are refused before planning: a repeated index makes the planner's
74    // result-length arithmetic underflow, and `replace_indices` can produce one through public API.
75    if !lhs.has_unique_indices() || !rhs.has_unique_indices() {
76        return Err(IdxTensorError::Operation {
77            operation: OP,
78            source: Arc::from(
79                anyhow::anyhow!(
80                    "every index of an operand must be distinct; a repeated index cannot be contracted"
81                )
82                .into_boxed_dyn_error(),
83            ),
84        });
85    }
86
87    // Planning: the compatibility route's own preparation, with the same conjugated operand index lists.
88    let lhs_indices = lhs.operand_indices_for_contraction(options.lhs_conj);
89    let rhs_indices = rhs.operand_indices_for_contraction(options.rhs_conj);
90    let lhs_dims = lhs.dims();
91    let rhs_dims = rhs.dims();
92    let spec = prepare_contraction(&lhs_indices, &lhs_dims, &rhs_indices, &rhs_dims)
93        .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
94
95    // Adaptation: refuse every representation this slice does not serve before touching a session.
96    let lhs_operand = lhs.session_dense_operand(OP)?;
97    let rhs_operand = rhs.session_dense_operand(OP)?;
98
99    let mut lhs_value = materialize_on_session(OP, session, lhs_operand)?;
100    let mut rhs_value = materialize_on_session(OP, session, rhs_operand)?;
101    if options.lhs_conj {
102        lhs_value = session
103            .conj(&lhs_value)
104            .map_err(|error| session_error(OP, error))?;
105    }
106    if options.rhs_conj {
107        rhs_value = session
108            .conj(&rhs_value)
109            .map_err(|error| session_error(OP, error))?;
110    }
111
112    let value = session
113        .contraction(&lhs_value, &spec.axes_a, &rhs_value, &spec.axes_b)
114        .map_err(|error| session_error(OP, error))?;
115
116    IdxTensor::publish_dense_session_result(spec.result_indices, &value)
117}
118
119/// Compute the inner product of two dense host tensors on a caller-held explicit session.
120///
121/// This is the reduction companion of [`contract_pair_on_session`] and the operation #510's TT inner
122/// product runs hottest. It mirrors [`IdxTensor::inner_product`]: when both operands carry the same index
123/// set the right operand is aligned to the left one's order on the session and contracted with the left
124/// operand conjugated, and otherwise the two are contracted over their shared indices and the remaining
125/// axes are reduced on the session. The result is a plain scalar.
126///
127/// # Errors
128///
129/// Returns a typed [`IdxTensorError`] under exactly the conditions [`contract_pair_on_session`] refuses an
130/// operand - a tracked value, a device-resident operand, a diagonal, structured or compact payload, an
131/// unsupported dtype, a repeated index, an incompatible index space - when the session rejects the
132/// contraction or the reduction, or when the result cannot be published.
133///
134/// # Examples
135///
136/// ```
137/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
138/// use tensor4all_core::{inner_product_on_session, IdxTensor};
139/// use tensor4all_tensorbackend::CpuExecutionContext;
140/// use tensor4all_tensorbackend::CpuBackend;
141///
142/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
143/// let i = Index::new_dyn(2);
144/// let j = Index::new_dyn(2);
145/// let lhs: IdxTensor =
146///     IdxTensor::from_dense(vec![i.clone(), j.clone()], vec![1.0_f64, 2.0, 3.0, 4.0])?;
147/// let rhs: IdxTensor =
148///     IdxTensor::from_dense(vec![i.clone(), j.clone()], vec![1.0_f64, 1.0, 1.0, 1.0])?;
149///
150/// let value = context.with_concrete_session(|session| {
151///     inner_product_on_session(session, &lhs, &rhs)
152/// })??;
153/// // conj(lhs) contracted with rhs over both indices: 1 + 2 + 3 + 4.
154/// assert_eq!(value.real(), 10.0);
155/// # Ok::<(), Box<dyn std::error::Error>>(())
156/// ```
157pub fn inner_product_on_session(
158    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
159    lhs: &IdxTensor,
160    rhs: &IdxTensor,
161) -> Result<crate::AnyScalar, IdxTensorError> {
162    // Malformed index lists are refused before planning, as in the contraction entry point.
163    if !lhs.has_unique_indices() || !rhs.has_unique_indices() {
164        return Err(IdxTensorError::Operation {
165            operation: OP,
166            source: Arc::from(
167                anyhow::anyhow!(
168                    "every index of an operand must be distinct; an inner product needs a valid index space"
169                )
170                .into_boxed_dyn_error(),
171            ),
172        });
173    }
174
175    let lhs_dims = lhs.dims();
176    let rhs_dims = rhs.dims();
177    // Both operands are adapted - and therefore refused - before either is materialized, so a refusal on
178    // the right cannot follow session work on the left.
179    let lhs_operand = lhs.session_dense_operand(OP)?;
180    let rhs_operand = rhs.session_dense_operand(OP)?;
181    // Planning is metadata too, so it happens before any numerical session work, and the route is decided
182    // once: an aligned pair needs only the permutation, an unaligned one needs the planner's axes.
183    let route = match alignment_permutation(lhs.indices(), rhs.indices()) {
184        Some(perm) => Route::Aligned(perm),
185        None => {
186            let lhs_indices = lhs.operand_indices_for_contraction(true);
187            let rhs_indices = rhs.operand_indices_for_contraction(false);
188            Route::Planned(Box::new(
189                prepare_contraction(&lhs_indices, &lhs_dims, &rhs_indices, &rhs_dims)
190                    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?,
191            ))
192        }
193    };
194
195    let mut lhs_value = materialize_on_session(OP, session, lhs_operand)?;
196    let mut rhs_value = materialize_on_session(OP, session, rhs_operand)?;
197    // The left operand is conjugated, exactly as the compatibility route conjugates it.
198    lhs_value = session
199        .conj(&lhs_value)
200        .map_err(|error| session_error(OP, error))?;
201
202    let product = match route {
203        // Same index set: align the right operand to the left one's order and contract every axis.
204        Route::Aligned(perm) => {
205            let axes: Vec<usize> = (0..rhs_dims.len()).collect();
206            if perm != axes {
207                rhs_value = session
208                    .permute(&rhs_value, &perm)
209                    .map_err(|error| session_error(OP, error))?;
210            }
211            session
212                .contraction(&lhs_value, &axes, &rhs_value, &axes)
213                .map_err(|error| session_error(OP, error))?
214        }
215        // Different index sets: contract the shared axes and reduce the rest.
216        Route::Planned(spec) => {
217            let contracted = session
218                .contraction(&lhs_value, &spec.axes_a, &rhs_value, &spec.axes_b)
219                .map_err(|error| session_error(OP, error))?;
220            session
221                .sum(&contracted)
222                .map_err(|error| session_error(OP, error))?
223        }
224    };
225
226    // The scalar is taken from the concrete value, not through the tensor's eager representation, which
227    // would adopt it into the process-global eager runtime.
228    let published = IdxTensor::publish_dense_session_result(Vec::new(), &product)?;
229    let scalar = match product.dtype() {
230        tenferro::DType::F64 => {
231            let value = first_scalar::<f64>(&product)?;
232            crate::AnyScalar::from_published(value, published)
233        }
234        tenferro::DType::F32 => {
235            let value = first_scalar::<f32>(&product)?;
236            crate::AnyScalar::from_published(value, published)
237        }
238        tenferro::DType::C32 => {
239            let value = first_scalar::<num_complex::Complex32>(&product)?;
240            crate::AnyScalar::from_published(value, published)
241        }
242        tenferro::DType::C64 => {
243            let value = first_scalar::<num_complex::Complex64>(&product)?;
244            crate::AnyScalar::from_published(value, published)
245        }
246        other => {
247            return Err(IdxTensorError::operation(
248                OP,
249                anyhow::anyhow!(
250                    "the explicit-session route returns floating-point scalars, not {other:?}"
251                ),
252            ));
253        }
254    };
255    Ok(scalar)
256}
257
258/// How the session should combine the two operands, decided before any session work.
259enum Route {
260    /// Both operands carry the same index set: permute the right one into the left one's order.
261    Aligned(Vec<usize>),
262    /// The index sets differ: contract the planned axes and reduce what is left.
263    ///
264    /// Boxed because the plan is much larger than the permutation and this enum is matched by value.
265    Planned(Box<crate::index_ops::ContractionSpec<crate::defaults::DynIndex>>),
266}
267
268/// Read the single element of a rank-zero result.
269fn first_scalar<T: TensorScalar>(value: &NativeTensor) -> Result<T, IdxTensorError> {
270    value
271        .as_slice::<T>()
272        .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?
273        .first()
274        .copied()
275        .ok_or_else(|| {
276            IdxTensorError::operation(OP, anyhow::anyhow!("the reduced result is empty"))
277        })
278}
279
280/// The permutation that reorders `rhs`'s axes into `lhs`'s index order, when both carry the same set.
281///
282/// Returns `None` when an index of `lhs` does not appear in `rhs`, which is the case the caller handles by
283/// contracting the shared axes and reducing the rest.
284fn alignment_permutation(
285    lhs_indices: &[crate::defaults::DynIndex],
286    rhs_indices: &[crate::defaults::DynIndex],
287) -> Option<Vec<usize>> {
288    if lhs_indices.len() != rhs_indices.len() {
289        return None;
290    }
291    let mut perm = Vec::with_capacity(lhs_indices.len());
292    for index in lhs_indices {
293        let position = rhs_indices
294            .iter()
295            .position(|candidate| candidate == index)?;
296        perm.push(position);
297    }
298    Some(perm)
299}
300
301/// Operation name the sum entry point reports; it shares the seam's adaptation, not its identity.
302const SUM_OP: &str = "sum_on_session";
303
304/// Sum every element of a dense host tensor on a caller-held explicit session.
305///
306/// The reduction companion of the F1 seam for the dense host representations this slice serves, so an app
307/// can reduce a value inside a region it holds open. The result is a plain scalar, published from the
308/// concrete value rather than adopted into any eager runtime.
309///
310/// **A zero-length axis is not parity with [`IdxTensor::sum`] on purpose** in this slice: the compatibility
311/// route accumulates from zero over the payload and answers `0`, while the session's reduction rejects a
312/// zero-length axis. The session keeps its backend's refusal rather than inventing a special case, and the
313/// difference is covered by a regression test. Everything else in the served domain matches.
314///
315/// # Errors
316///
317/// Returns a typed [`IdxTensorError`] under the same conditions as [`contract_pair_on_session`] refuses an
318/// operand - a tracked value, a device-resident operand, a diagonal, structured or compact payload, a
319/// deferred storage error, an unsupported dtype - when the session rejects the reduction, or when the
320/// result cannot be published.
321///
322/// # Examples
323///
324/// ```
325/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
326/// use tensor4all_core::{sum_on_session, IdxTensor};
327/// use tensor4all_tensorbackend::CpuExecutionContext;
328/// use tensor4all_tensorbackend::CpuBackend;
329///
330/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
331/// let i = Index::new_dyn(2);
332/// let k = Index::new_dyn(3);
333/// let tensor: IdxTensor =
334///     IdxTensor::from_dense(vec![i.clone(), k.clone()], vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0])?;
335///
336/// let total = context.with_concrete_session(|session| sum_on_session(session, &tensor))??;
337/// assert_eq!(total.real(), 21.0);
338/// # Ok::<(), Box<dyn std::error::Error>>(())
339/// ```
340pub fn sum_on_session(
341    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
342    tensor: &IdxTensor,
343) -> Result<crate::AnyScalar, IdxTensorError> {
344    // The operand is adapted - and therefore refused - before any session work, as in the other slices. The
345    // result is published as a rank-zero `IdxTensor` whose compact storage keeps the reduced dtype, so a
346    // narrow `f32`/`c32` operand yields a narrow scalar rather than a widened one.
347    let operand = tensor.session_dense_operand(SUM_OP)?;
348    let value = materialize_on_session(SUM_OP, session, operand)?;
349    // `Session::sum` reduces every axis and duplicates an already rank-zero value, so one call covers both.
350    let reduced = session
351        .sum(&value)
352        .map_err(|error| session_error(SUM_OP, error))?;
353    let published = IdxTensor::publish_dense_session_result(Vec::new(), &reduced)?;
354    let scalar = match reduced.dtype() {
355        tenferro::DType::F64 => {
356            crate::AnyScalar::from_published(first_scalar::<f64>(&reduced)?, published)
357        }
358        tenferro::DType::F32 => {
359            crate::AnyScalar::from_published(first_scalar::<f32>(&reduced)?, published)
360        }
361        tenferro::DType::C32 => crate::AnyScalar::from_published(
362            first_scalar::<num_complex::Complex32>(&reduced)?,
363            published,
364        ),
365        tenferro::DType::C64 => crate::AnyScalar::from_published(
366            first_scalar::<num_complex::Complex64>(&reduced)?,
367            published,
368        ),
369        other => {
370            return Err(IdxTensorError::operation(
371                SUM_OP,
372                anyhow::anyhow!(
373                    "the explicit-session route reduces floating-point dtypes, not {other:?}"
374                ),
375            ));
376        }
377    };
378    Ok(scalar)
379}
380
381/// Permute a dense host tensor's axes on a caller-held explicit session.
382///
383/// The numerical counterpart of [`IdxTensor::permute`]: the compatibility entry point materializes an eager
384/// transpose, which is numerical work rather than metadata, so an app that holds a region open needs this
385/// route too. The permutation convention is the compatibility one - `perm[new_axis] = old_axis` - and the
386/// result is the same materialized dense host value with the same permuted index list. An identity
387/// permutation is returned unchanged, exactly as the compatibility route returns a clone, and performs no
388/// session work.
389///
390/// # Errors
391///
392/// Returns a typed [`IdxTensorError`] for a permutation whose length does not match the rank, an axis out of
393/// range or repeated - with the shared validator's messages - and, **for a nonidentity permutation**, under
394/// the same conditions as [`contract_pair_on_session`] refuses an operand: an AD-tracked value, a
395/// device-resident operand, a diagonal, structured or compact payload, a deferred storage error, or a
396/// non-floating dtype. It also returns one when the session rejects the permutation or the
397/// result cannot be published.
398///
399/// **An identity permutation never reaches those refusals**: like the compatibility route it returns the
400/// tensor unchanged, whatever its representation or dtype, because it is metadata only. Execution - and
401/// therefore the floating-dtype domain of this seam - applies to the nonidentity case.
402///
403/// # Examples
404///
405/// ```
406/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
407/// use tensor4all_core::{permute_on_session, IdxTensor};
408/// use tensor4all_tensorbackend::CpuExecutionContext;
409/// use tensor4all_tensorbackend::CpuBackend;
410///
411/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
412/// let i = Index::new_dyn(2);
413/// let k = Index::new_dyn(3);
414/// let tensor: IdxTensor =
415///     IdxTensor::from_dense(vec![i.clone(), k.clone()], (1..=6).map(|v| v as f64).collect())?;
416///
417/// let transposed = context.with_concrete_session(|session| {
418///     permute_on_session(session, &tensor, &[1, 0])
419/// })??;
420/// assert_eq!(transposed.indices()[0], k);
421/// assert_eq!(transposed.indices()[1], i);
422/// // Column-major with the axes swapped: row major in the old order.
423/// assert_eq!(transposed.to_vec::<f64>()?, vec![1.0, 3.0, 5.0, 2.0, 4.0, 6.0]);
424/// # Ok::<(), Box<dyn std::error::Error>>(())
425/// ```
426pub fn permute_on_session(
427    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
428    tensor: &IdxTensor,
429    perm: &[usize],
430) -> Result<IdxTensor, IdxTensorError> {
431    const PERMUTE_OP: &str = "permute_on_session";
432    let rank = tensor.indices().len();
433    // The shared validator, before any session work: one definition of the contract for both entry points.
434    if let Err(message) = crate::defaults::idx_tensor::validate_permutation(rank, perm) {
435        return Err(IdxTensorError::operation(
436            PERMUTE_OP,
437            anyhow::anyhow!(message),
438        ));
439    }
440    if perm.iter().copied().eq(0..rank) {
441        // Identity is metadata only: no session work, exactly as the compatibility route clones.
442        return Ok(tensor.clone());
443    }
444
445    let operand = tensor.session_dense_operand(PERMUTE_OP)?;
446    let value = materialize_on_session(PERMUTE_OP, session, operand)?;
447    let input_ids: Vec<usize> = (0..rank).collect();
448    let read = tenferro::TensorRead::from_tensor(&value);
449    // Output labels in the requested order: new axis `i` takes old axis `perm[i]`.
450    let permuted = session
451        .einsum_reads(&[read], &[&input_ids], perm)
452        .map_err(|error| session_error(PERMUTE_OP, error))?;
453
454    let new_indices: Vec<crate::defaults::DynIndex> = perm
455        .iter()
456        .map(|&axis| tensor.indices()[axis].clone())
457        .collect();
458    IdxTensor::publish_dense_session_result(new_indices, &permuted)
459}
460
461/// Align a dense host tensor to a caller-supplied index order on a caller-held explicit session.
462///
463/// The numerical counterpart of [`IdxTensor::permute_indices`], which the app crates use to align operands
464/// before a contraction. The index list must be a permutation of the tensor's own indices by full index
465/// equality, exactly as the compatibility route requires; the permutation is derived with the same helper,
466/// so the two routes refuse the same inputs with the same messages. Execution is
467/// [`permute_on_session`]'s, so this route serves the same dense floating-dtype domain, and an identity
468/// permutation is metadata only - it returns the same storage under the supplied index list, for any
469/// representation, without touching the session.
470///
471/// # Errors
472///
473/// Returns a typed [`IdxTensorError`] when the index list is not a permutation of the tensor's indices (the
474/// compatibility helper's reason, reported under this entry point's operation name), when a supplied index
475/// carries a different dimension than the tensor's matching index (this route's own message), under the same
476/// conditions as a nonidentity [`permute_on_session`] refuses an operand - whose errors keep that route's
477/// operation name, because the execution is delegated - and when the session rejects the permutation or the
478/// result cannot be published. A delegated storage error keeps its original source rather than being
479/// re-labelled.
480///
481/// # Examples
482///
483/// ```
484/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
485/// use tensor4all_core::{permute_indices_on_session, IdxTensor};
486/// use tensor4all_tensorbackend::CpuExecutionContext;
487/// use tensor4all_tensorbackend::CpuBackend;
488///
489/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
490/// let i = Index::new_dyn(2);
491/// let k = Index::new_dyn(3);
492/// let tensor: IdxTensor =
493///     IdxTensor::from_dense(vec![i.clone(), k.clone()], (1..=6).map(|v| v as f64).collect())?;
494///
495/// let aligned = context.with_concrete_session(|session| {
496///     permute_indices_on_session(session, &tensor, &[k.clone(), i.clone()])
497/// })??;
498/// assert_eq!(aligned.indices()[0], k);
499/// assert_eq!(aligned.to_vec::<f64>()?, vec![1.0, 3.0, 5.0, 2.0, 4.0, 6.0]);
500/// # Ok::<(), Box<dyn std::error::Error>>(())
501/// ```
502pub fn permute_indices_on_session(
503    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
504    tensor: &IdxTensor,
505    new_indices: &[crate::defaults::DynIndex],
506) -> Result<IdxTensor, IdxTensorError> {
507    const OP: &str = "permute_indices_on_session";
508    // The compatibility route's own permutation derivation, so invalid index lists are refused identically.
509    let perm = crate::defaults::idx_tensor::compute_permutation_from_indices(
510        tensor.indices(),
511        new_indices,
512    )
513    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
514
515    // Full index equality ignores the dimension, so a supplied list can carry a different shape than the
516    // tensor's matching index. The compatibility route rejects that for a nonidentity order when it builds
517    // the result, and this route refuses it before either branch rather than silently keeping the tensor's
518    // own dimensions. For an identity order that is deliberately stricter than compatibility, which accepts
519    // malformed dimension metadata there.
520    for (position, supplied) in new_indices.iter().enumerate() {
521        let expected = &tensor.indices()[perm[position]];
522        if supplied.dim != expected.dim {
523            return Err(IdxTensorError::operation(
524                OP,
525                anyhow::anyhow!(
526                    "index {position} is supplied with dimension {} but the tensor's matching index has dimension {}",
527                    supplied.dim,
528                    expected.dim
529                ),
530            ));
531        }
532    }
533
534    if perm.iter().copied().eq(0..perm.len()) {
535        // Identity is metadata only: the same storage and eager cache under the supplied index list, for
536        // any representation.
537        return Ok(tensor.aligned_identity_metadata(new_indices));
538    }
539
540    permute_on_session(session, tensor, &perm)
541}
542
543/// Compute the squared Euclidean norm of a dense host tensor on a caller-held explicit session.
544///
545/// The compatibility entry point accumulates the norm over the tensor's payload through a scaled
546/// accumulator), so it computes the same value as the compatibility route rather than an algebraic
547/// shortcut. The session's part is the materialization: the operand is adapted and materialized on the
548/// caller's session instead of through the compatibility route.
549///
550/// Note what that accumulator does and does not change here: this API squares the accumulated norm, so for a
551/// tensor whose norm is representable but whose square is not - the `1e-200` scale - the returned value is
552/// `0` in the compatibility route as well, and [`norm_on_session`] squares-and-roots the same value. The
553/// accumulator's benefit is that individually underflowing squares can still add up to a representable
554/// result; it is not a promise about any single fixture.
555///
556/// # Errors
557///
558/// Returns [`IdxTensorError::NaNInput`] naming `norm_squared_on_session` when an element or the accumulated
559/// result is NaN. Otherwise it returns a typed [`IdxTensorError`] for the representation and dtype refusals
560/// the shared admission applies - a tracked value, a device-resident operand, a diagonal, structured or
561/// compact payload, a deferred storage error, or a non-floating dtype; note that this is the admission's
562/// refusal, not the compatibility route's `ScalarTypeMismatch`, and that the contraction route's
563/// repeated-index and planning refusals do not apply here - and for a materialization, session-copy or
564/// host-read failure while the operand is brought onto the caller's session.
565///
566/// **`f32` and `c32` operands are served here**, because this route returns a scalar: they are promoted into
567/// the same `f64` accumulation the compatibility route uses for them.
568///
569/// # Examples
570///
571/// ```
572/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
573/// use tensor4all_core::{norm_squared_on_session, IdxTensor};
574/// use tensor4all_tensorbackend::CpuExecutionContext;
575/// use tensor4all_tensorbackend::CpuBackend;
576///
577/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
578/// let i = Index::new_dyn(2);
579/// let tensor: IdxTensor = IdxTensor::from_dense(vec![i.clone()], vec![3.0_f64, 4.0])?;
580///
581/// let squared = context.with_concrete_session(|session| {
582///     norm_squared_on_session(session, &tensor)
583/// })??;
584/// assert!((squared - 25.0).abs() < 1e-12);
585/// # Ok::<(), Box<dyn std::error::Error>>(())
586/// ```
587pub fn norm_squared_on_session(
588    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
589    tensor: &IdxTensor,
590) -> Result<f64, IdxTensorError> {
591    const OP: &str = "norm_squared_on_session";
592    let operand = tensor.session_dense_operand(OP)?;
593    let value = materialize_on_session(OP, session, operand)?;
594    let squared =
595        match value.dtype() {
596            // The compatibility route accumulates every accepted floating dtype in `f64`, so promoting here is
597            // its behaviour rather than a widening of this route's own.
598            tenferro::DType::F32 => {
599                let values = value
600                    .as_slice::<f32>()
601                    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
602                crate::defaults::idx_tensor::lassq_norm_squared(
603                    values
604                        .iter()
605                        .map(|value| num_complex::Complex64::new(f64::from(*value), 0.0)),
606                )
607            }
608            tenferro::DType::C32 => {
609                let values = value
610                    .as_slice::<num_complex::Complex32>()
611                    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
612                crate::defaults::idx_tensor::lassq_norm_squared(values.iter().map(|value| {
613                    num_complex::Complex64::new(f64::from(value.re), f64::from(value.im))
614                }))
615            }
616            tenferro::DType::F64 => {
617                let values = value
618                    .as_slice::<f64>()
619                    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
620                crate::defaults::idx_tensor::lassq_norm_squared(
621                    values
622                        .iter()
623                        .map(|value| num_complex::Complex64::new(*value, 0.0)),
624                )
625            }
626            tenferro::DType::C64 => {
627                let values = value
628                    .as_slice::<num_complex::Complex64>()
629                    .map_err(|error| IdxTensorError::operation(OP, anyhow::Error::new(error)))?;
630                crate::defaults::idx_tensor::lassq_norm_squared(values.iter().copied())
631            }
632            other => {
633                return Err(IdxTensorError::operation(
634                    OP,
635                    anyhow::anyhow!(
636                    "the explicit-session route serves the dtypes this seam serves, not {other:?}"
637                ),
638                ));
639            }
640        };
641    squared.ok_or(IdxTensorError::NaNInput { operation: OP })
642}
643
644/// Compute the Euclidean norm of a dense host tensor on a caller-held explicit session.
645///
646/// The square root of [`norm_squared_on_session`], exactly as the compatibility route defines
647/// [`IdxTensor::norm`] as the square root of [`IdxTensor::norm_squared`].
648///
649/// # Errors
650///
651/// Returns [`IdxTensorError::NaNInput`] when an element or the accumulated result is NaN, and
652/// [`IdxTensorError::Operation`] for the representation and dtype refusals the shared admission applies (a
653/// tracked value, a device-resident operand, a diagonal, structured or compact payload, a deferred storage
654/// error, or a non-floating dtype) and for a materialization, session-copy or host-read failure. `f32` and
655/// `c32` operands are **served** by this route, promoted into the same `f64` accumulation the compatibility
656/// route uses.
657/// Both carry `norm_squared_on_session` as their operation name, because the computation is delegated - the
658/// same shape as slice 5's delegation and as the compatibility route's own `norm` delegating to
659/// `norm_squared`.
660///
661/// # Examples
662///
663/// ```
664/// use tensor4all_core::index::{DefaultIndex as Index, DynId};
665/// use tensor4all_core::{norm_on_session, IdxTensor};
666/// use tensor4all_tensorbackend::CpuExecutionContext;
667/// use tensor4all_tensorbackend::CpuBackend;
668///
669/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
670/// let i = Index::new_dyn(2);
671/// let tensor: IdxTensor = IdxTensor::from_dense(vec![i.clone()], vec![3.0_f64, 4.0])?;
672///
673/// let norm = context.with_concrete_session(|session| norm_on_session(session, &tensor))??;
674/// assert!((norm - 5.0).abs() < 1e-12);
675/// # Ok::<(), Box<dyn std::error::Error>>(())
676/// ```
677pub fn norm_on_session(
678    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
679    tensor: &IdxTensor,
680) -> Result<f64, IdxTensorError> {
681    Ok(norm_squared_on_session(session, tensor)?.sqrt())
682}
683
684/// Publish a compact host copy of one operand on the caller's session.
685///
686/// The copy is the representation boundary the runtime-free indexed operations
687/// use: once an operand is compact, selection, stacking, gathering and
688/// permutation move host values without entering any runtime.
689fn compact_on_session(
690    op: &'static str,
691    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
692    tensor: &IdxTensor,
693) -> Result<IdxTensor, IdxTensorError> {
694    let operand = tensor.session_dense_operand(op)?;
695    let value = materialize_on_session(op, session, operand)?;
696    IdxTensor::publish_dense_session_result(tensor.indices().to_vec(), &value)
697}
698
699/// Contract a connected tensor network on a caller-held explicit session.
700///
701/// The runtime-free counterpart of [`contract_with_options`](crate::contract_with_options)
702/// for the concrete (non-AD) frontend: validation, connectivity, retained-index
703/// handling and the output index order come from the compatibility route's own
704/// planner, while the numerical contraction runs on `session` and the result is
705/// published as compact host storage. `f32`, `f64`, `c32` and `c64` operands are
706/// served, and the result keeps the session's dtype.
707///
708/// An empty input, an unreachable retained index, a disconnected network, a
709/// tracked or device-resident operand, or a structured/diagonal payload is
710/// refused with a typed [`IdxTensorError`].
711///
712/// # Errors
713///
714/// Returns [`IdxTensorError::Operation`] when the input is empty, when a
715/// retained index does not appear in any operand, when the operands form
716/// disconnected components, when an operand is tracked, device-resident, or a
717/// compact structured/diagonal payload, when the session rejects a
718/// materialization or the contraction, or when the result cannot be published.
719///
720/// # Examples
721///
722/// ```
723/// use tensor4all_core::{contract_with_options_on_session, ContractionOptions, DynIndex, IdxTensor};
724/// use tensor4all_tensorbackend::CpuExecutionContext;
725/// use tensor4all_tensorbackend::CpuBackend;
726///
727/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
728/// let i = DynIndex::new_dyn(2);
729/// let j = DynIndex::new_dyn(2);
730/// let batch = DynIndex::new_dyn(2);
731/// let left = IdxTensor::from_dense(
732///     vec![i.clone(), batch.clone()],
733///     vec![1.0_f64, 2.0, 3.0, 4.0],
734/// )?;
735/// let right = IdxTensor::from_dense(
736///     vec![i, j.clone()],
737///     vec![1.0_f64, 0.0, 0.0, 1.0],
738/// )?;
739/// let result = context.with_concrete_session(|session| {
740///     contract_with_options_on_session(
741///         session,
742///         &[&left, &right],
743///         ContractionOptions::new().with_retain_indices(&[batch.clone()]),
744///     )
745/// })??;
746/// assert_eq!(result.indices(), &[batch, j]);
747/// // Column-major over `[batch, j]`: each `j` column holds the two batch values.
748/// assert_eq!(result.dense_host_values::<f64>()?, vec![1.0, 3.0, 2.0, 4.0]);
749/// # Ok::<(), Box<dyn std::error::Error>>(())
750/// ```
751pub fn contract_with_options_on_session(
752    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
753    tensors: &[&IdxTensor],
754    options: crate::defaults::contract::ContractionOptions<'_>,
755) -> Result<IdxTensor, IdxTensorError> {
756    const OP: &str = "contract_with_options_on_session";
757    let Some(plan) = crate::defaults::contract::session_contraction_plan(tensors, options)
758        .map_err(|error| anyhow_error(OP, error))?
759    else {
760        let tensor = tensors.first().ok_or_else(|| {
761            IdxTensorError::operation(OP, anyhow::anyhow!("No tensors to contract"))
762        })?;
763        return Ok((*tensor).clone());
764    };
765
766    // Adapt every operand before any session work, so a refusal on a later
767    // operand cannot follow numerical work on an earlier one.
768    let operands = tensors
769        .iter()
770        .map(|tensor| tensor.session_dense_operand(OP))
771        .collect::<Result<Vec<_>, _>>()?;
772    let values = operands
773        .into_iter()
774        .map(|operand| materialize_on_session(OP, session, operand))
775        .collect::<Result<Vec<_>, _>>()?;
776    let value_refs = values.iter().collect::<Vec<_>>();
777    let input_ids = plan.input_ids.iter().map(Vec::as_slice).collect::<Vec<_>>();
778    let contracted = session
779        .einsum(&value_refs, &input_ids, &plan.output_ids)
780        .map_err(|error| session_error(OP, error))?;
781    IdxTensor::publish_dense_session_result(plan.result_indices, &contracted)
782}
783
784/// Select fixed coordinates of a dense host tensor on a caller-held explicit session.
785///
786/// The session counterpart of [`IdxTensor::select_indices`]: the operand is
787/// published as compact host storage on `session` and the selection is the
788/// runtime-free host movement the compact route already uses. All four floating
789/// dtypes are served, and the result is compact host storage.
790///
791/// # Errors
792///
793/// Returns a typed [`IdxTensorError`] for the same malformed selections as the
794/// compatibility route and for the representation/dtype refusals the session
795/// admission applies (a tracked or device-resident operand, a structured or
796/// diagonal payload, or a non-floating dtype).
797///
798/// # Examples
799///
800/// ```
801/// use tensor4all_core::{select_indices_on_session, DynIndex, IdxTensor};
802/// use tensor4all_tensorbackend::CpuExecutionContext;
803/// use tensor4all_tensorbackend::CpuBackend;
804///
805/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
806/// let i = DynIndex::new_dyn(2);
807/// let j = DynIndex::new_dyn(3);
808/// let tensor = IdxTensor::from_dense(
809///     vec![i, j.clone()],
810///     vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0],
811/// )?;
812/// let selected = context.with_concrete_session(|session| {
813///     select_indices_on_session(session, &tensor, std::slice::from_ref(&j), &[1])
814/// })??;
815/// assert_eq!(selected.dense_host_values::<f64>()?, vec![3.0, 4.0]);
816/// # Ok::<(), Box<dyn std::error::Error>>(())
817/// ```
818pub fn select_indices_on_session(
819    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
820    tensor: &IdxTensor,
821    selected_indices: &[crate::defaults::DynIndex],
822    positions: &[usize],
823) -> Result<IdxTensor, IdxTensorError> {
824    const OP: &str = "select_indices_on_session";
825    let compact = compact_on_session(OP, session, tensor)?;
826    compact.select_indices(selected_indices, positions)
827}
828
829/// Stack dense host tensors along a newly inserted index on a caller-held explicit session.
830///
831/// The session counterpart of [`IdxTensor::stack_along_new_index`]: every operand
832/// is published as compact host storage on `session`, then the runtime-free host
833/// stacking route concatenates them. All four floating dtypes are served.
834///
835/// # Errors
836///
837/// Returns a typed [`IdxTensorError`] for the same malformed inputs as the
838/// compatibility route (no tensors, a mismatched new-index dimension, differing
839/// index orders or dimensions, an invalid insertion axis) and for the
840/// representation/dtype refusals the session admission applies.
841///
842/// # Examples
843///
844/// ```
845/// use tensor4all_core::{stack_along_new_index_on_session, DynIndex, IdxTensor};
846/// use tensor4all_tensorbackend::CpuExecutionContext;
847/// use tensor4all_tensorbackend::CpuBackend;
848///
849/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
850/// let i = DynIndex::new_dyn(2);
851/// let batch = DynIndex::new_dyn(2);
852/// let left = IdxTensor::from_dense(vec![i.clone()], vec![1.0_f64, 2.0])?;
853/// let right = IdxTensor::from_dense(vec![i], vec![3.0_f64, 4.0])?;
854/// let stacked = context.with_concrete_session(|session| {
855///     stack_along_new_index_on_session(session, &[&left, &right], batch, -1)
856/// })??;
857/// assert_eq!(stacked.dense_host_values::<f64>()?, vec![1.0, 2.0, 3.0, 4.0]);
858/// # Ok::<(), Box<dyn std::error::Error>>(())
859/// ```
860pub fn stack_along_new_index_on_session(
861    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
862    tensors: &[&IdxTensor],
863    new_index: crate::defaults::DynIndex,
864    axis: isize,
865) -> Result<IdxTensor, IdxTensorError> {
866    const OP: &str = "stack_along_new_index_on_session";
867    let compact = tensors
868        .iter()
869        .map(|tensor| compact_on_session(OP, session, tensor))
870        .collect::<Result<Vec<_>, _>>()?;
871    let refs = compact.iter().collect::<Vec<_>>();
872    IdxTensor::stack_along_new_index(&refs, new_index, axis)
873}
874
875/// Gather positions along one index on a caller-held explicit session.
876///
877/// The session counterpart of [`IdxTensor::index_select`]: the operand is
878/// published as compact host storage on `session` and the gather runs on that
879/// host payload, so it enters no runtime. All four floating dtypes are served.
880///
881/// # Errors
882///
883/// Returns a typed [`IdxTensorError`] when the target dimension does not match
884/// the position count, the source index is absent, a position is out of range,
885/// or the session admission refuses the operand.
886///
887/// # Examples
888///
889/// ```
890/// use tensor4all_core::{index_select_on_session, DynIndex, IdxTensor};
891/// use tensor4all_tensorbackend::CpuExecutionContext;
892/// use tensor4all_tensorbackend::CpuBackend;
893///
894/// let context = CpuExecutionContext::from_backend(CpuBackend::with_threads(1)?);
895/// let source = DynIndex::new_dyn(3);
896/// let target = DynIndex::new_dyn(2);
897/// let tensor = IdxTensor::from_dense(vec![source.clone()], vec![10.0_f64, 20.0, 30.0])?;
898/// let gathered = context.with_concrete_session(|session| {
899///     index_select_on_session(session, &tensor, &source, target, &[2, 0])
900/// })??;
901/// assert_eq!(gathered.dense_host_values::<f64>()?, vec![30.0, 10.0]);
902/// # Ok::<(), Box<dyn std::error::Error>>(())
903/// ```
904pub fn index_select_on_session(
905    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
906    tensor: &IdxTensor,
907    source_index: &crate::defaults::DynIndex,
908    target_index: crate::defaults::DynIndex,
909    positions: &[usize],
910) -> Result<IdxTensor, IdxTensorError> {
911    const OP: &str = "index_select_on_session";
912    if target_index.dim() != positions.len() {
913        return Err(IdxTensorError::operation(
914            OP,
915            anyhow::anyhow!(
916                "index_select: target index dim {} does not match position count {}",
917                target_index.dim(),
918                positions.len()
919            ),
920        ));
921    }
922    let axis = tensor
923        .indices()
924        .iter()
925        .position(|index| index == source_index)
926        .ok_or_else(|| {
927            IdxTensorError::operation(
928                OP,
929                anyhow::anyhow!("index_select: source index is not present"),
930            )
931        })?;
932    let source_dim = tensor.indices()[axis].dim();
933    for &position in positions {
934        if position >= source_dim {
935            return Err(IdxTensorError::operation(
936                OP,
937                anyhow::anyhow!(
938                    "index_select: position {position} is out of range for source dim {source_dim}"
939                ),
940            ));
941        }
942    }
943    let compact = compact_on_session(OP, session, tensor)?;
944    compact.gather_dense_host(axis, positions, target_index)
945}
946
947/// Materialize one operand as an owned concrete host value of the caller's session.
948///
949/// Host storage is converted by the existing bridge, which does not enter a runtime; an untracked eager
950/// host value is read through its borrowed representation and copied by the session itself.
951fn materialize_on_session(
952    op: &'static str,
953    session: &mut tensor4all_tensorbackend::explicit::Session<'_>,
954    operand: SessionDenseOperand<'_>,
955) -> Result<tenferro::Tensor, IdxTensorError> {
956    match operand {
957        SessionDenseOperand::Storage(storage, dims) => {
958            tensor4all_tensorbackend::storage_to_native_tensor(&storage, &dims)
959                .map_err(|error| anyhow_error(op, anyhow::Error::new(error)))
960        }
961        SessionDenseOperand::Read(read, rank) => {
962            let ids: Vec<usize> = (0..rank).collect();
963            session
964                .einsum_reads(&[*read], &[&ids], &ids)
965                .map_err(|error| session_error(op, error))
966        }
967    }
968}
969
970/// Map a session-side failure into this crate's error, named by its own operation.
971fn session_error<E: Into<Box<dyn std::error::Error + Send + Sync>>>(
972    op: &'static str,
973    error: E,
974) -> IdxTensorError {
975    IdxTensorError::Operation {
976        operation: op,
977        source: Arc::from(error.into()),
978    }
979}
980
981/// Map a planning-side failure into this crate's error, named by its own operation.
982fn anyhow_error(op: &'static str, error: anyhow::Error) -> IdxTensorError {
983    IdxTensorError::Operation {
984        operation: op,
985        source: Arc::from(error.into_boxed_dyn_error()),
986    }
987}