Skip to main content

readcon_core/
array.rs

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