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}