Skip to main content

hurray_io/file/sans_io/
write.rs

1//! The sequential write half of the sans-io file core.
2//!
3//! The file container is written in one forward pass and never seeks — the footer index is
4//! built as tensors go out and emitted last — so this side needs no range vocabulary. What
5//! it does need is ADR-041 § 3's rule: payloads stay borrowed and are emitted one slice at
6//! a time, never assembled, never copied.
7
8use std::collections::HashSet;
9
10use hurray_core::{CompositeValidator, TensorDescriptor};
11
12use super::sections::{
13    align_up, encode_index_section, encode_kv_section, pad_to, validate_kv_keys,
14};
15use crate::file::types::{FileCompositeNode, FileWriterOptions, IndexEntry, KvValue};
16use crate::file::{file_flags, FILE_HEADER_SIZE, FILE_MAGIC, TRAILER_MAGIC};
17use crate::stream::{Unusable, WriteStep};
18use crate::{Error, Result};
19
20/// Alignment padding is emitted from here rather than materialised: a `data_buffer_alignment`
21/// of 2 MiB would otherwise mean a 2 MiB buffer of zeroes per tensor.
22static ZEROS: [u8; 4096] = [0u8; 4096];
23
24/// One stretch of bytes to emit, in file order.
25#[derive(Debug, Clone, Copy)]
26enum Seg {
27    /// Bytes the *operation* owns: the file header, a descriptor, or the footer. Held by
28    /// the op rather than the core so that abandoning it commits nothing (§ 6b).
29    Owned,
30    /// `len` padding bytes.
31    Zeros { len: u64 },
32    /// Caller payload `index`, borrowed and never copied (§ 3).
33    Payload { index: usize },
34}
35
36/// Writes the Hurray file container into bytes a driver sends onward.
37///
38/// Each tensor, and the footer, is submitted as one operation; the core emits the
39/// descriptor, the alignment padding and each payload in turn, and says when the item is
40/// written. The footer index is accumulated as it goes, so nothing has to be re-read and
41/// no offset is ever patched.
42///
43/// # Examples
44///
45/// ```
46/// use std::io::Write;
47///
48/// use hurray_core::{
49///     BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
50///     TensorDescriptor, MIN_BUFFER_ALIGNMENT,
51/// };
52/// use hurray_io::file::FileWriterCore;
53/// use hurray_io::stream::WriteStep;
54///
55/// let handle =
56///     BufferHandle::new(64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
57/// let descriptor = TensorDescriptor::new(
58///     1, 0, ElementType::Float32, Shape::new(vec![4u64, 4])?, 0,
59///     LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
60/// )?;
61/// let payload = [7u8; 64];
62/// // Bound, not a temporary: the operation borrows the payloads until the item is
63/// // written, which is ADR-041 § 3's lifetime rule showing up in the type system.
64/// let buffers: [&[u8]; 1] = [&payload];
65///
66/// /// Drives one operation to completion over any `io::Write`.
67/// fn drive(mut op: hurray_io::file::FileWriteOp<'_>, sink: &mut Vec<u8>) -> hurray_io::Result<()> {
68///     while let WriteStep::Output { .. } = op.step()? {
69///         let src = op.output_src();
70///         sink.write_all(src).map_err(hurray_io::Error::Io)?;
71///         let n = src.len();
72///         op.report_written(n)?;
73///     }
74///     Ok(())
75/// }
76///
77/// let mut file = Vec::new();
78/// let mut core = FileWriterCore::new();
79/// drive(core.header()?, &mut file)?;
80/// drive(core.tensor("embeddings", &descriptor, &buffers)?, &mut file)?;
81/// drive(core.finish(vec![])?, &mut file)?;
82///
83/// assert_eq!(&file[0..8], b"HRRYFILE");
84/// assert_eq!(&file[file.len() - 4..], b"HRRY");
85/// # Ok::<(), Box<dyn std::error::Error>>(())
86/// ```
87#[derive(Debug)]
88pub struct FileWriterCore {
89    alignment: u64,
90    sorted_index: bool,
91    /// Absolute offset of the first byte not yet reported written.
92    offset: u64,
93    entries: Vec<IndexEntry>,
94    seen_names: HashSet<String>,
95    unusable: Option<Unusable>,
96    /// Set when the item in flight has had a byte accepted, cleared when it completes
97    /// (§ 6b). On the core, not the op, because a driver that errors and drops the op
98    /// reports nothing — the poison cannot depend on being told.
99    torn: bool,
100    header_written: bool,
101    finished: bool,
102}
103
104impl Default for FileWriterCore {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl FileWriterCore {
111    /// Creates a core with default options: 4096-byte data alignment, unsorted index.
112    ///
113    /// # Examples
114    ///
115    /// See the type-level example.
116    pub fn new() -> Self {
117        // Default options are valid by construction, so this cannot fail — which is why
118        // `new` returns `Self` and only `with_options` returns a `Result`.
119        Self::build(FileWriterOptions::default())
120    }
121
122    /// Creates a core with custom options.
123    ///
124    /// # Errors
125    ///
126    /// [`Error::InvalidHeader`] — `data_buffer_alignment` is not a power of two in
127    /// `[4096, 2097152]`.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use hurray_io::file::{FileWriterCore, FileWriterOptions};
133    ///
134    /// let core = FileWriterCore::with_options(FileWriterOptions {
135    ///     data_buffer_alignment: 8192,
136    ///     sorted_index: true,
137    /// })?;
138    /// assert!(!core.is_unusable());
139    /// # Ok::<(), hurray_io::Error>(())
140    /// ```
141    pub fn with_options(options: FileWriterOptions) -> Result<Self> {
142        options.validate().map_err(Error::InvalidHeader)?;
143        Ok(Self::build(options))
144    }
145
146    fn build(options: FileWriterOptions) -> Self {
147        Self {
148            alignment: u64::from(options.data_buffer_alignment),
149            sorted_index: options.sorted_index,
150            offset: 0,
151            entries: Vec::new(),
152            seen_names: HashSet::new(),
153            unusable: None,
154            torn: false,
155            header_written: false,
156            finished: false,
157        }
158    }
159
160    /// Builds the 64-byte header on demand.
161    ///
162    /// Derived rather than stored so that a header operation owns its own bytes and an
163    /// abandoned one leaves the core untouched (§ 6b). Called once per file.
164    fn encode_header(&self) -> Vec<u8> {
165        // File flags known at write time. HAS_KV_METADATA is not set here because the KV
166        // content is supplied to `finish`, and this writer never seeks back to amend the
167        // header; readers treat a non-zero trailer `kv_offset` as authoritative.
168        let mut flags: u32 = file_flags::HAS_INDEX_CRC32C;
169        if self.sorted_index {
170            flags |= file_flags::SORTED_INDEX;
171        }
172
173        // `FILE_HEADER_SIZE`, not a literal 64: the header writes that same constant into
174        // its own `first_descriptor_offset` field below.
175        let mut header = vec![0u8; FILE_HEADER_SIZE as usize];
176        header[0..8].copy_from_slice(FILE_MAGIC);
177        header[8] = 1; // container_version_major
178        header[9] = 0; // container_version_minor
179                       // [10..12] _reserved = 0
180        header[12..16].copy_from_slice(&flags.to_le_bytes());
181        header[16..20].copy_from_slice(&(self.alignment as u32).to_le_bytes());
182        // first_descriptor_offset = 64 (immediately after the header)
183        header[20..28].copy_from_slice(&FILE_HEADER_SIZE.to_le_bytes());
184        // tensor_count_hint = UINT64_MAX (unknown at write time)
185        header[28..36].copy_from_slice(&u64::MAX.to_le_bytes());
186        // [36..64] _reserved_header = 0
187        header
188    }
189
190    /// Emits the 64-byte file header.
191    ///
192    /// Drive this before the first tensor: a core with no header written refuses
193    /// [`tensor`][Self::tensor] and [`finish`][Self::finish].
194    ///
195    /// # Errors
196    ///
197    /// - [`Error::CoreUnusable`] — the core is poisoned
198    /// - [`Error::CoreFailed`] — the header is already written, or the file is finished
199    ///
200    /// # Examples
201    ///
202    /// See the type-level example.
203    pub fn header(&mut self) -> Result<FileWriteOp<'_>> {
204        if let Some(reason) = self.retired() {
205            return Err(Error::CoreUnusable(reason));
206        }
207        // Checked in the same order as a second `finish`, so a header after the trailer
208        // reports `CoreFailed` for the same reason.
209        if self.finished || self.header_written {
210            return Err(Error::CoreFailed);
211        }
212        let bytes = self.encode_header();
213        Ok(FileWriteOp {
214            plan: vec![Seg::Owned],
215            core: self,
216            bytes,
217            payloads: &[],
218            cursor: 0,
219            written: 0,
220            emitted_bytes: 0,
221            commit: Some(Commit::Header),
222            failed: false,
223        })
224    }
225
226    /// Submits one tensor: its descriptor, its alignment padding, and its payloads.
227    ///
228    /// The returned operation borrows `buffers` until it reports
229    /// [`WriteStep::ItemWritten`]; the core holds no pointer into them afterwards (§ 3).
230    /// The tensor's index entry is recorded at that same point.
231    ///
232    /// # Errors
233    ///
234    /// - [`Error::TensorNameEmpty`] / [`Error::TensorNameTooLong`] — invalid name
235    /// - [`Error::DuplicateTensorName`] — the name is already in this file
236    /// - [`Error::MultiBufferLengthMismatch`] — `buffers.len()` is not the descriptor's
237    ///   buffer count
238    /// - [`Error::BufferSizeMismatch`] — a slice length differs from its handle's
239    ///   `byte_size`
240    /// - [`Error::Core`] — the descriptor does not encode
241    /// - [`Error::CoreFailed`] — the footer has already been written
242    /// - [`Error::CoreUnusable`] — the sink was poisoned mid-item
243    ///
244    /// # Examples
245    ///
246    /// See the type-level example.
247    pub fn tensor<'a>(
248        &'a mut self,
249        name: &str,
250        descriptor: &TensorDescriptor,
251        buffers: &'a [&'a [u8]],
252    ) -> Result<FileWriteOp<'a>> {
253        if let Some(reason) = self.retired() {
254            return Err(Error::CoreUnusable(reason));
255        }
256        // The trailer is the last thing in the file, so there is no legal tensor after it.
257        if self.finished {
258            return Err(Error::CoreFailed);
259        }
260        if !self.header_written {
261            return Err(Error::HeaderNotWritten);
262        }
263        if name.is_empty() {
264            return Err(Error::TensorNameEmpty);
265        }
266        if name.len() > 65535 {
267            return Err(Error::TensorNameTooLong { len: name.len() });
268        }
269        // `contains`, not `insert`: the name is consumed when the item completes, so an
270        // operation that never emits a byte leaves it free (§ 6b).
271        if self.seen_names.contains(name) {
272            return Err(Error::DuplicateTensorName(name.to_string()));
273        }
274        if buffers.len() != descriptor.buffers.len() {
275            return Err(Error::MultiBufferLengthMismatch {
276                declared: descriptor.buffers.len(),
277                actual: buffers.len(),
278            });
279        }
280        for (i, (handle, payload)) in descriptor.buffers.iter().zip(buffers).enumerate() {
281            let declared = handle.byte_size();
282            let actual = payload.len() as u64;
283            if declared != actual {
284                return Err(Error::BufferSizeMismatch {
285                    index: i,
286                    declared,
287                    actual,
288                });
289            }
290        }
291        let encoded = descriptor.encode()?;
292        // Exact, with nothing staged on the core: every byte an earlier item planned has
293        // either been reported written or belongs to an op that poisoned the core (§ 6b).
294        let descriptor_offset = self.offset;
295        let descriptor_length = encoded.len() as u32;
296
297        let mut plan = vec![Seg::Owned];
298
299        // Padding from the descriptor's end to the first data buffer. Keyed on there
300        // *being* a first data buffer: `buffer_count = 0` is reachable only for a
301        // composite head (ADR-027 D-1), which owns no buffer to align, and
302        // `file-format.md § Composite Tensors` forbids padding after one. An empty tensor
303        // still declares one buffer of `byte_size = 0`, so it keeps its padding — which
304        // is why the condition is the buffer table, not the payload size.
305        let descriptor_end = descriptor_offset + u64::from(descriptor_length);
306        let data_offset = if buffers.is_empty() {
307            descriptor_end
308        } else {
309            align_up(descriptor_end, self.alignment)
310        };
311        if data_offset > descriptor_end {
312            plan.push(Seg::Zeros {
313                len: data_offset - descriptor_end,
314            });
315        }
316
317        let mut cursor = data_offset;
318        let last = buffers.len().saturating_sub(1);
319        for (index, payload) in buffers.iter().enumerate() {
320            plan.push(Seg::Payload { index });
321            cursor += payload.len() as u64;
322            if index < last {
323                let padding = pad_to(cursor, self.alignment);
324                if padding > 0 {
325                    plan.push(Seg::Zeros { len: padding });
326                    cursor += padding;
327                }
328            }
329        }
330        // `data_length` covers data_offset through the last byte of the last buffer,
331        // excluding the trailing padding that aligns the next descriptor.
332        let data_length = cursor - data_offset;
333
334        // Pad to 8 bytes so the next descriptor starts on its own boundary.
335        let padding = pad_to(cursor, 8);
336        if padding > 0 {
337            plan.push(Seg::Zeros { len: padding });
338        }
339
340        Ok(FileWriteOp {
341            plan,
342            core: self,
343            bytes: encoded,
344            payloads: buffers,
345            cursor: 0,
346            written: 0,
347            emitted_bytes: 0,
348            // The name travels inside the entry, which already carries it.
349            commit: Some(Commit::Tensor(IndexEntry {
350                name: name.to_string(),
351                descriptor_offset,
352                descriptor_length,
353                data_offset,
354                data_length,
355            })),
356            failed: false,
357        })
358    }
359
360    /// Submits the footer: the KV section, the index, and the 40-byte trailer.
361    ///
362    /// # Errors
363    ///
364    /// - [`Error::KvKeyEmpty`] / [`Error::KvKeyTooLong`] / [`Error::DuplicateKvKey`] —
365    ///   invalid KV keys
366    /// - [`Error::InvalidHeader`] — a KV array is empty, nested, or not homogeneous
367    /// - [`Error::CoreFailed`] — the footer has already been written
368    /// - [`Error::CoreUnusable`] — the sink was poisoned mid-item
369    ///
370    /// # Examples
371    ///
372    /// See the type-level example.
373    pub fn finish(&mut self, kv: Vec<(String, KvValue)>) -> Result<FileWriteOp<'_>> {
374        if let Some(reason) = self.retired() {
375            return Err(Error::CoreUnusable(reason));
376        }
377        if self.finished {
378            return Err(Error::CoreFailed);
379        }
380        if !self.header_written {
381            return Err(Error::HeaderNotWritten);
382        }
383        validate_kv_keys(&kv)?;
384
385        // The tensor region is already 8-aligned, so the KV section starts where the last
386        // tensor left off. Footer padding is at most 7 bytes, so it is staged inline
387        // rather than streamed from `ZEROS`.
388        let footer_offset = self.offset;
389        let mut footer = Vec::new();
390
391        let (kv_offset, kv_length) = if kv.is_empty() {
392            (0u64, 0u32)
393        } else {
394            let kv_bytes = encode_kv_section(&kv)?;
395            let kv_length = kv_bytes.len() as u32;
396            footer.extend_from_slice(&kv_bytes);
397            // Absolute, not relative: the 8-byte boundary the index needs is a boundary in
398            // the file, and `footer_offset` is only incidentally aligned.
399            let padding = pad_to(footer_offset + footer.len() as u64, 8) as usize;
400            footer.resize(footer.len() + padding, 0);
401            (footer_offset, kv_length)
402        };
403
404        // Sorted into a local copy, not in place: `finish` may be retried after a footer
405        // that emitted nothing, and § 6b forbids an operation mutating state the core
406        // carries past it. (The sort is idempotent, so this is about the rule, not a bug.)
407        let index_bytes = if self.sorted_index {
408            let mut sorted = self.entries.clone();
409            sorted.sort_unstable_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
410            encode_index_section(&sorted)
411        } else {
412            encode_index_section(&self.entries)
413        };
414        let index_offset = footer_offset + footer.len() as u64;
415        let index_length = index_bytes.len() as u64;
416        let index_crc32c = crc32c::crc32c(&index_bytes);
417        footer.extend_from_slice(&index_bytes);
418
419        let mut trailer = [0u8; 40];
420        trailer[0..8].copy_from_slice(&index_offset.to_le_bytes());
421        trailer[8..16].copy_from_slice(&index_length.to_le_bytes());
422        trailer[16..24].copy_from_slice(&kv_offset.to_le_bytes());
423        trailer[24..28].copy_from_slice(&kv_length.to_le_bytes());
424        trailer[28..32].copy_from_slice(&index_crc32c.to_le_bytes());
425        // [32..36] _reserved = 0
426        trailer[36..40].copy_from_slice(TRAILER_MAGIC);
427        footer.extend_from_slice(&trailer);
428
429        Ok(FileWriteOp {
430            plan: vec![Seg::Owned],
431            core: self,
432            bytes: footer,
433            payloads: &[],
434            cursor: 0,
435            written: 0,
436            emitted_bytes: 0,
437            commit: Some(Commit::Footer),
438            failed: false,
439        })
440    }
441
442    /// Whether a write put an unknown number of bytes on the sink, leaving the file torn.
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// use hurray_io::file::FileWriterCore;
448    ///
449    /// assert!(!FileWriterCore::new().is_unusable());
450    /// ```
451    pub fn is_unusable(&self) -> bool {
452        self.retired().is_some()
453    }
454
455    /// The reason this core is retired, if it is.
456    ///
457    /// `unusable` wins over `torn`: a lost count is strictly worse than a known-incomplete
458    /// item. Read through here at every site so the precedence cannot drift.
459    fn retired(&self) -> Option<Unusable> {
460        self.unusable
461            .or(self.torn.then_some(Unusable::IncompleteOutput))
462    }
463
464    /// Retires the core because the output can no longer be completed.
465    ///
466    /// For a unit larger than one item — a composite group, whose head is already on the
467    /// sink when a member fails. A [`FileWriteOp`] cannot express that: the failure may
468    /// come from *submission*, with no operation in hand.
469    ///
470    /// Public because the driver that needs it may live in another crate. ADR-041 § 1
471    /// puts drivers at the edge, and a group spanning several operations is the driver's
472    /// unit, not the core's — so a binding outside `hurray-io` writing composites needs
473    /// the same tool `FileWriter` uses, exactly as [`WriteOp::mark_unusable`] is public
474    /// for the single-operation case.
475    ///
476    /// [`WriteOp::mark_unusable`]: crate::stream::WriteOp::mark_unusable
477    ///
478    /// # Examples
479    ///
480    /// ```
481    /// use hurray_io::file::FileWriterCore;
482    /// use hurray_io::stream::Unusable;
483    ///
484    /// let mut core = FileWriterCore::new();
485    /// core.poison(Unusable::IncompleteOutput);
486    /// assert!(core.is_unusable());
487    /// ```
488    pub fn poison(&mut self, reason: Unusable) {
489        self.unusable = Some(reason);
490    }
491}
492
493/// One item being written: a tensor, the header, or the footer.
494///
495/// Borrowing both the core and the caller's payloads for its lifetime is what enforces
496/// ADR-041 § 3's ownership rule in the type system — the core cannot hold a payload
497/// pointer after the operation ends, because the operation is what holds them.
498///
499/// # Examples
500///
501/// See [`FileWriterCore`].
502pub struct FileWriteOp<'a> {
503    core: &'a mut FileWriterCore,
504    /// The stretches to emit, in file order.
505    plan: Vec<Seg>,
506    /// This operation's own bytes: the header, a descriptor, or the footer.
507    bytes: Vec<u8>,
508    /// Payload slices of the tensor being emitted, in buffer order.
509    payloads: &'a [&'a [u8]],
510    /// Which entry of `plan` is being emitted.
511    cursor: usize,
512    /// Bytes of the current stretch already written.
513    written: usize,
514    /// Bytes of *this item* already accepted by the sink — per item, not per stretch. The
515    /// § 6b predicate.
516    emitted_bytes: u64,
517    /// What this operation changes on the core once every byte is out. `take`n at
518    /// completion so a second `step` after `ItemWritten` stays idempotent.
519    commit: Option<Commit>,
520    failed: bool,
521}
522
523/// What an operation owes the core once its last byte is out (§ 6b).
524#[derive(Debug)]
525enum Commit {
526    Header,
527    Tensor(IndexEntry),
528    Footer,
529}
530
531impl FileWriteOp<'_> {
532    /// Advances the operation and returns what it needs next. Never blocks.
533    ///
534    /// # Errors
535    ///
536    /// - [`Error::CoreFailed`] — a previous call ended this operation
537    /// - [`Error::CoreUnusable`] — the core was poisoned mid-item
538    ///
539    /// # Examples
540    ///
541    /// See [`FileWriterCore`].
542    pub fn step(&mut self) -> Result<WriteStep> {
543        if let Some(reason) = self.core.unusable {
544            return Err(Error::CoreUnusable(reason));
545        }
546        if self.failed {
547            return Err(Error::CoreFailed);
548        }
549        loop {
550            let Some(seg) = self.plan.get(self.cursor).copied() else {
551                // Every byte is out: the borrowed payloads are released and the operation
552                // pays what it owes. This is the single commit point of § 6b — the name and
553                // the index entry move together, which is what #248 was about.
554                self.core.torn = false;
555                match self.commit.take() {
556                    Some(Commit::Header) => self.core.header_written = true,
557                    Some(Commit::Tensor(entry)) => {
558                        self.core.seen_names.insert(entry.name.clone());
559                        self.core.entries.push(entry);
560                    }
561                    Some(Commit::Footer) => self.core.finished = true,
562                    None => {}
563                }
564                return Ok(WriteStep::ItemWritten);
565            };
566
567            let remaining = self.remaining_in(seg);
568            if remaining == 0 {
569                self.cursor += 1;
570                self.written = 0;
571                continue;
572            }
573            return Ok(WriteStep::Output { len: remaining });
574        }
575    }
576
577    /// The bytes to write now: one stretch of one segment, never a concatenation (§ 3).
578    ///
579    /// For a payload this is a slice of the caller's own memory; for padding it is a
580    /// window onto a shared page of zeroes, so the same bytes may be offered repeatedly
581    /// until the padding is out.
582    ///
583    /// # Examples
584    ///
585    /// See [`FileWriterCore`].
586    pub fn output_src(&self) -> &[u8] {
587        match self.plan.get(self.cursor).copied() {
588            Some(Seg::Owned) => &self.bytes[self.written.min(self.bytes.len())..],
589            Some(Seg::Zeros { len }) => {
590                let left = len - self.written as u64;
591                let take = left.min(ZEROS.len() as u64) as usize;
592                &ZEROS[..take]
593            }
594            Some(Seg::Payload { index }) => match self.payloads.get(index) {
595                Some(payload) => &payload[self.written.min(payload.len())..],
596                None => &[],
597            },
598            None => &[],
599        }
600    }
601
602    /// Reports that the sink accepted `n` bytes of [`output_src`][Self::output_src].
603    ///
604    /// A short write re-offers the remainder.
605    ///
606    /// # Errors
607    ///
608    /// - [`Error::ZeroLengthWrite`] — `n == 0`, which § 4 forbids treating as progress. A
609    ///   sink that cannot accept bytes is reported with
610    ///   [`report_sink_closed`][Self::report_sink_closed] instead.
611    /// - [`Error::InvalidProgressReport`] — more bytes reported than were offered. An
612    ///   unknown number of bytes is then on the sink and cannot be un-written, so this
613    ///   poisons the core (§ 6).
614    ///
615    /// # Examples
616    ///
617    /// See [`FileWriterCore`].
618    pub fn report_written(&mut self, n: usize) -> Result<()> {
619        if let Some(reason) = self.core.unusable {
620            return Err(Error::CoreUnusable(reason));
621        }
622        if self.failed {
623            return Err(Error::CoreFailed);
624        }
625        if n == 0 {
626            // § 4: a zero-length write is not success. The position is still known, so the
627            // operation stays usable — the driver may retry or declare the sink closed.
628            return Err(Error::ZeroLengthWrite);
629        }
630        let Some(seg) = self.plan.get(self.cursor).copied() else {
631            return Err(Error::InvalidProgressReport {
632                reported: n,
633                offered: 0,
634            });
635        };
636        let offered = self.remaining_in(seg);
637        if n > offered {
638            self.core.unusable = Some(Unusable::LostWriteCount);
639            self.failed = true;
640            return Err(Error::InvalidProgressReport {
641                reported: n,
642                offered,
643            });
644        }
645
646        if self.emitted_bytes == 0 {
647            // Poison at the first accepted byte, not in `Drop`: `Drop` can be skipped by
648            // `forget`, delayed by a live op, or run while unwinding. The transient state
649            // is unobservable anyway — the op holds `&mut core` for its whole life.
650            //
651            // Strictly this is the first *reported* byte. ADR-041 § 4 is what closes the
652            // gap: the transfer and its count report form one uninterruptible step, so a
653            // byte the sink took is always a byte reported.
654            self.core.torn = true;
655        }
656        self.emitted_bytes += n as u64;
657        self.core.offset += n as u64;
658        // One buffer per segment now, so `written` tracks every kind of stretch.
659        self.written += n;
660        Ok(())
661    }
662
663    /// Bytes of this item already accepted by the sink, padding included.
664    ///
665    /// Zero means nothing of the item has left the driver, which is exactly when the item
666    /// may be resubmitted after a failure (§ 6b).
667    ///
668    /// # Examples
669    ///
670    /// ```
671    /// use hurray_io::file::FileWriterCore;
672    ///
673    /// let mut core = FileWriterCore::new();
674    /// let mut op = core.header()?;
675    /// // Stepping offers bytes; only a report counts them.
676    /// op.step()?;
677    /// assert_eq!(op.emitted(), 0);
678    ///
679    /// op.report_written(16)?;
680    /// assert_eq!(op.emitted(), 16);
681    /// # Ok::<(), hurray_io::Error>(())
682    /// ```
683    pub fn emitted(&self) -> u64 {
684        self.emitted_bytes
685    }
686
687    /// Declares the sink closed while this item is in flight.
688    ///
689    /// Returns the error to propagate. A partially written file has a torn item in it and
690    /// no footer, so this poisons the core: a fresh operation on the same sink is not a
691    /// recovery (§ 6).
692    ///
693    /// # Examples
694    ///
695    /// ```no_run
696    /// # fn main() -> hurray_io::Result<()> {
697    /// # let mut core = hurray_io::file::FileWriterCore::new();
698    /// let mut op = core.header()?;
699    /// let error = op.report_sink_closed();
700    /// assert!(matches!(error, hurray_io::Error::SinkClosed));
701    /// # Ok(())
702    /// # }
703    /// ```
704    pub fn report_sink_closed(&mut self) -> Error {
705        // Not `LostWriteCount`: the count is known exactly. What is lost is the ability to
706        // finish the item, which is a different claim (§ 6b).
707        self.core.unusable = Some(Unusable::IncompleteOutput);
708        self.failed = true;
709        Error::SinkClosed
710    }
711
712    fn remaining_in(&self, seg: Seg) -> usize {
713        match seg {
714            Seg::Owned => self.bytes.len().saturating_sub(self.written),
715            Seg::Zeros { len } => {
716                let left = len - self.written as u64;
717                left.min(ZEROS.len() as u64) as usize
718            }
719            Seg::Payload { index } => match self.payloads.get(index) {
720                Some(payload) => payload.len().saturating_sub(self.written),
721                None => 0,
722            },
723        }
724    }
725}
726
727/// One tensor of a flattened composite, ready to hand to
728/// [`FileWriterCore::tensor`][FileWriterCore::tensor].
729///
730/// # Examples
731///
732/// See [`flatten_composite`].
733pub struct CompositeWrite<'a> {
734    /// The tensor's name, which becomes its footer-index entry.
735    pub name: &'a str,
736    /// Its descriptor. A composite head's declares no buffers.
737    pub descriptor: &'a TensorDescriptor,
738    /// Its payloads, one slice per buffer handle. Empty for a head.
739    pub buffers: &'a [&'a [u8]],
740}
741
742/// Validates a composite tree and flattens it into the order its tensors go on disk.
743///
744/// Every tensor — the head and each member, nested or not — gets its own footer-index
745/// entry (ADR-027 § Binding), and membership is recovered on read from the head's
746/// `member_count` plus file-offset adjacency. So the order here *is* the membership: head
747/// first, then each member in turn, depth first.
748///
749/// The whole tree is validated before anything is returned — member counts, partition
750/// exact-cover, overlay ordering, at every level — so a torn or invalid composite never
751/// reaches a sink. The traversal is an explicit stack rather than recursion (§ 10).
752///
753/// # Errors
754///
755/// [`Error::Core`] — a head is not a valid composite head, or a level failed validation.
756///
757/// # Examples
758///
759/// ```
760/// use hurray_core::{
761///     layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
762///     BufferHandle, DeviceTag, ElementType, Shape, SyncMode, TensorDescriptor,
763///     MIN_BUFFER_ALIGNMENT,
764/// };
765/// use hurray_io::file::{flatten_composite, FileCompositeNode};
766///
767/// let head = TensorDescriptor::new(
768///     1, 0, ElementType::Float32, Shape::new(vec![2u64, 2])?, 0,
769///     LayoutDescriptor::Composite(CompositeLayout::new(CompositionRule::Group, 1)?),
770///     vec![], None, None, None, None,
771/// )?;
772/// let handle =
773///     BufferHandle::new(16, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
774/// let member = TensorDescriptor::new(
775///     1, 0, ElementType::Float32, Shape::new(vec![2u64, 2])?, 0,
776///     LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
777/// )?;
778/// let data = [0u8; 16];
779/// let buffers: [&[u8]; 1] = [&data];
780/// let members = [FileCompositeNode::Tensor {
781///     name: "w.0",
782///     descriptor: &member,
783///     buffers: &buffers,
784/// }];
785///
786/// let order = flatten_composite("w", &head, &members)?;
787/// assert_eq!(order.len(), 2);
788/// assert_eq!(order[0].name, "w");
789/// assert!(order[0].buffers.is_empty());
790/// assert_eq!(order[1].name, "w.0");
791/// # Ok::<(), Box<dyn std::error::Error>>(())
792/// ```
793pub fn flatten_composite<'a>(
794    head_name: &'a str,
795    head: &'a TensorDescriptor,
796    members: &'a [FileCompositeNode<'a>],
797) -> Result<Vec<CompositeWrite<'a>>> {
798    const NO_BUFFERS: &[&[u8]] = &[];
799
800    let mut order = vec![CompositeWrite {
801        name: head_name,
802        descriptor: head,
803        buffers: NO_BUFFERS,
804    }];
805
806    // Each frame is one open head's remaining members, so the deepest frame is the one
807    // being consumed — the call stack `write_composite_members` used, made visible.
808    let mut stack = vec![(head, members, 0usize)];
809
810    while let Some((open_head, nodes, next)) = stack.pop() {
811        if next == 0 {
812            // Validate a level once, on the way in.
813            let mut validator = CompositeValidator::new(open_head)?;
814            for node in nodes {
815                validator.push_member(node.descriptor())?;
816            }
817            validator.finish()?;
818        }
819        let Some(node) = nodes.get(next) else {
820            continue;
821        };
822        stack.push((open_head, nodes, next + 1));
823
824        match node {
825            FileCompositeNode::Tensor {
826                name,
827                descriptor,
828                buffers,
829            } => order.push(CompositeWrite {
830                name,
831                descriptor,
832                buffers,
833            }),
834            FileCompositeNode::Composite {
835                name,
836                head: nested_head,
837                members: nested_members,
838            } => {
839                order.push(CompositeWrite {
840                    name,
841                    descriptor: nested_head,
842                    buffers: NO_BUFFERS,
843                });
844                stack.push((nested_head, nested_members, 0));
845            }
846        }
847    }
848
849    Ok(order)
850}