Skip to main content

ocas_py/
tensor.rs

1//! Python bindings for the basic tensor algebra module.
2//!
3//! Wraps [`ocas_atom::tensor`] — an independent `Tensor` type with index
4//! slots, variance, and slot symmetry, plus explicit contraction and a
5//! symmetrisation sign. Each [`Tensor`][PyTensor] owns a private leaked
6//! arena pair (mirroring [`Expression`](crate::expression::Expression)).
7//!
8//! ```python
9//! from ocas import Tensor, contract_tensors, tensor_symmetrise_sign
10//!
11//! # T^i_j · U^j_k = (TU)^i_k  (partial contraction over j)
12//! t = Tensor("T", [("i", "upper"), ("j", "lower")])
13//! u = Tensor("U", [("j", "upper"), ("k", "lower")])
14//! kind, payload = contract_tensors(t, u)
15//! assert kind == "product"
16//!
17//! # Antisymmetric ε_ab has a sign under slot swap.
18//! eps = Tensor("eps", [("a", "lower"), ("b", "lower")], symmetry="antisymmetric")
19//! assert tensor_symmetrise_sign(eps) in (1, -1)
20//! ```
21
22use ocas_atom::tensor::{
23    Contracted, IndexPosition, IndexSlot, Symmetry, Tensor, contract, symmetrise_sign,
24};
25use ocas_atom::{AtomArena, Symbol};
26use ocas_core::arena::Arena;
27use pyo3::exceptions::PyValueError;
28use pyo3::prelude::*;
29use pyo3::types::PyList;
30use std::collections::HashMap;
31
32// ------------------------------------------------------------------
33//  Arena management (leaked pair, recovered on Drop)
34// ------------------------------------------------------------------
35
36/// Internal storage behind a [`PyTensor`]: a leaked arena pair recovered on
37/// drop. See [`crate::expression::ExprInner`] for the same pattern.
38struct TensorInner {
39    arena_ptr: *mut Arena,
40    ctx_ptr: *mut AtomArena<'static>,
41    tensor: Tensor<'static>,
42}
43
44// SAFETY: matches crate::expression::ExprInner — the heap allocations are not
45// tied to any thread, and pyclass method invocations are GIL-serialized.
46unsafe impl Send for TensorInner {}
47unsafe impl Sync for TensorInner {}
48
49impl Drop for TensorInner {
50    fn drop(&mut self) {
51        // SAFETY: both pointers came from `Box::into_raw`. Drop `ctx_ptr`
52        // first because it borrows `arena_ptr`.
53        unsafe {
54            let _ = Box::from_raw(self.ctx_ptr);
55            let _ = Box::from_raw(self.arena_ptr);
56        }
57    }
58}
59
60impl TensorInner {
61    /// Borrow the atom arena as `&'static AtomArena<'static>`.
62    fn ctx(&self) -> &'static AtomArena<'static> {
63        // SAFETY: valid while `TensorInner` is alive.
64        unsafe { &*self.ctx_ptr }
65    }
66
67    /// Build a fresh arena pair.
68    fn new_pair() -> (*mut Arena, *mut AtomArena<'static>) {
69        let arena_box: Box<Arena> = Box::new(Arena::new());
70        let arena_ptr = Box::into_raw(arena_box);
71        // SAFETY: `arena_ptr` outlives `TensorInner`; recovered in Drop.
72        let arena_ref: &'static Arena = unsafe { &*arena_ptr };
73        let ctx = AtomArena::new(arena_ref);
74        let ctx_ptr = Box::into_raw(Box::new(ctx));
75        (arena_ptr, ctx_ptr)
76    }
77
78    /// Build a `TensorInner` from a closure that constructs the tensor in the
79    /// freshly-allocated arena.
80    fn build<F>(f: F) -> PyResult<Box<Self>>
81    where
82        F: FnOnce(&'static AtomArena<'static>) -> Tensor<'static>,
83    {
84        let (arena_ptr, ctx_ptr) = Self::new_pair();
85        // If `f` panics we must free the arenas. Use a guard.
86        struct Guard {
87            arena_ptr: *mut Arena,
88            ctx_ptr: *mut AtomArena<'static>,
89            armed: bool,
90        }
91        impl Drop for Guard {
92            fn drop(&mut self) {
93                if self.armed {
94                    unsafe {
95                        let _ = Box::from_raw(self.ctx_ptr);
96                        let _ = Box::from_raw(self.arena_ptr);
97                    }
98                }
99            }
100        }
101        let mut g = Guard {
102            arena_ptr,
103            ctx_ptr,
104            armed: true,
105        };
106        let ctx = unsafe { &*ctx_ptr };
107        let tensor = f(ctx);
108        g.armed = false;
109        Ok(Box::new(TensorInner {
110            arena_ptr,
111            ctx_ptr,
112            tensor,
113        }))
114    }
115}
116
117// ------------------------------------------------------------------
118//  Helpers
119// ------------------------------------------------------------------
120
121/// Parse `"upper"` / `"lower"` into an [`IndexPosition`].
122fn parse_position(s: &str) -> PyResult<IndexPosition> {
123    match s.to_ascii_lowercase().as_str() {
124        "upper" | "up" | "contravariant" => Ok(IndexPosition::Upper),
125        "lower" | "down" | "covariant" => Ok(IndexPosition::Lower),
126        _ => Err(PyValueError::new_err(format!(
127            "position must be 'upper' or 'lower', got {s:?}"
128        ))),
129    }
130}
131
132fn position_str(p: IndexPosition) -> &'static str {
133    match p {
134        IndexPosition::Upper => "upper",
135        IndexPosition::Lower => "lower",
136    }
137}
138
139fn parse_symmetry(s: &str) -> PyResult<Symmetry> {
140    match s.to_ascii_lowercase().as_str() {
141        "none" | "" => Ok(Symmetry::None),
142        "symmetric" | "sym" => Ok(Symmetry::Symmetric),
143        "antisymmetric" | "antisym" | "skew" => Ok(Symmetry::Antisymmetric),
144        _ => Err(PyValueError::new_err(format!(
145            "symmetry must be 'none', 'symmetric', or 'antisymmetric', got {s:?}"
146        ))),
147    }
148}
149
150fn symmetry_str(s: Symmetry) -> &'static str {
151    match s {
152        Symmetry::None => "none",
153        Symmetry::Symmetric => "symmetric",
154        Symmetry::Antisymmetric => "antisymmetric",
155    }
156}
157
158/// A tensor: a named object with a list of index slots and an optional slot
159/// symmetry. Each tensor owns its own private arena; contraction rebuilds the
160/// operands into a fresh arena so lifetimes stay decoupled.
161#[pyclass(name = "Tensor")]
162pub struct PyTensor {
163    inner: Box<TensorInner>,
164}
165
166#[pymethods]
167impl PyTensor {
168    /// Create a tensor from a name and a list of `(label, position)` slots.
169    ///
170    /// `position` is the string `"upper"` (or `"up"`, `"contravariant"`) or
171    /// `"lower"` (or `"down"`, `"covariant"`). The optional `symmetry`
172    /// keyword is one of `"none"` (default), `"symmetric"`, or
173    /// `"antisymmetric"`.
174    #[new]
175    #[pyo3(signature = (name, slots, symmetry="none"))]
176    fn new(name: &str, slots: &Bound<'_, PyAny>, symmetry: &str) -> PyResult<Self> {
177        let sym = parse_symmetry(symmetry)?;
178        let parsed: Vec<(String, IndexPosition)> = slots
179            .try_iter()
180            .map_err(|_| PyValueError::new_err("slots must be a list of (label, position) pairs"))?
181            .map(|item| -> PyResult<(String, IndexPosition)> {
182                let item = item?;
183                let (label, pos): (String, String) = item.extract().map_err(|_| {
184                    PyValueError::new_err("each slot must be a (label, position) pair")
185                })?;
186                Ok((label, parse_position(&pos)?))
187            })
188            .collect::<PyResult<_>>()?;
189        let symbol = Symbol::new(name);
190        let inner = TensorInner::build(|ctx| {
191            let slots: Vec<IndexSlot<'static>> = parsed
192                .iter()
193                .map(|(label, pos)| IndexSlot::new(ctx.var(label), *pos))
194                .collect();
195            Tensor::new(symbol, slots).with_symmetry(sym)
196        })?;
197        Ok(PyTensor { inner })
198    }
199
200    /// The tensor name.
201    #[getter]
202    fn name(&self) -> String {
203        self.inner.tensor.name().as_str().to_string()
204    }
205
206    /// The tensor arity (number of slots).
207    #[getter]
208    fn rank(&self) -> usize {
209        self.inner.tensor.rank()
210    }
211
212    /// The slot symmetry string ("none", "symmetric", or "antisymmetric").
213    #[getter]
214    fn symmetry(&self) -> &'static str {
215        symmetry_str(self.inner.tensor.symmetry())
216    }
217
218    /// Return the slots as a list of `(label, position)` string pairs.
219    fn slots(&self) -> Vec<(String, &'static str)> {
220        self.inner
221            .tensor
222            .slots()
223            .iter()
224            .map(|s| (s.label().to_string(), position_str(s.position())))
225            .collect()
226    }
227
228    /// Return the dummy labels (labels occurring exactly twice across the
229    /// slots).
230    fn dummy_labels(&self) -> Vec<String> {
231        self.inner
232            .tensor
233            .dummy_labels()
234            .into_iter()
235            .map(|a| a.to_string())
236            .collect()
237    }
238
239    /// Render the tensor as an `Atom` function node `name(slot, slot, ...)`.
240    fn to_string_atom(&self) -> String {
241        self.inner.tensor.to_atom(self.inner.ctx()).to_string()
242    }
243
244    fn __repr__(&self) -> String {
245        format!(
246            "Tensor({:?}, rank={}, symmetry={:?})",
247            self.name(),
248            self.rank(),
249            self.symmetry()
250        )
251    }
252}
253
254// ------------------------------------------------------------------
255//  contract and symmetrise_sign
256// ------------------------------------------------------------------
257
258/// Build an independent [`PyTensor`] (own arena) from name/slots/symmetry.
259fn rebuild_tensor(
260    name: &str,
261    sym: Symmetry,
262    slots: &[(String, IndexPosition)],
263) -> PyResult<PyTensor> {
264    let inner = TensorInner::build(|ctx| {
265        let slots: Vec<IndexSlot<'static>> = slots
266            .iter()
267            .map(|(label, pos)| IndexSlot::new(ctx.var(label), *pos))
268            .collect();
269        Tensor::new(Symbol::new(name), slots).with_symmetry(sym)
270    })?;
271    Ok(PyTensor { inner })
272}
273
274/// Snapshot a tensor's name, symmetry, and slots as plain `String`/enum data
275/// so it can be rebuilt into a fresh arena.
276fn snapshot(tensor: &Tensor<'_>) -> (String, Symmetry, Vec<(String, IndexPosition)>) {
277    let name = tensor.name().as_str().to_string();
278    let sym = tensor.symmetry();
279    let slots: Vec<(String, IndexPosition)> = tensor
280        .slots()
281        .iter()
282        .map(|s| (s.label().to_string(), s.position()))
283        .collect();
284    (name, sym, slots)
285}
286
287/// Contract two tensors by summing over shared dummy indices (equal label,
288/// opposite variance).
289///
290/// Returns a `(kind, payload)` tuple where `kind` is `"product"` or
291/// `"scalar"`. For `"product"`, `payload` is a list of resulting tensors
292/// (their free slots concatenated). For `"scalar"`, `payload` is the
293/// string form of the contracted atom expression.
294#[pyfunction]
295pub fn contract_tensors<'py>(
296    py: Python<'py>,
297    a: &PyTensor,
298    b: &PyTensor,
299) -> PyResult<Bound<'py, PyAny>> {
300    // Allocate a single shared arena for the contraction computation. It is
301    // dropped before this function returns; the result PyTensors are rebuilt
302    // into independent arenas via `rebuild_tensor`.
303    let (arena_ptr, ctx_ptr) = TensorInner::new_pair();
304    struct DropGuard {
305        arena_ptr: *mut Arena,
306        ctx_ptr: *mut AtomArena<'static>,
307    }
308    impl Drop for DropGuard {
309        fn drop(&mut self) {
310            unsafe {
311                let _ = Box::from_raw(self.ctx_ptr);
312                let _ = Box::from_raw(self.arena_ptr);
313            }
314        }
315    }
316    let _guard = DropGuard { arena_ptr, ctx_ptr };
317    let ctx: &'static AtomArena<'static> = unsafe { &*ctx_ptr };
318
319    // Rebuild a and b into the shared arena.
320    let (a_name, a_sym, a_slots_data) = snapshot(&a.inner.tensor);
321    let (b_name, b_sym, b_slots_data) = snapshot(&b.inner.tensor);
322    let a_slots: Vec<IndexSlot<'static>> = a_slots_data
323        .iter()
324        .map(|(label, pos)| IndexSlot::new(ctx.var(label), *pos))
325        .collect();
326    let b_slots: Vec<IndexSlot<'static>> = b_slots_data
327        .iter()
328        .map(|(label, pos)| IndexSlot::new(ctx.var(label), *pos))
329        .collect();
330    let a_rebuilt = Tensor::new(Symbol::new(&a_name), a_slots).with_symmetry(a_sym);
331    let b_rebuilt = Tensor::new(Symbol::new(&b_name), b_slots).with_symmetry(b_sym);
332
333    let result = contract(ctx, &a_rebuilt, &b_rebuilt);
334    match result {
335        Contracted::Product(p) => {
336            let mut out: Vec<PyTensor> = Vec::with_capacity(p.factors.len());
337            for factor in &p.factors {
338                let (name, sym, slots) = snapshot(factor);
339                out.push(rebuild_tensor(&name, sym, &slots)?);
340            }
341            let list = PyList::new(py, out)?;
342            let tuple = ("product", list.into_any()).into_pyobject(py)?;
343            Ok(tuple.into_any())
344        }
345        Contracted::Scalar(atom) => {
346            let s = atom.to_string();
347            let tuple = ("scalar", s).into_pyobject(py)?;
348            Ok(tuple.into_any())
349        }
350    }
351}
352
353/// Return the symmetrisation sign of a tensor (+1 or -1).
354///
355/// For `symmetry="none"` and `"symmetric"` this is always +1. For
356/// `"antisymmetric"` it returns the parity of the slot-sorting permutation.
357#[pyfunction]
358pub fn tensor_symmetrise_sign(tensor: &PyTensor) -> i64 {
359    symmetrise_sign(&tensor.inner.tensor)
360}
361
362/// Canonicalise a tensor expression using the graph-isomorphism engine.
363///
364/// `specs` is a dict mapping tensor name → symmetry spec string:
365/// `"none"`, `"symmetric"`, `"antisymmetric"`.
366#[pyfunction]
367#[pyo3(signature = (expr, specs, index_groups=None))]
368pub fn canonicalize_tensors(
369    expr: &str,
370    specs: HashMap<String, String>,
371    index_groups: Option<HashMap<String, u64>>,
372) -> PyResult<String> {
373    use ocas_atom::tensor::canon::canonicalize_tensors as canon;
374    use ocas_atom::tensor::spec::TensorRegistry;
375    use ocas_parse;
376
377    let arena = Arena::new();
378    let ctx = AtomArena::new(&arena);
379    let parsed = ocas_parse::parse(&ctx, expr)
380        .map_err(|e| PyValueError::new_err(format!("parse error: {e}")))?;
381
382    let mut reg = TensorRegistry::new();
383    for (name, spec_str) in &specs {
384        let spec = parse_symmetry_spec(spec_str);
385        reg.register(Symbol::new(name), spec);
386    }
387    if let Some(groups) = &index_groups {
388        for (label, group) in groups {
389            reg.set_index_group(Symbol::new(label), *group);
390        }
391    }
392
393    let ct = canon(&ctx, parsed, &reg)
394        .map_err(|e| PyValueError::new_err(format!("canonicalisation error: {e:?}")))?;
395
396    Ok(ct.canonical_form.to_string())
397}
398
399fn parse_symmetry_spec(s: &str) -> ocas_atom::tensor::spec::SymmetrySpec {
400    use ocas_atom::tensor::spec::SymmetrySpec;
401    match s {
402        "none" => SymmetrySpec::none(),
403        // Use a large sentinel rank so that is_slot_hidden(pos) returns true
404        // for any reasonable tensor arity.
405        "symmetric" => SymmetrySpec::fully_symmetric(64),
406        "antisymmetric" => SymmetrySpec::fully_antisymmetric(64),
407        _ => SymmetrySpec::none(),
408    }
409}
410
411/// Apply a Young projector to a tensor expression.
412///
413/// `tableau` is a list of row lengths, e.g. `[2, 1]` for □□/□.
414#[pyfunction]
415pub fn young_project(expr: &str, tableau: Vec<usize>) -> PyResult<String> {
416    use ocas_atom::tensor::young::{YoungTableau, young_project as yp};
417    use ocas_parse;
418
419    let arena = Arena::new();
420    let ctx = AtomArena::new(&arena);
421    let parsed = ocas_parse::parse(&ctx, expr)
422        .map_err(|e| PyValueError::new_err(format!("parse error: {e}")))?;
423
424    let t = YoungTableau::new(tableau);
425    // young_project currently returns Atom directly (not Result); catch any
426    // panics (e.g. from internal unwrap) and convert to a Python exception
427    // instead of aborting the process.
428    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| yp(&ctx, parsed, &t)))
429        .map_err(|e| {
430            PyValueError::new_err(format!(
431                "young projection panicked: {}",
432                if let Some(s) = e.downcast_ref::<String>() {
433                    s.clone()
434                } else if let Some(s) = e.downcast_ref::<&str>() {
435                    s.to_string()
436                } else {
437                    "unknown panic".to_string()
438                }
439            ))
440        })?;
441    Ok(result.to_string())
442}
443
444/// Refresh (rename) dummy indices in a tensor expression.
445#[pyfunction]
446pub fn refresh_dummies(expr: &str, specs: HashMap<String, String>) -> PyResult<String> {
447    use ocas_atom::tensor::dummy::refresh_dummies as rd;
448    use ocas_atom::tensor::spec::TensorRegistry;
449    use ocas_parse;
450
451    let arena = Arena::new();
452    let ctx = AtomArena::new(&arena);
453    let parsed = ocas_parse::parse(&ctx, expr)
454        .map_err(|e| PyValueError::new_err(format!("parse error: {e}")))?;
455
456    let mut reg = TensorRegistry::new();
457    for (name, spec_str) in &specs {
458        let spec = parse_symmetry_spec(spec_str);
459        reg.register(Symbol::new(name), spec);
460    }
461
462    let result =
463        rd(&ctx, parsed, &reg).map_err(|e| PyValueError::new_err(format!("dummy error: {e:?}")))?;
464    Ok(result.to_string())
465}