Skip to main content

readcon_core/
array.rs

1//! In-memory array storage for the v0.11 ConFrameBuilder.
2//!
3//! Mirrors metatensor v2's `Array` trait shape so readcon's per-atom
4//! buffers (positions / velocities / forces / atom_energies / masses /
5//! atom_ids / fixed) live behind a `Box<dyn Array>` and can swap
6//! between dtypes (f32 / f64 / u64 / bool), devices (CPU / future GPU
7//! backings), and ownership models (owned `ArrayD<T>`,
8//! `Arc<RwLock<ArrayD<T>>>` for shared mut, mmap'd, ...) without
9//! changing the builder's surface.
10//!
11//! DLPack is the cross-language export protocol; every `Array`
12//! implementor returns a `DLPackTensor` view via `as_dlpack` so C /
13//! C++ / Python (NumPy / PyTorch / JAX / ...) / Julia consumers all
14//! see the same memory through one ABI.
15//!
16//! The default backing is `Arc<RwLock<ndarray::ArrayD<T>>>` --
17//!   * `Arc`     : multiple DLPack views can share the same buffer
18//!     across threads / FFI consumers.
19//!   * `RwLock`  : enforces aliasing soundness; concurrent reads
20//!     are non-blocking, concurrent writes contend.
21//!   * `ndarray::ArrayD<T>` : type-erased dimension, generic dtype,
22//!     ndarray's allocator (8-byte aligned, fine for f64; future
23//!     SIMD-aligned variants implement this trait separately).
24//!
25//! See `docs/orgmode/spec.org` ยง17 for the public contract.
26
27use std::sync::{Arc, RwLock, TryLockError};
28
29use dlpk::sys::{DLDataType, DLDevice, DLPackVersion};
30use dlpk::{DLPackPointerCast, DLPackTensor, GetDLPackDataType};
31use ndarray::ArrayD;
32
33use crate::error::ParseError;
34
35/// Storage hook for one per-atom field of a ConFrameBuilder.
36///
37/// Implementors hold the raw bytes for a single field (e.g. all atom
38/// positions as a `(N, 3) f64` block) and expose them via DLPack so
39/// downstream consumers can map a numpy / Eigen / torch view onto
40/// the same memory zero-copy.
41pub trait Array: std::any::Any + Send + Sync {
42    /// `&dyn Any` access for downcast (mirrors metatensor's pattern).
43    fn as_any(&self) -> &dyn std::any::Any;
44
45    /// `&mut dyn Any` access for downcast.
46    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
47
48    /// Shape of the underlying tensor.
49    fn shape(&self) -> Vec<usize>;
50
51    /// DLPack dtype of the elements.
52    fn dtype(&self) -> DLDataType;
53
54    /// Device residency of the storage.
55    fn device(&self) -> DLDevice;
56
57    /// Export a DLPack-managed tensor view of this array.
58    ///
59    /// `device` requests the consumer's preferred device; CPU
60    /// implementors return `Err(ParseError::ValidationError(...))`
61    /// when asked for a non-CPU device they cannot service.
62    /// `stream` is the consumer's stream (CUDA / ROCm / SYCL); CPU
63    /// backings ignore it.
64    fn as_dlpack(
65        &self,
66        device: DLDevice,
67        stream: Option<i64>,
68        max_version: DLPackVersion,
69    ) -> Result<DLPackTensor, ParseError>;
70
71    /// Deep-copy this array (used by ConFrameBuilder::clone +
72    /// `move_data`-style ops). Default impl just `clone`s through
73    /// the implementor's natural mechanism.
74    fn copy(&self) -> Box<dyn Array>;
75}
76
77/// Default Rust backing for the Array trait: shared, lockable,
78/// dynamic-rank ndarray. Matches metatensor v2's
79/// `Arc<RwLock<ArrayD<T>>>` choice and inherits its DLPack +
80/// concurrency semantics.
81impl<T> Array for Arc<RwLock<ArrayD<T>>>
82where
83    T: 'static + Send + Sync + Clone + Default + GetDLPackDataType + DLPackPointerCast,
84{
85    fn as_any(&self) -> &dyn std::any::Any {
86        self
87    }
88
89    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
90        self
91    }
92
93    fn shape(&self) -> Vec<usize> {
94        match self.try_read() {
95            Ok(lock) => lock.shape().to_vec(),
96            Err(TryLockError::Poisoned(_)) => panic!("readcon-core array lock is poisoned"),
97            Err(TryLockError::WouldBlock) => panic!("readcon-core array is already locked"),
98        }
99    }
100
101    fn dtype(&self) -> DLDataType {
102        T::get_dlpack_data_type()
103    }
104
105    fn device(&self) -> DLDevice {
106        DLDevice::cpu()
107    }
108
109    fn as_dlpack(
110        &self,
111        device: DLDevice,
112        _stream: Option<i64>,
113        _max_version: DLPackVersion,
114    ) -> Result<DLPackTensor, ParseError> {
115        if device != DLDevice::cpu() {
116            return Err(ParseError::ValidationError(format!(
117                "Arc<RwLock<ArrayD>> is CPU-only; requested device {device:?} unsupported"
118            )));
119        }
120        // Borrow the inner ArrayD<T> read-only and convert to DLPack
121        // through dlpk's ndarray feature. The resulting DLPackTensor
122        // owns a clone of the Arc, so the lifetime is decoupled from
123        // the borrow above.
124        let lock = match self.try_read() {
125            Ok(lock) => lock,
126            Err(TryLockError::Poisoned(_)) => {
127                return Err(ParseError::ValidationError(
128                    "readcon-core array lock is poisoned".into(),
129                ));
130            }
131            Err(TryLockError::WouldBlock) => {
132                return Err(ParseError::ValidationError(
133                    "readcon-core array is already locked".into(),
134                ));
135            }
136        };
137        // Clone the ArrayD<T> contents into an owned ndarray, then
138        // hand it to dlpk's TryFrom<ArrayD<T>> -> DLPackTensor (this
139        // takes ownership, so the resulting DLPackTensor has its own
140        // backing storage independent of the Arc<RwLock<...>>; future
141        // optimisation: build a custom Array impl that exposes the
142        // Arc-shared storage directly via dlpk's manager_ctx).
143        let owned: ArrayD<T> = lock.to_owned();
144        DLPackTensor::try_from(owned).map_err(|e| {
145            ParseError::ValidationError(format!("dlpk ArrayD conversion failed: {e}"))
146        })
147    }
148
149    fn copy(&self) -> Box<dyn Array> {
150        // Cheap Arc clone, NOT a deep copy of the data buffer. If a
151        // caller needs a true deep copy, materialize via
152        // `Arc::new(RwLock::new(ArrayD::clone(&*lock)))`.
153        Box::new(Arc::clone(self))
154    }
155}
156
157/// Convenience constructor for the default backing.
158pub fn array_from_shape<T>(shape: &[usize]) -> Box<dyn Array>
159where
160    T: 'static + Send + Sync + Clone + Default + GetDLPackDataType + DLPackPointerCast,
161{
162    let arr: ArrayD<T> = ArrayD::default(ndarray::IxDyn(shape));
163    Box::new(Arc::new(RwLock::new(arr)))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn array_from_shape_reports_shape_and_dtype() {
172        let a: Box<dyn Array> = array_from_shape::<f64>(&[5, 3]);
173        assert_eq!(a.shape(), vec![5, 3]);
174        let dt = a.dtype();
175        assert_eq!(dt.code, dlpk::sys::DLDataTypeCode::kDLFloat);
176        assert_eq!(dt.bits, 64);
177        assert_eq!(dt.lanes, 1);
178        assert_eq!(a.device(), DLDevice::cpu());
179    }
180
181    #[test]
182    fn array_copy_shares_storage_via_arc() {
183        let a = array_from_shape::<f64>(&[2, 3]);
184        let b = a.copy();
185        // shapes match
186        assert_eq!(a.shape(), b.shape());
187    }
188
189    #[test]
190    fn array_dlpack_export_round_trip() {
191        let a = array_from_shape::<f64>(&[4, 3]);
192        let tensor = a
193            .as_dlpack(DLDevice::cpu(), None, DLPackVersion::current())
194            .expect("DLPack export should succeed for CPU array");
195        assert_eq!(tensor.shape(), &[4, 3]);
196    }
197}