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}