hurray_ffi/composite.rs
1//! Writing a composite from C: the node array, and the borrowed tree it becomes.
2//!
3//! The mirror of [`crate::item`]. A C caller describes a composite the way the format
4//! does — a head, then its members, each node declaring how many of the nodes after it
5//! are its own ([ADR-027](../../../docs/adr/ADR-027-composite-tensors.md) § Binding) — and
6//! hands the whole thing over as one preorder array:
7//!
8//! ```text
9//! index kind name member_count payloads
10//! 0 composite "w" 2 - <- the head, nodes[0]
11//! 1 tensor "w.0" 0 1 slice
12//! 2 composite "w.1" 2 -
13//! 3 tensor "w.1.0" 0 1 slice
14//! 4 tensor "w.1.1" 0 1 slice
15//! ```
16//!
17//! One array rather than a tree of pointers, for the reason [`crate::item`] gives: a
18//! pointer tree crossing an ABI can be cyclic, and preorder with member counts cannot
19//! describe a cycle. It is also one allocation for the caller instead of one per level.
20//!
21//! `hurray-io` wants that as a tree of borrowed nodes — `CompositeNode<'a>` for a stream,
22//! `FileCompositeNode<'a>` for a file — so this module turns one into the other, with an
23//! explicit stack and no recursion (ADR-041 § 10), and with the payload slices left
24//! borrowed from the caller the whole way (§ 3).
25
26use hurray_core::TensorDescriptor;
27use hurray_io::file::FileCompositeNode;
28use hurray_io::stream::CompositeNode;
29
30use crate::{
31 descriptor::HurrayDescriptor,
32 file::kv::{bytes_from_c, str_from_c},
33 item::{HURRAY_ITEM_COMPOSITE, HURRAY_ITEM_TENSOR},
34 status::{
35 HurrayStatus, HURRAY_ERR_INDEX_OUT_OF_BOUNDS, HURRAY_ERR_INTERNAL,
36 HURRAY_ERR_NOT_COMPOSITE, HURRAY_ERR_NULL_POINTER,
37 },
38 stream::HurrayIoSlice,
39};
40
41// ── Nodes ─────────────────────────────────────────────────────────────────────
42
43/// One node of a composite to write: a tensor member, or a head whose members follow it.
44///
45/// A flat tagged struct rather than a union, for the reason
46/// [`HurrayKvValue`][crate::HurrayKvValue] is one: every binding generator downstream of
47/// this header reads a struct correctly and few read a union well. Fields the `kind` does
48/// not select are ignored.
49///
50/// Every pointer borrows from the caller and MUST stay valid and unmodified until the
51/// write call returns. Nothing here is copied — not the descriptors, and above all not
52/// the payloads (ADR-041 § 3).
53#[repr(C)]
54#[derive(Clone, Copy)]
55pub struct HurrayCompositeNode {
56 /// [`HURRAY_ITEM_TENSOR`] or [`HURRAY_ITEM_COMPOSITE`].
57 pub kind: u8,
58 /// First byte of this node's tensor name, UTF-8 and not NUL-terminated.
59 ///
60 /// Required on the file surface, where every tensor — head and member alike — gets its
61 /// own footer-index entry. Ignored on the stream surface, which carries no names.
62 pub name: *const u8,
63 /// Byte length of `name`.
64 pub name_len: usize,
65 /// The node's descriptor: a member's tensor descriptor, or a head's composite one.
66 pub descriptor: *const HurrayDescriptor,
67 /// One slice per buffer handle in `descriptor`'s buffer table, in that order.
68 ///
69 /// Ignored for a composite node: a head owns no data buffers, which its descriptor
70 /// already states — and a head whose descriptor says otherwise is refused by the
71 /// validator, not here.
72 pub payloads: *const HurrayIoSlice,
73 /// Number of slices at `payloads`.
74 pub payload_count: usize,
75 /// How many of the nodes following this one are its direct members. `0` for a tensor.
76 pub member_count: u32,
77}
78
79impl Default for HurrayCompositeNode {
80 /// A zeroed node, which is what a C caller gets from `{0}`.
81 fn default() -> Self {
82 Self {
83 kind: HURRAY_ITEM_TENSOR,
84 name: std::ptr::null(),
85 name_len: 0,
86 descriptor: std::ptr::null(),
87 payloads: std::ptr::null(),
88 payload_count: 0,
89 member_count: 0,
90 }
91 }
92}
93
94// ── The structure of the array ────────────────────────────────────────────────
95
96/// Resolves the preorder array into each node's list of direct members.
97///
98/// This is the whole structural check: the array MUST be exactly one tree. A head that
99/// declares more members than the array carries, and nodes trailing after the root's
100/// subtree is complete, are both [`HURRAY_ERR_INDEX_OUT_OF_BOUNDS`] — which is what each
101/// of them is, a member count that does not fit the array it indexes.
102///
103/// A single forward pass with a stack of open heads, so a malformed array is rejected
104/// before a descriptor is touched.
105fn members_of(nodes: &[HurrayCompositeNode]) -> Result<Vec<Vec<usize>>, HurrayStatus> {
106 let mut members: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
107 // Each entry is an open head and the number of members it is still owed. A head owed
108 // zero is never on the stack, which is what makes the `-= 1` below safe without a
109 // check: it is popped the instant it fills.
110 let mut open: Vec<(usize, u32)> = Vec::new();
111
112 for (index, node) in nodes.iter().enumerate() {
113 if index > 0 {
114 let Some(parent) = open.last_mut() else {
115 // The root's subtree is complete and the array continues: whatever
116 // follows belongs to no head.
117 return Err(HURRAY_ERR_INDEX_OUT_OF_BOUNDS);
118 };
119 parent.1 -= 1;
120 members[parent.0].push(index);
121 while matches!(open.last(), Some((_, 0))) {
122 open.pop();
123 }
124 }
125 if node.kind == HURRAY_ITEM_COMPOSITE && node.member_count > 0 {
126 open.push((index, node.member_count));
127 }
128 }
129
130 if !open.is_empty() {
131 // A head declared members the array does not carry.
132 return Err(HURRAY_ERR_INDEX_OUT_OF_BOUNDS);
133 }
134 Ok(members)
135}
136
137// ── Stable storage for the tree ───────────────────────────────────────────────
138
139/// Owns the member arrays and payload-slice arrays a borrowed tree points into.
140///
141/// The tree `hurray-io` wants is a graph of slices, and every slice needs an owner that
142/// outlives the write call. This is that owner: one `Box` per array, so an array's address
143/// survives later pushes into the `Vec` — the property ADR-042 § 4 wrote down for the
144/// async writer's node arena, and the reason `Vec<Box<[T]>>` is not `Vec<Vec<T>>` here.
145///
146/// Nothing is ever removed or mutated after it is pushed.
147struct Arena<T> {
148 #[allow(clippy::vec_box)] // see above: the pointee's address must not move
149 arrays: Vec<Box<[T]>>,
150}
151
152// Written out rather than derived: `#[derive(Default)]` would demand `T: Default`, and a
153// borrowed composite node has no default.
154impl<T> Default for Arena<T> {
155 fn default() -> Self {
156 Self { arrays: Vec::new() }
157 }
158}
159
160impl<T: 'static> Arena<T> {
161 /// Stores one array and borrows it back for the rest of the call.
162 ///
163 /// The `'static` is the same documented lie [`bytes_from_c`] tells: the slice is valid
164 /// for as long as this arena, and every caller drops the borrow before returning to C.
165 fn store(&mut self, array: Vec<T>) -> &'static [T] {
166 let boxed = array.into_boxed_slice();
167 let ptr = boxed.as_ptr();
168 let len = boxed.len();
169 self.arrays.push(boxed);
170 // SAFETY: `ptr` points into a heap allocation owned by a `Box` this arena now
171 // holds. A `Box`'s pointee does not move when the `Vec` holding the box
172 // reallocates, and nothing in this type ever mutates or removes an array, so the
173 // slice stays valid until the arena is dropped — which the caller does only after
174 // the write it built the tree for has returned.
175 unsafe { std::slice::from_raw_parts(ptr, len) }
176 }
177}
178
179/// The owners of one converted tree, kept alive for the duration of a write call.
180pub(crate) struct TreeArena<T: 'static> {
181 nodes: Arena<T>,
182 payloads: Arena<&'static [u8]>,
183}
184
185impl<T: 'static> Default for TreeArena<T> {
186 fn default() -> Self {
187 Self {
188 nodes: Arena::default(),
189 payloads: Arena::default(),
190 }
191 }
192}
193
194// ── C → Rust: the borrowed tree ───────────────────────────────────────────────
195
196/// Borrows a descriptor handle for the rest of the call.
197///
198/// # Safety
199///
200/// `ptr` MUST be null or point to a live [`HurrayDescriptor`] that outlives the call.
201unsafe fn descriptor_from_c(
202 ptr: *const HurrayDescriptor,
203) -> Result<&'static TensorDescriptor, HurrayStatus> {
204 if ptr.is_null() {
205 return Err(HURRAY_ERR_NULL_POINTER);
206 }
207 // SAFETY: non-null and caller-guaranteed live. The `'static` is [`bytes_from_c`]'s
208 // documented lie: the borrow ends before this call returns to C.
209 Ok((*ptr).inner())
210}
211
212/// Borrows one node's payload slices, leaving the bytes where the caller put them.
213///
214/// # Safety
215///
216/// The node's `payloads` MUST describe `payload_count` readable [`HurrayIoSlice`] values,
217/// each readable for its own `len`, unmodified for the duration of the call.
218unsafe fn payloads_from_c(node: &HurrayCompositeNode) -> Result<Vec<&'static [u8]>, HurrayStatus> {
219 if node.payload_count == 0 {
220 return Ok(Vec::new());
221 }
222 if node.payloads.is_null() {
223 return Err(HURRAY_ERR_NULL_POINTER);
224 }
225 // SAFETY: non-null and caller-guaranteed to describe `payload_count` readable slices.
226 let slices = std::slice::from_raw_parts(node.payloads, node.payload_count);
227 let mut borrowed = Vec::with_capacity(slices.len());
228 for slice in slices {
229 // SAFETY: caller guarantees `len` readable bytes at `ptr`; a zero length needs no
230 // pointer.
231 borrowed.push(bytes_from_c(slice.ptr, slice.len)?);
232 }
233 Ok(borrowed)
234}
235
236/// Converts the preorder array into the nameless tree a stream writer takes.
237///
238/// Returns the head's descriptor and its direct members, which is exactly what
239/// `StreamWriterCore::composite` asks for. Nodes are built in reverse preorder — a node's
240/// members always sit after it, so by the time a head is built its members already are —
241/// which is how the nesting is rebuilt without recursion.
242///
243/// # Safety
244///
245/// Every pointer in `nodes` MUST satisfy the contract documented on
246/// [`HurrayCompositeNode`] for the duration of the call.
247pub(crate) unsafe fn stream_tree(
248 arena: &mut TreeArena<CompositeNode<'static>>,
249 nodes: &[HurrayCompositeNode],
250) -> Result<(&'static TensorDescriptor, &'static [CompositeNode<'static>]), HurrayStatus> {
251 let members_of_node = root_check(nodes)?;
252 let mut built: Vec<Option<CompositeNode<'static>>> = (0..nodes.len()).map(|_| None).collect();
253
254 for index in (0..nodes.len()).rev() {
255 let node = &nodes[index];
256 // SAFETY: delegated to this function's own contract.
257 let descriptor = descriptor_from_c(node.descriptor)?;
258 built[index] = Some(if node.kind == HURRAY_ITEM_COMPOSITE {
259 CompositeNode::Composite {
260 head: descriptor,
261 members: arena
262 .nodes
263 .store(take_members(&mut built, &members_of_node[index])?),
264 }
265 } else {
266 // SAFETY: delegated to this function's own contract.
267 let payloads = payloads_from_c(node)?;
268 CompositeNode::Tensor {
269 descriptor,
270 buffers: arena.payloads.store(payloads),
271 }
272 });
273 }
274
275 match built[0].take() {
276 Some(CompositeNode::Composite { head, members }) => Ok((head, members)),
277 // `root_check` already refused a non-composite root.
278 _ => Err(HURRAY_ERR_INTERNAL),
279 }
280}
281
282/// Converts the preorder array into the named tree a file writer takes.
283///
284/// Returns the head's name and descriptor plus its direct members, which is what
285/// `flatten_composite` asks for.
286///
287/// Deliberately not shared with [`stream_tree`]: `CompositeNode` and `FileCompositeNode`
288/// are distinct types with no common trait upstream, and only one of them has names. A
289/// local trait to join them would be more surface than the thirty lines it saves — step
290/// 4b's conclusion for the same pair of write ops.
291///
292/// # Safety
293///
294/// Every pointer in `nodes` MUST satisfy the contract documented on
295/// [`HurrayCompositeNode`] for the duration of the call.
296pub(crate) unsafe fn file_tree(
297 arena: &mut TreeArena<FileCompositeNode<'static>>,
298 nodes: &[HurrayCompositeNode],
299) -> Result<
300 (
301 &'static str,
302 &'static TensorDescriptor,
303 &'static [FileCompositeNode<'static>],
304 ),
305 HurrayStatus,
306> {
307 let members_of_node = root_check(nodes)?;
308 let mut built: Vec<Option<FileCompositeNode<'static>>> =
309 (0..nodes.len()).map(|_| None).collect();
310
311 for index in (0..nodes.len()).rev() {
312 let node = &nodes[index];
313 // SAFETY: delegated to this function's own contract.
314 let descriptor = descriptor_from_c(node.descriptor)?;
315 let name = str_from_c(node.name, node.name_len)?;
316 built[index] = Some(if node.kind == HURRAY_ITEM_COMPOSITE {
317 FileCompositeNode::Composite {
318 name,
319 head: descriptor,
320 members: arena
321 .nodes
322 .store(take_members(&mut built, &members_of_node[index])?),
323 }
324 } else {
325 // SAFETY: delegated to this function's own contract.
326 let payloads = payloads_from_c(node)?;
327 FileCompositeNode::Tensor {
328 name,
329 descriptor,
330 buffers: arena.payloads.store(payloads),
331 }
332 });
333 }
334
335 match built[0].take() {
336 Some(FileCompositeNode::Composite {
337 name,
338 head,
339 members,
340 }) => Ok((name, head, members)),
341 // `root_check` already refused a non-composite root.
342 _ => Err(HURRAY_ERR_INTERNAL),
343 }
344}
345
346/// Validates the array's shape and that its root is a composite head.
347fn root_check(nodes: &[HurrayCompositeNode]) -> Result<Vec<Vec<usize>>, HurrayStatus> {
348 // A composite write with no head, or headed by a plain tensor, is the caller asking
349 // for a composite where there is none — `hurray_writer_write_tensor` writes a tensor.
350 match nodes.first() {
351 Some(root) if root.kind == HURRAY_ITEM_COMPOSITE => members_of(nodes),
352 _ => Err(HURRAY_ERR_NOT_COMPOSITE),
353 }
354}
355
356/// Takes the already-built members at `indices` out of `built`, in order.
357fn take_members<T>(built: &mut [Option<T>], indices: &[usize]) -> Result<Vec<T>, HurrayStatus> {
358 let mut members = Vec::with_capacity(indices.len());
359 for &index in indices {
360 // Reverse preorder guarantees every member index is greater than its head's, so
361 // each one is built and untaken: a `None` here would be this module's own bug.
362 let Some(member) = built.get_mut(index).and_then(Option::take) else {
363 return Err(HURRAY_ERR_INTERNAL);
364 };
365 members.push(member);
366 }
367 Ok(members)
368}