Skip to main content

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}