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}