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