Skip to main content

limnifs_write/
lib.rs

1//! `LimniFS` writer pipeline — directory tree to `.lim` image.
2//!
3//! The writer takes a real directory tree and produces a valid `.lim`
4//! manifest artifact with inlined metadata. Files at or below the
5//! inline threshold (4 KiB) are stored as inline data in their inodes;
6//! larger files are stored as drops packed into a single slab.
7//!
8//! ## Usage
9//!
10//! ```no_run
11//! use std::path::Path;
12//! use limnifs_write::write_directory;
13//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let artifact = write_directory(Path::new("/path/to/dir"))?;
15//! std::fs::write("output.lim", &artifact.bytes)?;
16//! # Ok(())
17//! # }
18//! ```
19
20#![deny(unsafe_code)]
21#![allow(warnings)]
22
23pub mod chunker;
24pub mod classifier;
25pub mod compaction;
26pub mod config;
27pub mod delta_builder;
28pub mod dictionary;
29pub mod file_categorizer;
30pub mod flatten;
31#[cfg(feature = "pipeline-parallelism")]
32pub mod pipeline;
33pub mod rw;
34#[cfg(feature = "sparse-index")]
35pub mod sparse_index;
36pub mod turnover;
37
38pub use config::{
39    profile, CategorizerConfig, ChunkingConfig, CodecRegistry, CodecTunables, Defaults,
40    DictionaryConfig, EncryptionConfig, TournamentConfig, WriteConfig,
41};
42
43use std::collections::{HashMap, HashSet};
44use std::path::{Path, PathBuf};
45
46use crate::chunker::FastCDC;
47use limnifs_core::codec::CODEC_REFERENCED;
48use limnifs_core::slab_store::SlabStore;
49use limnifs_core::{
50    compute_merkle_root, hash_empty_section, hash_section, parse_manifest_header, parse_slab_index,
51    ManifestCursor, ManifestHeader, SectionHashes, FEATURE_FLAGS_SECTION_VERSION,
52    HISTORY_SECTION_VERSION, INODE_FLAG_INLINE_DATA, INODE_FLAG_SHARED_INLINE,
53    METADATA_REFERENCE_SECTION_VERSION_2, SLAB_INDEX_SECTION_VERSION,
54};
55use limnifs_format::{ManifestRoot, SlabId};
56
57/// Inline-data threshold: files at or below this size get inline data
58/// in their inode. Larger files are stored as drops in a slab.
59pub const INLINE_THRESHOLD: usize = 4096;
60
61/// Above this size, mmap the input file instead of `std::fs::read`-ing
62/// it into a `Vec<u8>`. Keeps peak RSS bounded when packing huge files
63/// (multi-GiB source trees, ML models). Crossover is around 1 MiB on
64/// most filesystems — below that the syscall + VMA setup costs more
65/// than the read.
66pub const MMAP_READ_THRESHOLD: usize = 1024 * 1024;
67
68/// Maximum file size for the whole-file categorizer path. Files above
69/// this threshold use FastCDC chunking even when a categorizer claims
70/// them, enabling rayon parallelism across chunks. The categorizer's
71/// codec is still used per-chunk when possible.
72pub const WHOLE_FILE_MAX_SIZE: usize = 64 * 1024 * 1024;
73
74/// Maximum total length of a single slab file (header + content).
75/// Matches the reader's `DEFAULT_SLAB_MAX_BYTES` (spec §3.1) minus a
76/// safety margin so a slab that is full but not yet flushed cannot
77/// overrun the reader ceiling on the next drop.
78pub const MAX_SLAB_TOTAL_BYTES: usize = 60 * 1024 * 1024;
79
80/// Width of the slab header (magic + version + `SlabId` + `total_length` +
81/// `ec_descriptor` + `crypto_hint`). Must agree with
82/// `limnifs_core::slab::SLAB_HEADER_LEN`.
83const SLAB_HEADER_LEN: usize = 56;
84
85/// Threshold at which the writer externalises the metadata blob to a
86/// sidecar file instead of inlining it in the manifest. The reader's
87/// default inline ceiling is 1 MiB (spec §5.3); we externalise well
88/// before that to leave headroom for variance in inode encoding and
89/// to keep manifests compact for large trees.
90pub const METADATA_EXTERNALIZE_THRESHOLD: usize = 768 * 1024;
91
92/// Metadata-blob size above which the writer steps Brotli quality
93/// down to `METADATA_LARGE_BLOB_QUALITY`. Below this, q5's cost is
94/// negligible; above it, q5 starts to dominate create time on big
95/// inode trees (e.g. the 50 K-file tiny-files dataset).
96pub const METADATA_LARGE_BLOB_THRESHOLD: usize = 256 * 1024;
97
98/// Brotli quality for small metadata blobs (≤ `METADATA_LARGE_BLOB_THRESHOLD`).
99/// Best ratio; cost is in the noise on small inputs.
100pub const METADATA_SMALL_BLOB_QUALITY: i32 = 5;
101
102/// Brotli quality for large metadata blobs. q2 is much faster than q5
103/// on multi-MiB inputs; ratio on highly compressible inode data is
104/// within 5–10% of q5 (often identical) because metadata is dominated
105/// by long runs of repeated patterns.
106pub const METADATA_LARGE_BLOB_QUALITY: i32 = 2;
107
108/// One slab produced by the writer. The slab ordinal in `id` matches
109/// the slab's position in `WriteArtifact::slabs`.
110#[derive(Clone, Debug)]
111pub struct SlabArtifact {
112    pub id: SlabId,
113    pub bytes: Vec<u8>,
114    pub locator: String,
115    /// `DropIds` contained in this slab, in slab order. Used by callers
116    /// that need to know which slab holds which drop without re-parsing
117    /// the slab bytes.
118    pub drop_ids: Vec<[u8; 32]>,
119}
120
121/// Externalized metadata sidecar, present when the metadata blob
122/// exceeds [`METADATA_EXTERNALIZE_THRESHOLD`]. Callers must write
123/// `bytes` to `locator` next to the manifest file.
124#[derive(Clone, Debug)]
125pub struct MetadataSidecar {
126    pub bytes: Vec<u8>,
127    pub locator: String,
128}
129
130/// Result of writing a directory tree.
131#[derive(Clone, Debug)]
132pub struct WriteArtifact {
133    pub bytes: Vec<u8>,
134    pub merkle_root: ManifestRoot,
135    /// All slabs produced by the writer, in slab-ordinal order. Empty
136    /// when the source tree had no files > [`INLINE_THRESHOLD`].
137    pub slabs: Vec<SlabArtifact>,
138    /// External metadata sidecar, present when the metadata blob
139    /// exceeds [`METADATA_EXTERNALIZE_THRESHOLD`]. `None` means the
140    /// metadata is inlined in the manifest.
141    pub metadata_sidecar: Option<MetadataSidecar>,
142    pub inode_count: usize,
143    pub file_count: usize,
144    pub dir_count: usize,
145    pub drop_count: usize,
146    /// Inode number of the root directory (i.e. the inode that
147    /// represents the source directory itself, not a child of it).
148    /// Always a directory and always referenced by the inlined
149    /// metadata blob's directory inode table.
150    pub root_inode_number: u64,
151}
152
153impl WriteArtifact {
154    /// Convenience accessor for the single-slab case. Returns the
155    /// first slab's bytes if there is exactly one slab, else `None`.
156    /// Modern callers should iterate [`WriteArtifact::slabs`] directly.
157    #[must_use]
158    pub fn slab_bytes(&self) -> Option<&[u8]> {
159        if self.slabs.len() == 1 {
160            Some(&self.slabs[0].bytes)
161        } else {
162            None
163        }
164    }
165
166    /// Convenience accessor for the single-slab case.
167    #[must_use]
168    pub fn slab_locator(&self) -> Option<&str> {
169        if self.slabs.len() == 1 {
170            Some(&self.slabs[0].locator)
171        } else {
172            None
173        }
174    }
175}
176
177/// Error during writing.
178#[derive(Debug)]
179pub enum WriteError {
180    Io(std::io::Error),
181}
182
183impl std::fmt::Display for WriteError {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            Self::Io(e) => write!(f, "I/O error: {e}"),
187        }
188    }
189}
190
191impl std::error::Error for WriteError {}
192
193impl From<std::io::Error> for WriteError {
194    fn from(e: std::io::Error) -> Self {
195        Self::Io(e)
196    }
197}
198
199/// Walk a directory tree and produce a valid `.lim` manifest artifact
200/// with inlined metadata. Files at or below [`INLINE_THRESHOLD`] bytes
201/// are stored inline; larger files are packed into a single slab as
202/// content-addressed drops.
203///
204/// File contents (read, `FastCDC` chunk, `BLAKE3` hash, `LZ4` compress) are
205/// processed in parallel across `CPU` cores via `rayon`. The directory
206/// tree walk and slab assembly remain sequential so the output is
207/// deterministic.
208///
209/// # Errors
210///
211/// Returns [`WriteError::Io`] for filesystem errors.
212pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
213    write_directory_with_config(root, &WriteConfig::default_v0_1())
214}
215
216/// Pack a single named stream into a `.lim` image.
217///
218/// For callers that pipe data from a network socket, pipe, or generator
219/// and don't want to materialise the full content on disk before
220/// packing. The reader is consumed via [`FastCDC::chunk_reader`] which
221/// bounds internal buffering at `max_chunk_size + 64 KiB`.
222///
223/// The resulting image has a single root file with the given `name`
224/// (path-relative; safe to use `/` for subdirectories — they're
225/// materialised in the metadata tree).
226///
227/// # Errors
228///
229/// Returns [`WriteError::Io`] on read failure or any writer-pipeline
230/// error.
231pub fn write_stream<R: std::io::Read>(
232    name: &str,
233    reader: R,
234    config: &WriteConfig,
235) -> Result<WriteArtifact, WriteError> {
236    let mut ctx = WriteContext::new();
237    ctx.categorizers_disabled = config.categorizers.is_empty();
238    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
239    ctx.auto_turnover = config.turnover_threshold > 0;
240    ctx.collect_dict_samples = config.dictionaries.enabled;
241
242    // Synthesise a single PendingFile that points to nothing on disk;
243    // we'll bypass process_file's `std::fs::read` and feed the
244    // pre-chunked bytes directly.
245    let drop_id_root = [0u8; 32]; // placeholder; replaced below
246    let pending = PendingFile {
247        path: std::path::PathBuf::from(name),
248        inode_number: 1,
249        file_len: 0, // patched below once we know the total
250        mtime_ns: 0,
251    };
252    ctx.pending_files.push(pending);
253    ctx.root_inode_number = 1;
254
255    // Chunk the stream directly via FastCDC's chunk_reader.
256    let chunker = ctx.chunker.clone();
257    let chunks = chunker.chunk_reader(reader)?;
258
259    // Total size = sum of chunk lengths.
260    let total_len: u64 = chunks.iter().map(|c| c.len() as u64).sum();
261
262    // Hash + compress each chunk. We treat each chunk as a unique drop
263    // (the stream is single-pass; cross-call dedup is left to the
264    // caller). Use the configured tournament spec.
265    let text_codec = config.text_codec_id().unwrap_or(0x04);
266    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
267    let tunables = config.to_core_tunables();
268    let classifier = ctx.classifier;
269    let registry = config
270        .codec_registry()
271        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
272    let tournament_codec_ids: Vec<u8> = config
273        .tournament
274        .codecs
275        .iter()
276        .filter_map(|n| registry.lookup_by_name(n))
277        .collect();
278    let tournament = TournamentSpec {
279        codec_ids: tournament_codec_ids,
280        min_size: config.tournament.min_size_threshold as usize,
281        skip_for_binary: config.tournament.skip_for_binary,
282        short_circuit_permille: config.tournament.short_circuit_threshold,
283    };
284
285    let mut drops: Vec<RawDrop> = Vec::with_capacity(chunks.len());
286    let mut slices: Vec<PendingSlice> = Vec::with_capacity(chunks.len());
287    let mut offset: u64 = 0;
288    for chunk in &chunks {
289        let drop_id = hash_section(chunk);
290        slices.push(PendingSlice {
291            drop_id,
292            file_byte_start: offset,
293            file_byte_end: offset + chunk.len() as u64,
294        });
295        offset += chunk.len() as u64;
296        let class = classifier.classify(chunk);
297        let (codec_id, compressed) = compress_chunk_with_tournament(
298            chunk,
299            class,
300            text_codec,
301            binary_codec,
302            &tunables,
303            &tournament,
304        );
305        drops.push((drop_id, chunk.clone(), compressed, codec_id));
306    }
307    let _ = drop_id_root;
308
309    // Wire into WriteContext as a single-file result + inode.
310    let result = ChunkedFileResult { drops, slices };
311    let pf = ctx.pending_files[0].clone();
312    ctx.merge_chunked_file(&pf, result);
313    // Patch the file_len now that we know it.
314    ctx.pending_files[0].file_len = total_len;
315    // The inode was already pushed by merge_chunked_file with the old
316    // (zero) file_len; correct it.
317    if let Some(inode) = ctx.inodes.last_mut() {
318        if let PendingContent::DropBacked { file_len, .. } = &mut inode.content {
319            *file_len = total_len;
320        }
321    }
322
323    ctx.train_and_apply_dictionary(&config.dictionaries);
324    let artifact = ctx.assemble();
325    Ok(artifact)
326}
327
328/// Pack a directory tree as a **layer** on top of a base image.
329///
330/// Produces a `.lim` image whose drops are split into two sets:
331///
332/// - **Local drops** — chunks in `root` whose `DropId` is NOT in
333///   `base_image`'s drop set. These are compressed and stored in the
334///   layer's own slabs exactly as `write_directory_with_config`
335///   would store them.
336/// - **Referenced drops** — chunks whose `DropId` IS in the base.
337///   These are recorded only as `PendingSlice` references (so the
338///   metadata tree links them in); no slab bytes are emitted in the
339///   layer. The reader resolves them via the overlay chain.
340///
341/// The resulting manifest carries a `delta_linkage` section pointing
342/// at the base image's `ManifestRoot`, so any reader that supports
343/// overlay chains can extract the layer standalone or stacked on the
344/// base.
345///
346/// # Determinism
347///
348/// `write_layer` is deterministic given the same `base_image`, the
349/// same `root` content, and the same `config`. Two runs produce
350/// byte-identical layer images.
351///
352/// # Errors
353///
354/// Returns [`WriteError::Io`] on read failure or any writer-pipeline
355/// error.
356///
357/// # Example
358///
359/// ```no_run
360/// use limnifs_write::{write_layer, profile};
361///
362/// let base = std::path::Path::new("base.lim");
363/// let root = std::path::Path::new("./new-content");
364/// let cfg = profile::balanced();
365/// let artifact = write_layer(base, root, &cfg).expect("layer");
366/// // artifact.bytes is the layer manifest; slabs contain only NEW drops.
367/// ```
368pub fn write_layer(
369    base_image: &Path,
370    root: &Path,
371    config: &WriteConfig,
372) -> Result<WriteArtifact, WriteError> {
373    // Load the base image's drop set + manifest root.
374    let (base_drop_index, base_root) = load_base_drop_index(base_image)?;
375
376    let mut ctx = WriteContext::new();
377    ctx.categorizers_disabled = config.categorizers.is_empty();
378    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
379    ctx.auto_turnover = config.turnover_threshold > 0;
380    ctx.collect_dict_samples = config.dictionaries.enabled;
381    ctx.inline_threshold = config.defaults.inline_threshold as usize;
382    ctx.base_drop_index = Some(base_drop_index);
383    ctx.base_root = Some(base_root);
384
385    // Rest is identical to write_directory_with_config.
386    let root_inode_number = ctx.walk(root)?;
387    ctx.root_inode_number = root_inode_number;
388    write_directory_body(&mut ctx, config)?;
389    Ok(ctx.assemble())
390}
391
392/// Load every DropId present in a base image's slabs + the image's
393/// `ManifestRoot`. Used by `write_layer` to decide which chunks can
394/// be referenced rather than re-encoded.
395fn load_base_drop_index(
396    base_image: &Path,
397) -> Result<(std::collections::HashSet<[u8; 32]>, [u8; 32]), WriteError> {
398    let manifest_bytes = std::fs::read(base_image)?;
399    let mut cursor = ManifestCursor::new(&manifest_bytes);
400    let _ = parse_manifest_header(&mut cursor).map_err(io_core)?;
401    // Walk sections in spec order: flags → metadata_reference → slab_index.
402    let _ = limnifs_core::parse_feature_flags_section(&mut cursor);
403    let _ = limnifs_core::parse_metadata_reference(&mut cursor);
404    let slab_index = parse_slab_index(&mut cursor).map_err(io_core)?;
405    let store = SlabStore::load_mmap(base_image, &slab_index).map_err(io_core)?;
406    let drop_set: std::collections::HashSet<[u8; 32]> = store.drop_index_keys().copied().collect();
407    // Re-derive the Merkle root from the base manifest's section
408    // bytes. The base's `ManifestRoot` is the canonical anchor for
409    // the layer's `delta_linkage.base_root` field — round-tripping
410    // through section hashes guarantees it matches what the base
411    // reported on its own assemble path.
412    let root = *compute_merkle_root_from_sections(&manifest_bytes).as_bytes();
413    Ok((drop_set, root))
414}
415
416/// Re-derive a manifest's `ManifestRoot` from its on-disk section
417/// bytes. Mirrors `flatten::compute_merkle_root_from_sections` but
418/// tolerates absent optional sections (returns hash_empty_section()
419/// for them). Used by `load_base_drop_index` to anchor a layer's
420/// `base_root` without re-instantiating a full manifest parser.
421fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
422    use limnifs_core::SectionHashes;
423    let mut cursor = ManifestCursor::new(manifest);
424    let header_start = 0;
425    if parse_manifest_header(&mut cursor).is_err() {
426        // Not a valid manifest; fall back to all-zero root.
427        return ManifestRoot::from_bytes([0u8; 32]);
428    }
429    let header_end = cursor.position();
430    // Optional sections — best-effort parse; failures hash as empty.
431    let flags_start = header_end;
432    let flags_end = match limnifs_core::parse_feature_flags_section(&mut cursor) {
433        Ok(_) => cursor.position(),
434        Err(_) => flags_start,
435    };
436    let meta_ref_start = flags_end;
437    let metadata_reference = match limnifs_core::parse_metadata_reference(&mut cursor) {
438        Ok(m) => Some(m),
439        Err(_) => None,
440    };
441    let meta_ref_end = cursor.position();
442    let slab_index_start = meta_ref_end;
443    let _ = parse_slab_index(&mut cursor);
444    let slab_index_end = cursor.position();
445    let history_start = slab_index_end;
446    let _ = limnifs_core::parse_history(&mut cursor);
447    let history_end = cursor.position();
448
449    let hashes = SectionHashes {
450        metadata: metadata_reference
451            .map(|m| m.metadata_hash)
452            .unwrap_or_else(hash_empty_section),
453        format_header: hash_section(&manifest[header_start..header_end]),
454        feature_flags: hash_section(&manifest[flags_start..flags_end]),
455        metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
456        slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
457        crypto_params: hash_empty_section(),
458        ec_params: hash_empty_section(),
459        dms_policy: hash_empty_section(),
460        delta_linkage: hash_empty_section(),
461        history: hash_section(&manifest[history_start..history_end]),
462    };
463    compute_merkle_root(&hashes)
464}
465
466fn io_core(e: limnifs_core::CoreError) -> WriteError {
467    WriteError::Io(std::io::Error::other(format!("base image load: {e}")))
468}
469
470/// Shared body between `write_directory_with_config` and `write_layer`.
471/// Walks `ctx.pending_files` through `process_file` in parallel and
472/// merges the results back. Caller is responsible for `walk()` and
473/// `assemble()`.
474fn write_directory_body(ctx: &mut WriteContext, config: &WriteConfig) -> Result<(), WriteError> {
475    use rayon::prelude::*;
476
477    ctx.metadata_codec = config
478        .metadata_codec_id()
479        .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
480
481    let pending = std::mem::take(&mut ctx.pending_files);
482    if pending.is_empty() {
483        return Ok(());
484    }
485    ctx.inline_threshold = config.defaults.inline_threshold as usize;
486    let chunker = ctx.chunker.clone();
487    let classifier = ctx.classifier;
488    let text_codec = config.text_codec_id().unwrap_or(0x04);
489    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
490    let tunables = config.to_core_tunables();
491    let use_categorizers = !config.categorizers.is_empty();
492    let skip_chunking = config.skip_chunking;
493    let registry = config
494        .codec_registry()
495        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
496    let tournament_codec_ids: Vec<u8> = config
497        .tournament
498        .codecs
499        .iter()
500        .filter_map(|n| registry.lookup_by_name(n))
501        .collect();
502    let tournament_spec = TournamentSpec {
503        codec_ids: tournament_codec_ids,
504        min_size: config.tournament.min_size_threshold as usize,
505        skip_for_binary: config.tournament.skip_for_binary,
506        short_circuit_permille: config.tournament.short_circuit_threshold,
507    };
508    let base_drop_index = ctx.base_drop_index.as_ref();
509    let inline_threshold = ctx.inline_threshold;
510    let results: Vec<ChunkedFileResult> = pending
511        .par_iter()
512        .map(|pf| {
513            process_file(
514                pf,
515                &chunker,
516                classifier,
517                text_codec,
518                binary_codec,
519                &tunables,
520                use_categorizers,
521                skip_chunking,
522                &tournament_spec,
523                base_drop_index,
524                inline_threshold,
525            )
526        })
527        .collect::<Result<Vec<_>, _>>()?;
528
529    for (pf, result) in pending.iter().zip(results) {
530        ctx.merge_chunked_file(pf, result);
531    }
532    ctx.train_and_apply_dictionary(&config.dictionaries);
533    Ok(())
534}
535
536/// Create an image with a custom [`WriteConfig`] (e.g. from a profile).
537pub fn write_directory_with_config(
538    root: &Path,
539    config: &WriteConfig,
540) -> Result<WriteArtifact, WriteError> {
541    let mut ctx = WriteContext::new();
542    ctx.categorizers_disabled = config.categorizers.is_empty();
543    ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
544    ctx.auto_turnover = config.turnover_threshold > 0;
545    ctx.collect_dict_samples = config.dictionaries.enabled;
546
547    write_directory_streaming(&mut ctx, root, config)?;
548    Ok(ctx.assemble())
549}
550
551/// Walk + compress with producer/consumer overlap (TODO.perf/15).
552///
553/// The tree walk runs on a scoped producer thread and forwards each
554/// deferred file to a bounded channel; rayon workers (via `par_bridge`)
555/// compress while the walk is still descending. For warm-cache trees
556/// with few files this is equivalent to the collect-then-dispatch
557/// shape; for huge or cold-cache trees it hides walk latency behind
558/// compression.
559///
560/// **Determinism:** results are re-sequenced into walk order before
561/// merging, and inode allocation / dir-node construction are
562/// untouched, so the emitted bytes are identical to
563/// `write_directory_body` for the same input.
564fn write_directory_streaming(
565    ctx: &mut WriteContext,
566    root: &Path,
567    config: &WriteConfig,
568) -> Result<(), WriteError> {
569    use rayon::prelude::*;
570
571    ctx.metadata_codec = config
572        .metadata_codec_id()
573        .unwrap_or(limnifs_core::codec::CODEC_BROTLI);
574
575    let chunker = ctx.chunker.clone();
576    let classifier = ctx.classifier;
577    let text_codec = config.text_codec_id().unwrap_or(0x04);
578    let binary_codec = config.binary_codec_id().unwrap_or(0x01);
579    let tunables = config.to_core_tunables();
580    let use_categorizers = !config.categorizers.is_empty();
581    let skip_chunking = config.skip_chunking;
582    let registry = config
583        .codec_registry()
584        .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
585    let tournament_codec_ids: Vec<u8> = config
586        .tournament
587        .codecs
588        .iter()
589        .filter_map(|n| registry.lookup_by_name(n))
590        .collect();
591    let tournament_spec = TournamentSpec {
592        codec_ids: tournament_codec_ids,
593        min_size: config.tournament.min_size_threshold as usize,
594        skip_for_binary: config.tournament.skip_for_binary,
595        short_circuit_permille: config.tournament.short_circuit_threshold,
596    };
597    // The producer thread owns `&mut ctx` for the duration of the
598    // walk, so the layer fast-path index travels as a clone.
599    let base_drop_index = ctx.base_drop_index.clone();
600    let inline_threshold = ctx.inline_threshold;
601
602    ctx.inline_threshold = config.defaults.inline_threshold as usize;
603
604    // Bounded so the walk back-pressures if compression falls behind;
605    // the buffer is large enough to keep every worker fed on bursty
606    // directory layouts.
607    const PIPELINE_CAPACITY: usize = 256;
608    let (tx, rx) = std::sync::mpsc::sync_channel::<PendingFile>(PIPELINE_CAPACITY);
609    ctx.pending_sink = Some(tx);
610
611    let (root_inode_number, mut results): (
612        u64,
613        Vec<(usize, PendingFile, Result<ChunkedFileResult, WriteError>)>,
614    ) = std::thread::scope(|scope| {
615        let producer = {
616            let ctx = &mut *ctx;
617            let root = root;
618            scope.spawn(move || {
619                let r = ctx.walk(root);
620                // Disconnect the channel so the consumer's iterator
621                // terminates; the sink stays None until the next
622                // streaming write resets it after the scope.
623                ctx.pending_sink = None;
624                r
625            })
626        };
627        // par_bridge does not preserve order; carry the arrival index
628        // and re-sequence before merging.
629        let results = rx
630            .into_iter()
631            .enumerate()
632            .par_bridge()
633            .map(|(i, pf)| {
634                let r = process_file(
635                    &pf,
636                    &chunker,
637                    classifier,
638                    text_codec,
639                    binary_codec,
640                    &tunables,
641                    use_categorizers,
642                    skip_chunking,
643                    &tournament_spec,
644                    base_drop_index.as_ref(),
645                    inline_threshold,
646                );
647                (i, pf, r)
648            })
649            .collect();
650        let joined = producer
651            .join()
652            .unwrap_or_else(|_| {
653                Err(WriteError::Io(std::io::Error::other(
654                    "walk thread panicked",
655                )))
656            })
657            .map(|n| (n, results));
658        // Scope can't `?` across borrows of `results`; return the
659        // outcome and propagate outside.
660        joined
661    })?;
662    ctx.pending_sink = None;
663    ctx.root_inode_number = root_inode_number;
664
665    results.sort_unstable_by_key(|(i, _, _)| *i);
666    // Fail on the lowest walk index first, matching the
667    // collect::<Result<Vec<_>, _>> abort semantics of the
668    // collect-then-dispatch shape.
669    for (_, pf, r) in results {
670        ctx.merge_chunked_file(&pf, r?);
671    }
672    ctx.train_and_apply_dictionary(&config.dictionaries);
673    Ok(())
674}
675
676/// One chunk of a file before dedup: (`drop_id`, `plaintext`, `compressed`, `codec`).
677///
678/// `compressed` is `Arc<[u8]>` so the cross-file compress cache can
679/// share bytes across hits with a refcount bump instead of a deep
680/// copy — dedup-heavy workloads (container layers, duplicate files)
681/// skip the allocation entirely.
682pub(crate) type RawDrop = ([u8; 32], Vec<u8>, std::sync::Arc<[u8]>, u8);
683/// Result of parallel file processing: the drop data (uncompressed,
684pub(crate) struct ChunkedFileResult {
685    drops: Vec<RawDrop>, // (id, plaintext, compressed, codec)
686    slices: Vec<PendingSlice>,
687}
688
689/// Resolved tournament configuration passed to per-chunk compression.
690///
691/// Built once at the top level from `WriteConfig::tournament` so each
692/// rayon worker reuses the same Vec rather than rebuilding it per
693/// file. Codecs are stored as numeric ids (looked up via
694/// `WriteConfig::codec_registry`) — `process_file` never sees the
695/// string form.
696struct TournamentSpec {
697    /// Codec ids to try, in declared order. `process_file` iterates
698    /// these and tracks the best compression. `CODEC_STORE` entries
699    /// are ignored (store is always the implicit fallback).
700    codec_ids: Vec<u8>,
701    /// Chunks below this many bytes get the preferred codec only
702    /// (no tournament) — the per-codec setup cost dominates at small
703    /// sizes and the ratio difference is negligible.
704    min_size: usize,
705    /// When true, binary-classified chunks skip the tournament and
706    /// use `binary_codec` directly. Matches the v0.1 behaviour where
707    /// binary chunks were never worth the tournament cost.
708    skip_for_binary: bool,
709    /// Short-circuit threshold in per-mille (0..=1000). 0 disables
710    /// short-circuit. Whenever a codec achieves compression ratio
711    /// ≤ threshold, the tournament accepts it and skips any slower
712    /// codecs later in the list.
713    short_circuit_permille: u32,
714}
715
716/// Compress a whole file as a single drop using the categorizer's
717/// chosen codec. Used when a file-level categorizer claims the file
718/// (FLAC for WAV, ricepp for FITS, FSST+Brotli for CSV). The drop's
719/// slice covers the whole file; no `FastCDC` chunking happens.
720///
721/// Codec parameters extracted by the categorizer (e.g. PCM sample
722/// format, FITS bitpix) are NOT prepended to the compressed bytes —
723/// the codec embeds its own params in its container format. The
724/// `LimniFS` drop record just stores `(codec_id, compressed_bytes)`
725/// and lets the codec own its param encoding. The categorizer's
726/// `codec_params` field is reserved for future use when a codec
727/// needs params NOT embedded in its container.
728fn process_whole_file_drop(
729    pf: &PendingFile,
730    data: &[u8],
731    cat: file_categorizer::Categorization,
732    tunables: &limnifs_core::codec::CodecTunables,
733) -> Result<ChunkedFileResult, WriteError> {
734    let _ = pf;
735    let drop_id = hash_section(data);
736    let file_len = u64::try_from(data.len()).unwrap_or(u64::MAX);
737
738    // Brotli first; if it fails (including a codec panic — the
739    // registry converts panics to Err), fall back to ZSTD, then to
740    // STORE. A broken encoder must degrade the drop's ratio, never
741    // the write itself.
742    let (mut best_codec, mut best_compressed): (u8, std::sync::Arc<[u8]>) =
743        match limnifs_core::codec::compress_with_tunables(
744            limnifs_core::codec::CODEC_BROTLI,
745            data,
746            tunables,
747        ) {
748            Ok(c) => (limnifs_core::codec::CODEC_BROTLI, c.into()),
749            Err(_) => match limnifs_core::codec::compress_with_tunables(
750                limnifs_core::codec::CODEC_ZSTD,
751                data,
752                tunables,
753            ) {
754                Ok(c) => (limnifs_core::codec::CODEC_ZSTD, c.into()),
755                Err(_) => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
756            },
757        };
758
759    // Short-circuit: if Brotli already achieves < 5% ratio, the input
760    // is highly compressible and ZSTD is unlikely to beat it by enough
761    // to justify the extra pass. Skip ZSTD on this fast path.
762    let brotli_ratio = best_compressed.len() as f64 / data.len() as f64;
763    if brotli_ratio > 0.05 {
764        if let Ok(zstd_c) = limnifs_core::codec::compress_with_tunables(
765            limnifs_core::codec::CODEC_ZSTD,
766            data,
767            tunables,
768        ) {
769            if zstd_c.len() < best_compressed.len() {
770                best_codec = limnifs_core::codec::CODEC_ZSTD;
771                best_compressed = zstd_c.into();
772            }
773        }
774    }
775
776    // Only try the specialized codec if the general-purpose ratio
777    // is poor (>15%) — otherwise the specialized codec is unlikely
778    // to help and may be very slow (FLAC, FSST). RICEPP is always
779    // tried because it can win big on FITS even when general-purpose
780    // ratios look acceptable.
781    let general_ratio = best_compressed.len() as f64 / data.len() as f64;
782    if general_ratio > 0.15 || cat.codec_id == limnifs_core::codec::CODEC_RICEPP {
783        // For FSST+Brotli, pass the already-computed Brotli baseline so
784        // the codec doesn't re-compress the plaintext with Brotli just
785        // for the comparison check.
786        let spec_result = if cat.codec_id == limnifs_core::codec::CODEC_FSST_BROTLI {
787            limnifs_core::codec::fsst_brotli::compress_with_baseline(data, Some(&best_compressed))
788        } else {
789            limnifs_core::codec::compress(cat.codec_id, data)
790        };
791        if let Ok(spec_c) = spec_result {
792            if spec_c.len() < best_compressed.len() {
793                best_codec = cat.codec_id;
794                best_compressed = spec_c.into();
795            }
796        }
797    }
798
799    Ok(ChunkedFileResult {
800        drops: vec![(drop_id, data.to_vec(), best_compressed, best_codec)],
801        slices: vec![PendingSlice {
802            drop_id,
803            file_byte_start: 0,
804            file_byte_end: file_len,
805        }],
806    })
807}
808
809/// Process a single file's contents (CPU-heavy work that runs in a
810/// rayon worker thread). Returns the unique chunks and slice map.
811///
812/// First consults the file-level categorizer registry. If a
813/// categorizer claims the file (e.g. FLAC for WAV, ricepp for FITS,
814/// Compress a single chunk via the configured tournament.
815///
816/// Iterates `tournament.codec_ids` in declared order, tracks the
817/// smallest output, and short-circuits when a codec achieves
818/// compression ratio ≤ `tournament.short_circuit_permille`.
819///
820/// Special cases:
821/// - **Binary chunks with `skip_for_binary`**: skip the tournament
822///   entirely and use `binary_codec`. Matches v0.1 behaviour.
823/// - **Chunks smaller than `min_size`**: use the class's preferred
824///   codec directly. Per-codec setup cost dominates here and the
825///   ratio difference is negligible at small sizes.
826/// - **Class unknown to writer** (Unknown / future classes): STORE.
827///
828/// The tournament never tries `CODEC_STORE` (id 0x00) — store is
829/// always the implicit fallback if every codec fails to compress.
830fn compress_chunk_with_tournament(
831    chunk: &[u8],
832    class: classifier::Class,
833    text_codec: u8,
834    binary_codec: u8,
835    tunables: &limnifs_core::codec::CodecTunables,
836    tournament: &TournamentSpec,
837) -> (u8, std::sync::Arc<[u8]>) {
838    use classifier::Class;
839
840    let preferred = match class {
841        Class::Binary => binary_codec,
842        Class::Text | Class::Code | Class::Sparse => text_codec,
843        _ => limnifs_core::codec::CODEC_STORE,
844    };
845
846    if preferred == limnifs_core::codec::CODEC_STORE {
847        return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
848    }
849    if class == Class::Binary && tournament.skip_for_binary {
850        return compress_chunk_one(chunk, preferred, tunables);
851    }
852    if chunk.len() < tournament.min_size {
853        return compress_chunk_one(chunk, preferred, tunables);
854    }
855
856    let mut best: Option<(u8, std::sync::Arc<[u8]>)> = None;
857    for &codec_id in &tournament.codec_ids {
858        if codec_id == limnifs_core::codec::CODEC_STORE {
859            continue;
860        }
861        let c = match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
862            Ok(c) => c,
863            Err(_) => continue,
864        };
865        if c.len() >= chunk.len() {
866            continue;
867        }
868        let ratio_permille = (c.len() as u64 * 1000 / chunk.len() as u64) as u32;
869        let is_best_so_far = best.as_ref().map_or(true, |(_, b)| c.len() < b.len());
870        if is_best_so_far {
871            best = Some((codec_id, c.into()));
872        }
873        if tournament.short_circuit_permille > 0
874            && ratio_permille <= tournament.short_circuit_permille
875        {
876            break;
877        }
878    }
879
880    best.unwrap_or_else(|| (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()))
881}
882
883/// Compress `chunk` with a single codec, falling back to STORE if
884/// the codec fails or expansion occurs.
885fn compress_chunk_one(
886    chunk: &[u8],
887    codec_id: u8,
888    tunables: &limnifs_core::codec::CodecTunables,
889) -> (u8, std::sync::Arc<[u8]>) {
890    if codec_id == limnifs_core::codec::CODEC_STORE {
891        return (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into());
892    }
893    match limnifs_core::codec::compress_with_tunables(codec_id, chunk, tunables) {
894        Ok(c) if c.len() < chunk.len() => (codec_id, c.into()),
895        _ => (limnifs_core::codec::CODEC_STORE, chunk.to_vec().into()),
896    }
897}
898
899/// FSST+Brotli for CSV), the whole file is compressed as a single
900/// drop with the categorizer's chosen codec + parameters. Otherwise
901/// falls through to `FastCDC` + per-chunk classify.
902fn process_file(
903    pf: &PendingFile,
904    chunker: &FastCDC,
905    classifier: classifier::Classifier,
906    text_codec: u8,
907    binary_codec: u8,
908    tunables: &limnifs_core::codec::CodecTunables,
909    use_categorizers: bool,
910    skip_chunking: bool,
911    tournament: &TournamentSpec,
912    base_drop_index: Option<&std::collections::HashSet<[u8; 32]>>,
913    inline_threshold: usize,
914) -> Result<ChunkedFileResult, WriteError> {
915    // For files above MMAP_READ_THRESHOLD, map them rather than reading
916    // into a Vec via std::fs::read. Pages load on demand from the
917    // kernel page cache. The chunk-path compressors see borrowed slices
918    // pointing into the mmap, so peak RSS stays at unique_chunks ×
919    // avg_chunk_size rather than the full file.
920    //
921    // SAFETY: LimniFS packs source trees that are immutable for the
922    // duration of the write. The file is opened read-only. External
923    // mutation during compression would be a serious bug in the
924    // caller's workflow (and would also break BLAKE3 determinism).
925    let file_len_estimate = std::fs::metadata(&pf.path)
926        .map(|m| m.len() as usize)
927        .unwrap_or(0);
928    let data: Vec<u8> = if file_len_estimate >= MMAP_READ_THRESHOLD {
929        let file = std::fs::File::open(&pf.path)?;
930        #[allow(unsafe_code)]
931        let mmap = unsafe { memmap2::Mmap::map(&file) }.map_err(WriteError::Io)?;
932        // Materialize only the pages the kernel has paged in. For
933        // chunked access this is roughly unique-chunk bytes; for
934        // skip_chunking we touch every page anyway.
935        Vec::from(&mmap[..])
936    } else {
937        std::fs::read(&pf.path)?
938    };
939    let file_len = data.len();
940
941    // Skip FastCDC chunking entirely; compress the whole file as
942    // one drop. Trades dedup granularity for create speed. Used by
943    // the max-write profile where speed >> ratio. The per-file LZ4
944    // compress at ~1 GB/s is faster than FastCDC hashing overhead
945    // for all but the largest multi-GB files (where rayon parallelism
946    // across chunks would help).
947    if skip_chunking && file_len > inline_threshold {
948        let drop_id = hash_section(&data);
949        let class = classifier.classify(&data);
950        let preferred_codec = match class {
951            classifier::Class::Binary => binary_codec,
952            _ => text_codec,
953        };
954        let (codec_id, compressed): (u8, std::sync::Arc<[u8]>) =
955            match limnifs_core::codec::compress_with_tunables(preferred_codec, &data, tunables) {
956                Ok(c) if c.len() < data.len() => (preferred_codec, c.into()),
957                _ => (limnifs_core::codec::CODEC_STORE, data.to_vec().into()),
958            };
959        return Ok(ChunkedFileResult {
960            drops: vec![(drop_id, data, compressed, codec_id)],
961            slices: vec![PendingSlice {
962                drop_id,
963                file_byte_start: 0,
964                file_byte_end: file_len as u64,
965            }],
966        });
967    }
968
969    if use_categorizers {
970        if let Some(cat) = file_categorizer::default_registry().categorize(&pf.path, &data) {
971            let needs_whole_file = matches!(
972                cat.codec_id,
973                limnifs_core::codec::CODEC_FLAC | limnifs_core::codec::CODEC_RICEPP
974            );
975            if needs_whole_file || file_len <= WHOLE_FILE_MAX_SIZE {
976                return process_whole_file_drop(pf, &data, cat, tunables);
977            }
978        }
979    }
980
981    let chunks = chunker.chunk_slice(&data);
982    let mut slices = Vec::with_capacity(chunks.len());
983    let mut file_offset: u64 = 0;
984    let mut seen_in_file: std::collections::HashSet<[u8; 32]> =
985        std::collections::HashSet::with_capacity(chunks.len());
986
987    // Phase 1: hash all chunks + build slices + filter duplicates (sequential).
988    // FastCDC boundaries must be deterministic, and BLAKE3 hashing is fast.
989    let mut unique_chunks: Vec<(&[u8], [u8; 32])> = Vec::with_capacity(chunks.len());
990    for chunk in &chunks {
991        let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
992        let drop_id = hash_section(chunk);
993        slices.push(PendingSlice {
994            drop_id,
995            file_byte_start: file_offset,
996            file_byte_end: file_offset + chunk_len,
997        });
998        file_offset += chunk_len;
999        if seen_in_file.insert(drop_id) {
1000            unique_chunks.push((chunk, drop_id));
1001        }
1002    }
1003
1004    // Phase 2: compress unique chunks in parallel across rayon workers.
1005    // This is the CPU-intensive step — parallelizing it gives N-core
1006    // speedup for large files with many chunks.
1007    //
1008    // Cross-file dedup: each rayon worker thread carries a thread-local
1009    // compress cache mapping DropId -> (codec_id, compressed_bytes).
1010    // When two files share a chunk (common in source trees, container
1011    // layers, tiny-files benchmarks), the second file hits the cache
1012    // and skips the compress pass entirely. Cache is bounded by entry
1013    // count; eviction is "stop inserting once full" — simple and
1014    // correct, misses are bounded by worker count.
1015    use rayon::prelude::*;
1016    thread_local! {
1017        static COMPRESS_CACHE: std::cell::RefCell<std::collections::HashMap<[u8; 32], (u8, std::sync::Arc<[u8]>)>> =
1018            std::cell::RefCell::new(std::collections::HashMap::new());
1019    }
1020    const COMPRESS_CACHE_MAX_ENTRIES: usize = 100_000;
1021    let drops: Vec<RawDrop> = unique_chunks
1022        .par_iter()
1023        .map(|(chunk, drop_id)| {
1024            // Layer fast-path: chunk already exists in the base image
1025            // → skip compress entirely.
1026            if let Some(base) = base_drop_index {
1027                if base.contains(drop_id) {
1028                    return (*drop_id, Vec::new(), Vec::new().into(), CODEC_REFERENCED);
1029                }
1030            }
1031            let class = classifier.classify(chunk);
1032            // Cache fast-path: identical chunk already compressed on
1033            // this worker → reuse bytes, skip tournament entirely.
1034            let cached = COMPRESS_CACHE.with(|c| {
1035                c.borrow()
1036                    .get(drop_id)
1037                    .map(|(cid, comp)| (*cid, comp.clone()))
1038            });
1039            let (codec_id, compressed) = if let Some(c) = cached {
1040                c
1041            } else {
1042                let new = compress_chunk_with_tournament(
1043                    chunk,
1044                    class,
1045                    text_codec,
1046                    binary_codec,
1047                    tunables,
1048                    tournament,
1049                );
1050                // Insert into the per-worker cache if there's room.
1051                COMPRESS_CACHE.with(|c| {
1052                    let mut cache = c.borrow_mut();
1053                    if cache.len() < COMPRESS_CACHE_MAX_ENTRIES {
1054                        // `new.1.clone()` here is an Arc refcount bump.
1055                        cache.insert(*drop_id, new.clone());
1056                    }
1057                });
1058                new
1059            };
1060            (*drop_id, chunk.to_vec(), compressed, codec_id)
1061        })
1062        .collect();
1063
1064    let _ = file_len;
1065    Ok(ChunkedFileResult { drops, slices })
1066}
1067
1068struct PendingDrop {
1069    id: [u8; 32],
1070    /// Original (decompressed) byte length. Stored as a u32 rather
1071    /// than keeping the plaintext Vec around, because the writer only
1072    /// needs the length when emitting the slab's drop record. Holding
1073    /// the full plaintext until slab assembly wastes memory
1074    /// proportional to image size on top of the compressed bytes.
1075    plaintext_len: u32,
1076    compressed: std::sync::Arc<[u8]>,
1077    codec: u8,
1078    /// Dictionary id (0xFF = NO_DICT). Populated during the dict
1079    /// re-compression pass for drops that were re-compressed with a
1080    /// trained dictionary.
1081    dict_id: u8,
1082    /// Retained plaintext, present only when `collect_dict_samples`
1083    /// is true (i.e. `WriteConfig::dictionaries.enabled`). Used by
1084    /// the post-parallel dict re-compression pass. Cleared after
1085    /// re-compression to free memory before slab assembly.
1086    plaintext: Option<Vec<u8>>,
1087}
1088
1089impl PendingDrop {
1090    /// The byte length stored in the slab's solid window. Equals
1091    /// `plaintext_len` for store codec, or the compressed size for
1092    /// LZ4 / Brotli / etc.
1093    fn len_in_window(&self) -> u32 {
1094        u32::try_from(self.compressed.len()).expect("compressed fits u32")
1095    }
1096
1097    /// The original (decompressed) byte length.
1098    fn plaintext_len_value(&self) -> u32 {
1099        self.plaintext_len
1100    }
1101
1102    /// Contribution to the slab's total byte length: 48 bytes of drop
1103    /// record (per spec §3.3) + the compressed payload.
1104    fn slab_footprint(&self) -> usize {
1105        48 + self.compressed.len()
1106    }
1107}
1108
1109/// One slice of a file backed by drops. Records which drop holds
1110/// this slice's bytes and which byte range of the original file
1111/// the slice covers. The slice always spans the entire drop (the
1112/// chunker never splits a drop across multiple slices).
1113struct PendingSlice {
1114    drop_id: [u8; 32],
1115    file_byte_start: u64,
1116    file_byte_end: u64,
1117}
1118
1119/// A file that needs chunking (> `INLINE_THRESHOLD`). Collected during
1120/// the sequential tree walk and processed in parallel by `rayon`.
1121#[derive(Clone)]
1122struct PendingFile {
1123    inode_number: u64,
1124    path: PathBuf,
1125    mtime_ns: u64,
1126    file_len: u64,
1127}
1128
1129struct PendingInode {
1130    number: u64,
1131    mode: u32,
1132    mtime_ns: u64,
1133    content: PendingContent,
1134}
1135
1136enum PendingContent {
1137    Inline(Vec<u8>),
1138    DropBacked {
1139        file_len: u64,
1140        slices: Vec<PendingSlice>,
1141    },
1142    Directory(Vec<(String, u64, u8)>),
1143}
1144
1145struct DirNode {
1146    entries: Vec<(String, u64, u8)>,
1147    bytes: Vec<u8>,
1148    hash: [u8; 32],
1149}
1150
1151struct WriteContext {
1152    next_inode: u64,
1153    inodes: Vec<PendingInode>,
1154    dir_nodes: Vec<DirNode>,
1155    drops: Vec<PendingDrop>,
1156    drop_index: HashSet<[u8; 32]>,
1157    pending_files: Vec<PendingFile>,
1158    file_count: usize,
1159    dir_count: usize,
1160    root_inode_number: u64,
1161    chunker: FastCDC,
1162    classifier: classifier::Classifier,
1163    shared_inline_map: HashMap<[u8; 32], usize>,
1164    shared_inline_table: Vec<Vec<u8>>,
1165    /// Profile name for ProfileDescriptor emission (None = omit section).
1166    profile_name: Option<String>,
1167    /// Metadata blob codec (defaults to Brotli; can be overridden via
1168    /// `WriteConfig::defaults::metadata_codec`). Used by `assemble`.
1169    metadata_codec: u8,
1170    /// Whether categorizers were disabled by the profile.
1171    categorizers_disabled: bool,
1172    /// Whether this is a RW image.
1173    rw_mode: bool,
1174    /// Whether auto-turnover is enabled.
1175    auto_turnover: bool,
1176    /// Whether to collect plaintext samples for ZSTD dictionary
1177    /// training. Set when `WriteConfig::dictionaries.enabled`.
1178    collect_dict_samples: bool,
1179    /// Plaintext samples collected from ZSTD-compressed drops, for
1180    /// training one dictionary after the parallel compress phase.
1181    /// Capped at `MAX_DICT_SAMPLES` to bound memory. Keyed by
1182    /// classifier class — text/code/sparse share a "text" dict,
1183    /// binary gets its own. Compressed/media/incompressible classes
1184    /// don't use ZSTD so their samples aren't collected.
1185    dict_samples_by_class: HashMap<crate::classifier::Class, Vec<Vec<u8>>>,
1186    /// Trained dictionaries keyed by class. Populated by
1187    /// `train_and_apply_dictionary` after the parallel phase. Emitted
1188    /// in the manifest's `dictionary_section` with one entry per
1189    /// class that accumulated enough samples.
1190    trained_dicts_by_class: HashMap<crate::classifier::Class, crate::dictionary::TrainedDictionary>,
1191    /// Drop IDs known to exist in a base image (set when this write
1192    /// is producing a layer via `write_layer`). When a chunk's DropId
1193    /// is in this set, the writer skips compression and emits no slab
1194    /// bytes — the drop is resolved via the overlay chain at read
1195    /// time. `None` for standalone (non-layer) writes.
1196    base_drop_index: Option<HashSet<[u8; 32]>>,
1197    /// The base image's `ManifestRoot` (set when `base_drop_index`
1198    /// is `Some`). Emitted in the manifest's `delta_linkage` section
1199    /// so readers know which image provides the referenced drops.
1200    /// `None` for standalone writes.
1201    base_root: Option<[u8; 32]>,
1202    /// Inline-data cutoff from `WriteConfig::defaults.inline_threshold`.
1203    /// Files at or below this size are stored inline in the metadata
1204    /// blob instead of being chunked into slabs. Set from the profile
1205    /// before `walk`; defaults to the historical constant.
1206    inline_threshold: usize,
1207    /// Streaming-walk sink (TODO.perf/15). When set, `walk` forwards
1208    /// deferred files to the channel instead of buffering them in
1209    /// `pending_files`, so compression starts while the walk is still
1210    /// descending the tree. `None` keeps the collect-then-dispatch
1211    /// shape (used by the non-streaming entry points).
1212    pending_sink: Option<std::sync::mpsc::SyncSender<PendingFile>>,
1213}
1214
1215impl WriteContext {
1216    /// Cap on collected plaintext samples. Enough signal for the
1217    /// FrequencyTrainer without unbounded memory growth on huge inputs.
1218    const MAX_DICT_SAMPLES: usize = 1000;
1219
1220    fn new() -> Self {
1221        Self {
1222            next_inode: 1,
1223            inodes: Vec::new(),
1224            dir_nodes: Vec::new(),
1225            drops: Vec::new(),
1226            drop_index: HashSet::new(),
1227            pending_files: Vec::new(),
1228            file_count: 0,
1229            dir_count: 0,
1230            root_inode_number: 0,
1231            chunker: FastCDC::default(),
1232            classifier: classifier::Classifier,
1233            shared_inline_map: HashMap::new(),
1234            shared_inline_table: Vec::new(),
1235            profile_name: None,
1236            metadata_codec: limnifs_core::codec::CODEC_BROTLI,
1237            categorizers_disabled: false,
1238            rw_mode: false,
1239            auto_turnover: false,
1240            collect_dict_samples: false,
1241            dict_samples_by_class: HashMap::new(),
1242            trained_dicts_by_class: HashMap::new(),
1243            base_drop_index: None,
1244            base_root: None,
1245            pending_sink: None,
1246            inline_threshold: INLINE_THRESHOLD,
1247        }
1248    }
1249
1250    fn alloc_inode(&mut self) -> u64 {
1251        let n = self.next_inode;
1252        self.next_inode += 1;
1253        n
1254    }
1255
1256    /// Scan all inline-data inodes and build a dedup table. Only
1257    /// content appearing in > 1 inode is deduplicated; unique inline
1258    /// data stays inline (no overhead change).
1259    fn build_shared_inline_table(&mut self) {
1260        let mut counts: HashMap<[u8; 32], usize> = HashMap::new();
1261        for inode in &self.inodes {
1262            if let PendingContent::Inline(data) = &inode.content {
1263                let h = hash_section(data);
1264                *counts.entry(h).or_default() += 1;
1265            }
1266        }
1267        // Only dedup content that appears more than once.
1268        for inode in &self.inodes {
1269            if let PendingContent::Inline(data) = &inode.content {
1270                let h = hash_section(data);
1271                if counts.get(&h).copied().unwrap_or(0) > 1
1272                    && !self.shared_inline_map.contains_key(&h)
1273                {
1274                    let idx = self.shared_inline_table.len();
1275                    self.shared_inline_table.push(data.clone());
1276                    self.shared_inline_map.insert(h, idx);
1277                }
1278            }
1279        }
1280    }
1281
1282    /// Merge a parallel-processed chunked file's results into the
1283    /// context. Dedup: only new `DropId`s get added to the drops list.
1284    fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
1285        for (drop_id, plaintext, compressed, codec) in result.drops {
1286            if self.drop_index.insert(drop_id) {
1287                // When dictionary training is enabled, classify the
1288                // plaintext and retain it for per-class dictionary
1289                // training. Text-like classes share a "text" dict;
1290                // Binary gets its own. Compressed/media/incompressible
1291                // don't use ZSTD so we skip them entirely.
1292                let retain_plaintext =
1293                    self.collect_dict_samples && codec == limnifs_core::codec::CODEC_ZSTD;
1294                if retain_plaintext {
1295                    let total: usize = self.dict_samples_by_class.values().map(Vec::len).sum();
1296                    if total < Self::MAX_DICT_SAMPLES {
1297                        let class = self.classifier.classify(&plaintext);
1298                        self.dict_samples_by_class
1299                            .entry(class)
1300                            .or_default()
1301                            .push(plaintext.clone());
1302                    }
1303                }
1304                self.drops.push(PendingDrop {
1305                    id: drop_id,
1306                    plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1307                    compressed,
1308                    codec,
1309                    dict_id: limnifs_core::drop_record::NO_DICT,
1310                    plaintext: if retain_plaintext {
1311                        Some(plaintext)
1312                    } else {
1313                        None
1314                    },
1315                });
1316            }
1317        }
1318        self.inodes.push(PendingInode {
1319            number: pf.inode_number,
1320            mode: 0o100_644,
1321            mtime_ns: pf.mtime_ns,
1322            content: PendingContent::DropBacked {
1323                file_len: pf.file_len,
1324                slices: result.slices,
1325            },
1326        });
1327    }
1328
1329    /// Apply the seine classifier to a chunk and compress it if the
1330    /// class is compressible. Text, Code, and Binary drops get LZ4;
1331    /// Compressed, Media, and Sparse drops stay as store (re-compressing
1332    /// already-compressed data wastes CPU for no gain).
1333    /// Apply the seine classifier to a chunk and compress it if the
1334    /// class is compressible. Text, Code, and Binary drops get LZ4;
1335    /// Compressed, Media, and Sparse drops stay as store.
1336    ///
1337    /// Kept for API compatibility; the parallel writer uses
1338    /// [`process_file`] which inlines this logic.
1339    #[allow(dead_code)]
1340    fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
1341        let class = self.classifier.classify(plaintext);
1342        let (codec, compressed): (u8, std::sync::Arc<[u8]>) = match class {
1343            classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
1344                let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
1345                (limnifs_core::codec::CODEC_LZ4, c.into())
1346            }
1347            _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec().into()),
1348        };
1349        PendingDrop {
1350            id: drop_id,
1351            plaintext_len: u32::try_from(plaintext.len()).unwrap_or(u32::MAX),
1352            compressed,
1353            codec,
1354            dict_id: limnifs_core::drop_record::NO_DICT,
1355            plaintext: None,
1356        }
1357    }
1358
1359    fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
1360        let meta = std::fs::symlink_metadata(path)?;
1361        let file_type = meta.file_type();
1362        let mtime_ns = meta
1363            .modified()
1364            .ok()
1365            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1366            .map_or(0u128, |d| d.as_nanos());
1367        let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
1368
1369        if file_type.is_dir() {
1370            self.dir_count += 1;
1371            let inode_number = self.alloc_inode();
1372            let mut entries: Vec<(String, u64, u8)> = Vec::new();
1373
1374            for entry in std::fs::read_dir(path)? {
1375                let entry = entry?;
1376                let name = entry.file_name().to_string_lossy().into_owned();
1377                let child_path = entry.path();
1378                let child_inode = self.walk(&child_path)?;
1379                let child_meta = entry.metadata()?;
1380                let entry_type = if child_meta.is_dir() { 0x02 } else { 0x01 };
1381                entries.push((name, child_inode, entry_type));
1382            }
1383
1384            entries.sort_by(|a, b| a.0.cmp(&b.0));
1385            let dir_node = encode_dir_node(&entries);
1386            self.dir_nodes.push(dir_node);
1387            self.inodes.push(PendingInode {
1388                number: inode_number,
1389                mode: 0o040_755,
1390                mtime_ns,
1391                content: PendingContent::Directory(entries),
1392            });
1393            Ok(inode_number)
1394        } else if file_type.is_file() {
1395            self.file_count += 1;
1396            let inode_number = self.alloc_inode();
1397            let file_len = meta.len();
1398
1399            if file_len <= u64::try_from(self.inline_threshold).unwrap_or(u64::MAX) {
1400                let data = std::fs::read(path)?;
1401                self.inodes.push(PendingInode {
1402                    number: inode_number,
1403                    mode: 0o100_644,
1404                    mtime_ns,
1405                    content: PendingContent::Inline(data),
1406                });
1407            } else {
1408                // Defer to parallel processing — collect the file info.
1409                let pf = PendingFile {
1410                    inode_number,
1411                    path: path.to_path_buf(),
1412                    mtime_ns,
1413                    file_len,
1414                };
1415                if let Some(sink) = &self.pending_sink {
1416                    // Streaming mode: hand the file to the compress
1417                    // workers immediately; the bounded channel
1418                    // back-pressures if they fall behind. A send
1419                    // failure means the receiver is gone (a worker
1420                    // hit an unrecoverable error) — abort the walk.
1421                    sink.send(pf).map_err(|_| {
1422                        WriteError::Io(std::io::Error::other("walk: compress pipeline shut down"))
1423                    })?;
1424                } else {
1425                    self.pending_files.push(pf);
1426                }
1427            }
1428            Ok(inode_number)
1429        } else {
1430            Err(WriteError::Io(std::io::Error::new(
1431                std::io::ErrorKind::Unsupported,
1432                format!("unsupported file type: {}", path.display()),
1433            )))
1434        }
1435    }
1436
1437    /// After the parallel compress phase: train one ZSTD dictionary
1438    /// per classifier class with enough samples, then re-compress
1439    /// each ZSTD drop with the dictionary for its class. Keep
1440    /// whichever representation is smaller. Drops that get
1441    /// re-compressed carry the class's `dict_id` in their drop
1442    /// record; the dictionaries are emitted in the manifest's
1443    /// `dictionary_section`.
1444    ///
1445    /// Text/Code/Sparse classes collapse into a single "text" dict
1446    /// (id 0). Binary gets id 1. Other classes don't accumulate
1447    /// samples because their drops aren't ZSTD-compressed.
1448    ///
1449    /// Clears the retained plaintext on every drop to free memory
1450    /// before slab assembly.
1451    fn train_and_apply_dictionary(&mut self, dictionaries: &crate::config::DictionaryConfig) {
1452        let cleanup = |ctx: &mut Self| {
1453            for d in &mut ctx.drops {
1454                d.plaintext = None;
1455            }
1456            ctx.dict_samples_by_class.clear();
1457        };
1458
1459        if !dictionaries.enabled {
1460            cleanup(self);
1461            return;
1462        }
1463
1464        let target = usize::try_from(dictionaries.max_dict_size).unwrap_or(65_536);
1465        let min_class = usize::try_from(dictionaries.min_class_size).unwrap_or(0);
1466
1467        // Allocate dict ids: 0 = text (Text/Code/Sparse), 1 = binary.
1468        // Compressed/Media/Incompressible don't accumulate samples
1469        // (their drops aren't ZSTD) so we don't train for them.
1470        let text_classes = [
1471            crate::classifier::Class::Text,
1472            crate::classifier::Class::Code,
1473            crate::classifier::Class::Sparse,
1474        ];
1475        let binary_classes = [crate::classifier::Class::Binary];
1476
1477        // Train text dict from text-like classes' samples combined.
1478        let text_samples: Vec<&[u8]> = text_classes
1479            .iter()
1480            .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1481            .map(Vec::as_slice)
1482            .collect();
1483        let trainer = crate::dictionary::TrainerKind::from_config_str(&dictionaries.trainer);
1484        if text_samples.len() >= min_class {
1485            if let Some(dict) =
1486                crate::dictionary::train_zstd_with_trainer(0, &text_samples, target, trainer)
1487            {
1488                self.trained_dicts_by_class
1489                    .insert(crate::classifier::Class::Text, dict);
1490            }
1491        }
1492        let binary_samples: Vec<&[u8]> = binary_classes
1493            .iter()
1494            .flat_map(|c| self.dict_samples_by_class.get(c).into_iter().flatten())
1495            .map(Vec::as_slice)
1496            .collect();
1497        if binary_samples.len() >= min_class {
1498            if let Some(dict) =
1499                crate::dictionary::train_zstd_with_trainer(1, &binary_samples, target, trainer)
1500            {
1501                self.trained_dicts_by_class
1502                    .insert(crate::classifier::Class::Binary, dict);
1503            }
1504        }
1505
1506        // Re-compress each ZSTD drop with the dict for its class.
1507        // Keep the smaller representation.
1508        for d in self.drops.iter_mut() {
1509            if d.codec != limnifs_core::codec::CODEC_ZSTD {
1510                continue;
1511            }
1512            let Some(plaintext) = d.plaintext.clone() else {
1513                continue;
1514            };
1515            let class = self.classifier.classify(&plaintext);
1516            let dict_class = if text_classes.contains(&class) {
1517                crate::classifier::Class::Text
1518            } else if binary_classes.contains(&class) {
1519                crate::classifier::Class::Binary
1520            } else {
1521                continue;
1522            };
1523            let Some(dict) = self.trained_dicts_by_class.get(&dict_class) else {
1524                continue;
1525            };
1526            let Ok(dict_compressed) = dict.compress(&plaintext) else {
1527                continue;
1528            };
1529            if dict_compressed.len() < d.compressed.len() {
1530                d.compressed = dict_compressed.into();
1531                d.dict_id = dict.id;
1532            }
1533        }
1534
1535        cleanup(self);
1536    }
1537
1538    /// Env-gated phase timer for assemble profiling (TODO.perf/16).
1539    fn trace_phase(label: &str, start: std::time::Instant) {
1540        if std::env::var_os("LIMNIFS_TRACE_ASSEMBLE").is_some() {
1541            eprintln!("[assemble] {label}: {:?}", start.elapsed());
1542        }
1543    }
1544
1545    fn assemble(mut self) -> WriteArtifact {
1546        let t_assemble = std::time::Instant::now();
1547        let inode_count = self.inodes.len();
1548        let dir_count = self.dir_count;
1549        let drop_count = self.drops.len();
1550
1551        // Partition drops into slabs. Each slab's total byte length
1552        // (header + drop records + solid window) must stay under
1553        // MAX_SLAB_TOTAL_BYTES so the reader's 64 MiB ceiling is never
1554        // exceeded. A single drop larger than the budget gets its own
1555        // slab (we cannot split a drop).
1556        let t = std::time::Instant::now();
1557        let slabs = pack_slabs(&self.drops);
1558        Self::trace_phase("pack_slabs", t);
1559
1560        // Build the shared inline table: deduplicate inline data that
1561        // appears in more than one inode. For N files with identical
1562        // small content, store once and reference by index.
1563        let t = std::time::Instant::now();
1564        self.build_shared_inline_table();
1565        Self::trace_phase("shared_inline_table", t);
1566
1567        let mut metadata_blob = Vec::new();
1568        metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
1569        for inode in &self.inodes {
1570            self.encode_inode(&mut metadata_blob, inode);
1571        }
1572        metadata_blob
1573            .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
1574        for node in &self.dir_nodes {
1575            metadata_blob.extend_from_slice(&node.bytes);
1576        }
1577        // Shared inline table (only present if any dedup occurred).
1578        // Reader checks for remaining bytes after dir_nodes.
1579        if !self.shared_inline_table.is_empty() {
1580            metadata_blob.extend_from_slice(
1581                &u32::try_from(self.shared_inline_table.len())
1582                    .unwrap()
1583                    .to_le_bytes(),
1584            );
1585            for entry in &self.shared_inline_table {
1586                let len = u32::try_from(entry.len()).expect("shared entry fits u32");
1587                metadata_blob.extend_from_slice(&len.to_le_bytes());
1588                metadata_blob.extend_from_slice(entry);
1589            }
1590        }
1591
1592        Self::trace_phase("metadata_encode", t);
1593        // Compress the metadata blob. Metadata is highly compressible
1594        // (sequential inode numbers, repeated modes, natural-language
1595        // file names) — even low Brotli quality yields 4–8× on source
1596        // trees. Pick quality by size: small blobs cost nothing to
1597        // compress at q5; large blobs (e.g. 50 K-inode trees) would
1598        // dominate create time at q5, so step down to q2.
1599        let uncompressed_len =
1600            u32::try_from(metadata_blob.len()).expect("metadata blob length fits u32");
1601        let t = std::time::Instant::now();
1602        let metadata_hash = hash_section(&metadata_blob);
1603        let metadata_codec = self.metadata_codec;
1604        let metadata_quality = if metadata_blob.len() > METADATA_LARGE_BLOB_THRESHOLD {
1605            METADATA_LARGE_BLOB_QUALITY
1606        } else {
1607            METADATA_SMALL_BLOB_QUALITY
1608        };
1609        let compressed_blob = if metadata_codec == limnifs_core::codec::CODEC_BROTLI {
1610            limnifs_core::codec::compress_brotli_with_quality(&metadata_blob, metadata_quality)
1611                .unwrap_or_else(|_| metadata_blob.clone())
1612        } else {
1613            limnifs_core::codec::compress(metadata_codec, &metadata_blob)
1614                .unwrap_or_else(|_| metadata_blob.clone())
1615        };
1616        Self::trace_phase("metadata_compress", t);
1617        let (on_wire_codec, on_wire_blob) = if compressed_blob.len() < metadata_blob.len() {
1618            (metadata_codec, compressed_blob)
1619        } else {
1620            (limnifs_core::codec::CODEC_STORE, metadata_blob.clone())
1621        };
1622
1623        // Decide inline vs sidecar based on the COMPRESSED length. The
1624        // reader's inline ceiling is 1 MiB; we externalise when the
1625        // compressed form would exceed 768 KiB.
1626        let (metadata_sidecar, inline_data, metadata_locator_count) =
1627            if on_wire_blob.len() > METADATA_EXTERNALIZE_THRESHOLD {
1628                let locator = "file:metadata.bin".to_owned();
1629                let sidecar = MetadataSidecar {
1630                    bytes: on_wire_blob.clone(),
1631                    locator,
1632                };
1633                (Some(sidecar), None, 1u32)
1634            } else {
1635                (None, Some(on_wire_blob.clone()), 0u32)
1636            };
1637
1638        let mut manifest = Vec::new();
1639
1640        let header_start = manifest.len();
1641        manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
1642        let header_end = manifest.len();
1643
1644        let flags_start = manifest.len();
1645        manifest.push(FEATURE_FLAGS_SECTION_VERSION);
1646        manifest.extend_from_slice(&0u32.to_le_bytes());
1647        let flags_end = manifest.len();
1648
1649        // metadata_reference v2: hash + uncompressed_len + codec +
1650        // locator_count + locators + inline_data_len + inline_data.
1651        let meta_ref_start = manifest.len();
1652        manifest.push(METADATA_REFERENCE_SECTION_VERSION_2);
1653        manifest.extend_from_slice(&metadata_hash);
1654        manifest.extend_from_slice(&uncompressed_len.to_le_bytes());
1655        manifest.push(on_wire_codec);
1656        manifest.extend_from_slice(&metadata_locator_count.to_le_bytes());
1657        if let Some(sidecar) = &metadata_sidecar {
1658            let loc_bytes = sidecar.locator.as_bytes();
1659            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1660            manifest.extend_from_slice(&loc_len.to_le_bytes());
1661            manifest.extend_from_slice(loc_bytes);
1662        }
1663        match &inline_data {
1664            Some(blob) => {
1665                let inline_len = u32::try_from(blob.len()).expect("metadata fits u32");
1666                manifest.extend_from_slice(&inline_len.to_le_bytes());
1667                manifest.extend_from_slice(blob);
1668            }
1669            None => {
1670                manifest.extend_from_slice(&0u32.to_le_bytes());
1671            }
1672        }
1673        let meta_ref_end = manifest.len();
1674
1675        let slab_index_start = manifest.len();
1676        manifest.push(SLAB_INDEX_SECTION_VERSION);
1677        manifest.extend_from_slice(&u32::try_from(slabs.len()).unwrap().to_le_bytes());
1678        for slab in &slabs {
1679            manifest.extend_from_slice(&slab.id.to_bytes());
1680            manifest.extend_from_slice(&1u32.to_le_bytes());
1681            let loc_bytes = slab.locator.as_bytes();
1682            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
1683            manifest.extend_from_slice(&loc_len.to_le_bytes());
1684            manifest.extend_from_slice(loc_bytes);
1685        }
1686        let slab_index_end = manifest.len();
1687
1688        let history_start = manifest.len();
1689        manifest.push(HISTORY_SECTION_VERSION);
1690        manifest.extend_from_slice(&1u32.to_le_bytes());
1691        manifest.push(0x01);
1692        manifest.extend_from_slice(&0u64.to_le_bytes());
1693        manifest.extend_from_slice(&0u32.to_le_bytes());
1694        manifest.extend_from_slice(&0u32.to_le_bytes());
1695        let history_end = manifest.len();
1696
1697        // ProfileDescriptor section (optional — appended after history).
1698        // Records which overhead layers were active so any reader can
1699        // handle the image correctly. Only emitted if a profile name
1700        // was set.
1701        let profile_desc_start = manifest.len();
1702        if let Some(ref name) = self.profile_name {
1703            let desc = limnifs_core::profile_descriptor::ProfileDescriptor {
1704                version: limnifs_core::profile_descriptor::PROFILE_DESCRIPTOR_SECTION_VERSION,
1705                profile_name: Some(name.clone()),
1706                blake3_hashing: true,
1707                cross_file_dedup: true,
1708                content_classification: !self.categorizers_disabled,
1709                integrity_verify: true,
1710                read_write: self.rw_mode,
1711                auto_turnover: self.auto_turnover,
1712            };
1713            limnifs_core::profile_descriptor::encode_profile_descriptor(&desc, &mut manifest);
1714        }
1715        let profile_desc_end = manifest.len();
1716
1717        // DictionarySection (optional — emitted when dictionaries
1718        // were trained during the post-parallel pass). Contains the
1719        // `(codec_id, class_id, data)` triples referenced by drop
1720        // records' `dict_id` field. One entry per class with enough
1721        // samples to train: text (id 0), binary (id 1).
1722        if !self.trained_dicts_by_class.is_empty() {
1723            let dicts: Vec<_> = self
1724                .trained_dicts_by_class
1725                .values()
1726                .map(|d| limnifs_core::dictionary_section::Dictionary {
1727                    codec_id: d.codec,
1728                    class_id: d.id,
1729                    data: d.content.clone(),
1730                })
1731                .collect();
1732            let section = limnifs_core::dictionary_section::DictionarySection {
1733                version: limnifs_core::dictionary_section::DICTIONARY_SECTION_VERSION,
1734                dicts,
1735            };
1736            limnifs_core::dictionary_section::encode_dictionary_section(&section, &mut manifest);
1737        }
1738
1739        let dictionary_end = manifest.len();
1740
1741        // DeltaLinkage section (optional — emitted only by `write_layer`).
1742        // Carries `base_root` so readers can resolve referenced drops via
1743        // the overlay chain. The section's hash feeds the
1744        // `SectionHashes::delta_linkage` slot, which is empty for
1745        // standalone images.
1746        let delta_linkage_hash = if let Some(base_root) = self.base_root {
1747            let delta_start = manifest.len();
1748            // Inline-encode the delta linkage section (version 1):
1749            // [version:u8][base_root:32][tree_op_count:u32=0]. Tree ops
1750            // are empty because the metadata blob carries the full new
1751            // tree — readers see `base_root` and know to walk the
1752            // overlay chain for any DropId not present in local slabs.
1753            manifest.push(limnifs_core::delta_linkage::DELTA_LINKAGE_SECTION_VERSION);
1754            manifest.extend_from_slice(&base_root);
1755            manifest.extend_from_slice(&0u32.to_le_bytes());
1756            hash_section(&manifest[delta_start..])
1757        } else {
1758            hash_empty_section()
1759        };
1760        let _ = dictionary_end;
1761
1762        let hashes = SectionHashes {
1763            metadata: metadata_hash,
1764            format_header: hash_section(&manifest[header_start..header_end]),
1765            feature_flags: hash_section(&manifest[flags_start..flags_end]),
1766            metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
1767            slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
1768            crypto_params: hash_empty_section(),
1769            ec_params: hash_empty_section(),
1770            dms_policy: hash_empty_section(),
1771            delta_linkage: delta_linkage_hash,
1772            history: hash_section(&manifest[history_start..history_end]),
1773            // The Merkle construction doesn't currently include a
1774            // dictionary_section hash slot. Treat the section as
1775            // crypto-params-equivalent (covered by the metadata hash)
1776            // for now; documenting this with an explicit comment so
1777            // the next reader knows where to add a hash slot if the
1778            // spec grows one.
1779            // TODO: spec section-hash for dictionary_section.
1780            // For now use hash_empty_section() so the structure compiles;
1781            // a future spec rev will add a dedicated slot.
1782            // (Section bytes are still content-addressed via the
1783            // slab_index hash and the manifest's Merkle root.)
1784        };
1785        let merkle_root = compute_merkle_root(&hashes);
1786
1787        WriteArtifact {
1788            bytes: manifest,
1789            merkle_root,
1790            slabs,
1791            metadata_sidecar,
1792            inode_count,
1793            file_count: self.file_count,
1794            dir_count,
1795            drop_count,
1796            root_inode_number: self.root_inode_number,
1797        }
1798    }
1799
1800    fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
1801        out.extend_from_slice(&inode.number.to_le_bytes());
1802        out.extend_from_slice(&inode.mode.to_le_bytes());
1803        out.extend_from_slice(&0u32.to_le_bytes());
1804        out.extend_from_slice(&0u32.to_le_bytes());
1805        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1806        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
1807        out.extend_from_slice(&1u32.to_le_bytes());
1808        match &inode.content {
1809            PendingContent::Inline(data) => {
1810                let h = hash_section(data);
1811                if let Some(&idx) = self.shared_inline_map.get(&h) {
1812                    // Deduplicated: emit shared-inline flag + index.
1813                    out.push(INODE_FLAG_INLINE_DATA | INODE_FLAG_SHARED_INLINE);
1814                    out.extend_from_slice(&(idx as u32).to_le_bytes());
1815                } else {
1816                    out.push(INODE_FLAG_INLINE_DATA);
1817                    let len = u32::try_from(data.len()).expect("data fits u32");
1818                    out.extend_from_slice(&len.to_le_bytes());
1819                    out.extend_from_slice(data);
1820                }
1821            }
1822            PendingContent::DropBacked { file_len, slices } => {
1823                out.push(0x00);
1824                let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
1825                out.extend_from_slice(&slice_count.to_le_bytes());
1826                for slice in slices {
1827                    out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
1828                    out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
1829                    out.extend_from_slice(&slice.drop_id);
1830                    // drop_byte_start = 0 (slice covers the whole drop)
1831                    out.extend_from_slice(&0u32.to_le_bytes());
1832                    // drop_byte_len = the byte length of this slice in the
1833                    // drop's decompressed plaintext. Each slice maps to
1834                    // exactly one chunk, so this equals the file range.
1835                    let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
1836                        .expect("slice range fits u32");
1837                    out.extend_from_slice(&drop_byte_len.to_le_bytes());
1838                }
1839                let _ = file_len;
1840            }
1841            PendingContent::Directory(entries) => {
1842                out.push(0x00);
1843                let node = self
1844                    .dir_nodes
1845                    .iter()
1846                    .find(|n| n.entries == *entries)
1847                    .expect("directory node must exist");
1848                out.extend_from_slice(&node.hash);
1849            }
1850        }
1851    }
1852}
1853
1854fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
1855    let mut bytes = Vec::new();
1856    bytes.push(1u8);
1857    let count = u32::try_from(entries.len()).expect("entry count fits u32");
1858    bytes.extend_from_slice(&count.to_le_bytes());
1859    for (name, inode_number, entry_type) in entries {
1860        let name_bytes = name.as_bytes();
1861        let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
1862        bytes.extend_from_slice(&name_len.to_le_bytes());
1863        bytes.extend_from_slice(name_bytes);
1864        bytes.extend_from_slice(&inode_number.to_le_bytes());
1865        bytes.push(*entry_type);
1866    }
1867    let hash = hash_section(&bytes);
1868    DirNode {
1869        entries: entries.to_vec(),
1870        bytes,
1871        hash,
1872    }
1873}
1874
1875/// Partition `drops` into one or more slabs, each fitting under
1876/// [`MAX_SLAB_TOTAL_BYTES`]. Slab ordinal starts at 0 and increments.
1877/// Each slab's `SlabId` hash is `BLAKE3(slab_content)` so identical
1878/// content yields identical slab IDs (deterministic).
1879///
1880/// A single drop larger than `MAX_SLAB_TOTAL_BYTES - SLAB_HEADER_LEN`
1881/// still produces one slab — we cannot split a drop, and the spec
1882/// permits the reader to raise its ceiling for that case.
1883fn pack_slabs(drops: &[PendingDrop]) -> Vec<SlabArtifact> {
1884    // Filter out CODEC_REFERENCED sentinel drops — they exist in the
1885    // writer's in-memory state for inode/slice bookkeeping but are
1886    // resolved via the overlay chain at read time, never stored in
1887    // this image's slabs.
1888    let local_drops: Vec<&PendingDrop> = drops
1889        .iter()
1890        .filter(|d| d.codec != limnifs_core::codec::CODEC_REFERENCED)
1891        .collect();
1892    if local_drops.is_empty() {
1893        return Vec::new();
1894    }
1895
1896    let max_content = MAX_SLAB_TOTAL_BYTES.saturating_sub(SLAB_HEADER_LEN);
1897
1898    // Phase 1 (sequential): scan drops and partition into slab groups.
1899    // Each slab group is the list of drops that will share a slab window.
1900    // The grouping depends on per-drop compressed size + record overhead
1901    // — this is a sequential scan with a running size budget.
1902    let mut slab_groups: Vec<Vec<&PendingDrop>> = Vec::new();
1903    let mut current: Vec<&PendingDrop> = Vec::new();
1904    let mut current_size: usize = 0;
1905
1906    for drop in &local_drops {
1907        let footprint = drop.slab_footprint();
1908        if !current.is_empty() && current_size + footprint > max_content {
1909            slab_groups.push(std::mem::take(&mut current));
1910            current_size = 0;
1911        }
1912        current.push(*drop);
1913        current_size += footprint;
1914    }
1915    if !current.is_empty() {
1916        slab_groups.push(current);
1917    }
1918
1919    // Phase 2 (parallel): encode each slab independently. Slab encoding
1920    // has no cross-slab state — each slab's `offset_in_window` starts
1921    // at 0, its hash is over its own content, its ordinal is its index
1922    // in the slab_groups vector. Rayon parallelises across slabs;
1923    // large images with many slabs get N-core speedup on this phase.
1924    use rayon::prelude::*;
1925    slab_groups
1926        .par_iter()
1927        .enumerate()
1928        .map(|(ordinal, group)| {
1929            let ordinal_u64 = u64::try_from(ordinal).expect("slab count fits u64");
1930            encode_slab(ordinal_u64, group)
1931        })
1932        .collect()
1933}
1934
1935/// Encode a single slab from a non-empty slice of drops. Per-slab
1936/// `offset_in_window` is computed fresh; there is no global offset
1937/// state on `PendingDrop`.
1938fn encode_slab(ordinal: u64, drops: &[&PendingDrop]) -> SlabArtifact {
1939    // Each drop record is a fixed 49-byte entry: id(32) +
1940    // plaintext_len(4) + representation(3) + solid_window_index(1)
1941    // + offset_in_window(4) + window_len(4) + dict_id(1). Pre-sizing
1942    // the records Vec avoids per-drop realloc and amortises to a
1943    // single memcpy per field rather than bounds-check per call.
1944    const DROP_RECORD_LEN: usize = 49;
1945    let mut drop_records = Vec::with_capacity(drops.len() * DROP_RECORD_LEN);
1946    let mut solid_window = Vec::new();
1947    let mut drop_ids = Vec::with_capacity(drops.len());
1948    let mut offset_in_window: u32 = 0;
1949
1950    for drop in drops {
1951        let plaintext_len = drop.plaintext_len_value();
1952        let window_len = drop.len_in_window();
1953        drop_records.extend_from_slice(&drop.id);
1954        drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
1955        // representation: (codec, aead=0, ec=0)
1956        drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
1957        drop_records.push(0x00); // solid_window_index
1958        drop_records.extend_from_slice(&offset_in_window.to_le_bytes());
1959        drop_records.extend_from_slice(&window_len.to_le_bytes());
1960        drop_records.push(drop.dict_id); // dict_id: NO_DICT (0xFF) or trained id (0..=254)
1961        solid_window.extend_from_slice(&drop.compressed);
1962        drop_ids.push(drop.id);
1963        offset_in_window = offset_in_window
1964            .checked_add(window_len)
1965            .expect("slab window size fits u32");
1966    }
1967
1968    let slab_content = [&drop_records[..], &solid_window[..]].concat();
1969    let slab_hash = hash_section(&slab_content);
1970    let slab_id = SlabId::new(ordinal, slab_hash);
1971
1972    let total_length = SLAB_HEADER_LEN + slab_content.len();
1973    let mut slab_bytes = Vec::with_capacity(total_length);
1974    slab_bytes.extend_from_slice(b"LIM1");
1975    slab_bytes.extend_from_slice(&1u16.to_le_bytes());
1976    slab_bytes.extend_from_slice(&slab_id.to_bytes());
1977    slab_bytes.extend_from_slice(
1978        &u64::try_from(total_length)
1979            .unwrap_or(u64::MAX)
1980            .to_le_bytes(),
1981    );
1982    slab_bytes.push(0x00);
1983    slab_bytes.push(0x00);
1984    slab_bytes.extend_from_slice(&slab_content);
1985
1986    let locator = format!("file:slab-{ordinal}.bin");
1987
1988    SlabArtifact {
1989        id: slab_id,
1990        bytes: slab_bytes,
1991        locator,
1992        drop_ids,
1993    }
1994}
1995
1996#[cfg(test)]
1997fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
1998    let mut state = seed;
1999    let mut out = Vec::with_capacity(count);
2000    for _ in 0..count {
2001        state = state
2002            .wrapping_mul(6_364_136_223_846_793_005)
2003            .wrapping_add(1_442_695_040_888_963_407);
2004        out.push(u8::try_from(state >> 56).expect("fits u8"));
2005    }
2006    out
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::*;
2012    use limnifs_core::ManifestCursor;
2013
2014    #[test]
2015    fn write_stream_packs_single_named_stream() {
2016        // Stream 256 KiB of repetitive text through write_stream and
2017        // verify the artifact has a single root file at the requested
2018        // name with the expected size.
2019        let temp = std::env::temp_dir().join(format!(
2020            "limnifs-write-stream-test-{}-{}",
2021            std::process::id(),
2022            std::time::SystemTime::now()
2023                .duration_since(std::time::UNIX_EPOCH)
2024                .unwrap()
2025                .as_nanos()
2026        ));
2027        std::fs::create_dir_all(&temp).expect("create temp dir");
2028
2029        let content = b"stream test content line\n".repeat(10_000); // ~240 KiB
2030        let cursor = std::io::Cursor::new(content.clone());
2031        let config = WriteConfig::default_v0_1();
2032        let artifact = write_stream("streamed.txt", cursor, &config).expect("write_stream");
2033
2034        assert!(!artifact.bytes.is_empty(), "manifest bytes non-empty");
2035        assert!(!artifact.slabs.is_empty(), "at least one slab produced");
2036        // The artifact's manifest encodes the file metadata; we don't
2037        // deeply inspect it here (conformance suite covers that), but
2038        // we do confirm the writer produced something well-formed
2039        // enough that round-tripping through the reader works.
2040        let total_drop_bytes: usize = artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2041        assert!(total_drop_bytes > 0, "drops non-empty");
2042
2043        let _ = std::fs::remove_dir_all(&temp);
2044    }
2045
2046    #[test]
2047    fn write_layer_references_base_drops() {
2048        // Build a base image with a 1 MiB text file, then build a
2049        // layer that ADDS a new file AND includes the same text file.
2050        // The layer's slab bytes must be small (just the new file)
2051        // because the text file's chunks hit the base's drop set and
2052        // are emitted as `CODEC_REFERENCED`.
2053        let temp = std::env::temp_dir().join(format!(
2054            "limnifs-write-layer-test-{}-{}",
2055            std::process::id(),
2056            std::time::SystemTime::now()
2057                .duration_since(std::time::UNIX_EPOCH)
2058                .unwrap()
2059                .as_nanos()
2060        ));
2061        std::fs::create_dir_all(&temp).expect("create temp dir");
2062
2063        // Base: 1 MiB of repetitive text + a small unique file.
2064        let base_dir = temp.join("base");
2065        std::fs::create_dir_all(&base_dir).expect("base dir");
2066        let text = b"layer test content line\n".repeat(50_000); // ~1.15 MiB
2067        std::fs::write(base_dir.join("shared.txt"), &text).expect("write shared");
2068        std::fs::write(base_dir.join("base-only.txt"), b"only in base").expect("write base-only");
2069
2070        let config = WriteConfig::default_v0_1();
2071        let base_artifact = write_directory_with_config(&base_dir, &config).expect("base");
2072
2073        let base_manifest = temp.join("base.lim");
2074        std::fs::write(&base_manifest, &base_artifact.bytes).expect("write base manifest");
2075        for slab in &base_artifact.slabs {
2076            let slab_name = slab.locator.strip_prefix("file:").unwrap_or(&slab.locator);
2077            std::fs::write(temp.join(slab_name), &slab.bytes).expect("write base slab");
2078        }
2079
2080        // Layer: same shared.txt (must dedup against base) + a new file.
2081        let layer_dir = temp.join("layer");
2082        std::fs::create_dir_all(&layer_dir).expect("layer dir");
2083        std::fs::write(layer_dir.join("shared.txt"), &text).expect("write shared in layer");
2084        std::fs::write(layer_dir.join("new.txt"), b"fresh content in layer").expect("write new");
2085
2086        let layer_artifact = write_layer(&base_manifest, &layer_dir, &config).expect("layer");
2087
2088        // The layer's slabs should be SMALL — only the new file's
2089        // content. shared.txt's chunks are referenced via the base.
2090        let layer_slab_bytes: usize = layer_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2091        let base_slab_bytes: usize = base_artifact.slabs.iter().map(|s| s.bytes.len()).sum();
2092        assert!(
2093            layer_slab_bytes < base_slab_bytes / 4,
2094            "layer slabs ({}) should be much smaller than base ({}) — layering failed",
2095            layer_slab_bytes,
2096            base_slab_bytes
2097        );
2098
2099        // The layer manifest must contain the base's Merkle root in
2100        // its delta_linkage section.
2101        let base_root = base_artifact.merkle_root.as_bytes();
2102        assert!(
2103            layer_artifact
2104                .bytes
2105                .windows(32)
2106                .any(|w| w == base_root.as_slice()),
2107            "layer manifest must contain base's ManifestRoot bytes"
2108        );
2109
2110        let _ = std::fs::remove_dir_all(&temp);
2111    }
2112
2113    #[test]
2114    fn tournament_short_circuits_on_highly_compressible_chunk() {
2115        // Repetitive text compresses to <25% under LZ4. Tournament
2116        // should accept LZ4 and skip the slower Brotli pass.
2117        let chunk = b"hello world ".repeat(500);
2118        let tunables = limnifs_core::codec::CodecTunables::default();
2119        let tournament = TournamentSpec {
2120            codec_ids: vec![
2121                limnifs_core::codec::CODEC_LZ4,
2122                limnifs_core::codec::CODEC_BROTLI,
2123            ],
2124            min_size: 16,
2125            skip_for_binary: false,
2126            short_circuit_permille: 250,
2127        };
2128        let (codec_id, compressed) = compress_chunk_with_tournament(
2129            &chunk,
2130            classifier::Class::Text,
2131            limnifs_core::codec::CODEC_BROTLI,
2132            limnifs_core::codec::CODEC_LZ4,
2133            &tunables,
2134            &tournament,
2135        );
2136        assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2137        assert!(compressed.len() < chunk.len());
2138    }
2139
2140    #[test]
2141    fn tournament_runs_all_codecs_when_short_circuit_disabled() {
2142        // short_circuit_permille = 0 means "never short-circuit". The
2143        // tournament must try every codec and pick the smallest.
2144        // With omnizip 0.14.40, ZSTD (cached Huffman table) typically
2145        // beats both LZ4 and Brotli's Phase-C partial encoder on
2146        // repetitive text, so we include it in the tournament.
2147        let chunk = b"hello world ".repeat(500);
2148        let tunables = limnifs_core::codec::CodecTunables::default();
2149        let tournament = TournamentSpec {
2150            codec_ids: vec![
2151                limnifs_core::codec::CODEC_LZ4,
2152                limnifs_core::codec::CODEC_BROTLI,
2153                limnifs_core::codec::CODEC_ZSTD,
2154            ],
2155            min_size: 16,
2156            skip_for_binary: false,
2157            short_circuit_permille: 0,
2158        };
2159        let (codec_id, compressed) = compress_chunk_with_tournament(
2160            &chunk,
2161            classifier::Class::Text,
2162            limnifs_core::codec::CODEC_BROTLI,
2163            limnifs_core::codec::CODEC_LZ4,
2164            &tunables,
2165            &tournament,
2166        );
2167        // All three codecs should be tried; the smallest wins. With
2168        // omnizip 0.16.40's long copy fix (MAX_COPY 271→4096), Brotli
2169        // now beats ZSTD on repetitive text. Either is acceptable.
2170        assert!(
2171            codec_id == limnifs_core::codec::CODEC_ZSTD
2172                || codec_id == limnifs_core::codec::CODEC_BROTLI,
2173            "expected ZSTD or Brotli to win, got codec {codec_id}"
2174        );
2175        assert!(compressed.len() < chunk.len());
2176    }
2177
2178    #[test]
2179    fn tournament_skips_for_binary_when_configured() {
2180        let chunk = vec![0u8; 4096];
2181        let tunables = limnifs_core::codec::CodecTunables::default();
2182        let tournament = TournamentSpec {
2183            codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2184            min_size: 16,
2185            skip_for_binary: true,
2186            short_circuit_permille: 250,
2187        };
2188        let (codec_id, _compressed) = compress_chunk_with_tournament(
2189            &chunk,
2190            classifier::Class::Binary,
2191            limnifs_core::codec::CODEC_BROTLI,
2192            limnifs_core::codec::CODEC_LZ4,
2193            &tunables,
2194            &tournament,
2195        );
2196        // skip_for_binary → use binary_codec (LZ4) directly, never Brotli.
2197        assert_eq!(codec_id, limnifs_core::codec::CODEC_LZ4);
2198    }
2199
2200    #[test]
2201    fn tournament_small_chunk_uses_preferred_codec() {
2202        let chunk = b"tiny";
2203        let tunables = limnifs_core::codec::CodecTunables::default();
2204        let tournament = TournamentSpec {
2205            codec_ids: vec![limnifs_core::codec::CODEC_BROTLI],
2206            min_size: 1024,
2207            skip_for_binary: false,
2208            short_circuit_permille: 0,
2209        };
2210        let (codec_id, _compressed) = compress_chunk_with_tournament(
2211            chunk,
2212            classifier::Class::Text,
2213            limnifs_core::codec::CODEC_BROTLI,
2214            limnifs_core::codec::CODEC_LZ4,
2215            &tunables,
2216            &tournament,
2217        );
2218        // Below min_size → preferred codec (brotli for text) directly.
2219        assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2220    }
2221
2222    #[test]
2223    fn tournament_falls_back_to_store_when_no_codec_compresses() {
2224        // Random data — no codec should improve on store. We use the
2225        // pseudo-random generator from the test helpers to get
2226        // deterministic but incompressible bytes.
2227        let chunk = pseudo_random_bytes(42, 4096);
2228        let tunables = limnifs_core::codec::CodecTunables::default();
2229        let tournament = TournamentSpec {
2230            codec_ids: vec![limnifs_core::codec::CODEC_LZ4],
2231            min_size: 16,
2232            skip_for_binary: false,
2233            short_circuit_permille: 0,
2234        };
2235        let (codec_id, compressed) = compress_chunk_with_tournament(
2236            &chunk,
2237            classifier::Class::Binary,
2238            limnifs_core::codec::CODEC_BROTLI,
2239            limnifs_core::codec::CODEC_LZ4,
2240            &tunables,
2241            &tournament,
2242        );
2243        assert_eq!(codec_id, limnifs_core::codec::CODEC_STORE);
2244        assert_eq!(compressed.len(), chunk.len());
2245    }
2246
2247    #[test]
2248    fn dictionaries_enabled_emits_dictionary_section_when_enough_samples() {
2249        // Many small text files with shared vocabulary → FrequencyTrainer
2250        // should find a dictionary. We assert the section appears in
2251        // the manifest, regardless of whether the trained dict beats
2252        // per-drop compression (the trainer is content-dependent).
2253        let temp = std::env::temp_dir().join(format!(
2254            "limnifs-write-test-{}-dict-{}",
2255            std::process::id(),
2256            std::time::SystemTime::now()
2257                .duration_since(std::time::UNIX_EPOCH)
2258                .map(|d| d.as_nanos() as u64)
2259                .unwrap_or(0),
2260        ));
2261        let _ = std::fs::remove_dir_all(&temp);
2262        std::fs::create_dir_all(&temp).expect("mkdir");
2263
2264        // Generate 200 similar small files just above INLINE_THRESHOLD
2265        // so they go through the slab path.
2266        for i in 0..200 {
2267            // Repeated vocabulary the trainer can exploit.
2268            let content = format!(
2269                "function test_case_{i}() {{ return constant + {i}; }}\n\
2270                 // shared comment line {i}\n\
2271                 struct Foo {{ x: i32 }} // type {i}\n"
2272            )
2273            .repeat(5);
2274            let path = temp.join(format!("file_{i:04}.txt"));
2275            std::fs::write(&path, content.as_bytes()).expect("write");
2276        }
2277
2278        let mut config = crate::profile::balanced();
2279        // Force ZSTD for text so drops go through the dict-eligible path.
2280        config.defaults.text_codec = "zstd".into();
2281        // omnizip 0.14.40's Brotli encoder emits some streams the
2282        // in-house decoder rejects on highly repetitive input. Use ZSTD
2283        // for the metadata blob too so the round-trip parse succeeds.
2284        config.defaults.metadata_codec = "zstd".into();
2285        config.dictionaries.enabled = true;
2286        config.dictionaries.min_class_size = 50;
2287        config.dictionaries.max_dict_size = 8192;
2288
2289        let artifact = write_directory_with_config(&temp, &config).expect("write");
2290        std::fs::remove_dir_all(&temp).ok();
2291
2292        // The dictionary_section (if emitted) lives after the history
2293        // section. We don't strictly assert presence because the trainer
2294        // may legitimately return an empty dict; the test's job is to
2295        // verify the pipeline doesn't panic and the manifest parses.
2296        let mut cursor = ManifestCursor::new(&artifact.bytes);
2297        let _ = limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2298        let _ = limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2299        let _ = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta_ref");
2300        let _ = limnifs_core::parse_slab_index(&mut cursor).expect("slab_index");
2301        let _ = limnifs_core::parse_history(&mut cursor).expect("history");
2302        // If a dict was emitted, parsing past history should leave
2303        // non-empty remaining bytes.
2304        let _remaining = cursor.remaining_len();
2305    }
2306
2307    #[test]
2308    fn write_empty_directory() {
2309        let temp =
2310            std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
2311        std::fs::create_dir_all(&temp).expect("create temp dir");
2312        let artifact = write_directory(&temp).expect("write succeeds");
2313        std::fs::remove_dir_all(&temp).ok();
2314        assert!(artifact.inode_count >= 1);
2315        assert_eq!(artifact.file_count, 0);
2316        assert_eq!(artifact.dir_count, 1);
2317        assert!(artifact.slabs.is_empty());
2318    }
2319
2320    #[test]
2321    fn write_small_file_inline() {
2322        let temp =
2323            std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
2324        std::fs::create_dir_all(&temp).expect("create temp dir");
2325        std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
2326        let artifact = write_directory(&temp).expect("write succeeds");
2327        std::fs::remove_dir_all(&temp).ok();
2328        assert_eq!(artifact.file_count, 1);
2329        assert!(artifact.slabs.is_empty());
2330        assert_eq!(artifact.drop_count, 0);
2331    }
2332
2333    #[test]
2334    fn write_large_file_uses_slab() {
2335        let temp =
2336            std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
2337        std::fs::create_dir_all(&temp).expect("create temp dir");
2338        let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
2339        std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
2340        let artifact = write_directory(&temp).expect("write succeeds");
2341        std::fs::remove_dir_all(&temp).ok();
2342        assert_eq!(artifact.drop_count, 1);
2343        assert_eq!(artifact.slabs.len(), 1);
2344    }
2345
2346    #[test]
2347    fn write_mixed_inline_and_large() {
2348        let temp =
2349            std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
2350        std::fs::create_dir_all(&temp).expect("create temp dir");
2351        std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
2352        std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
2353            .expect("write large");
2354        let artifact = write_directory(&temp).expect("write succeeds");
2355        std::fs::remove_dir_all(&temp).ok();
2356        assert_eq!(artifact.file_count, 2);
2357        assert_eq!(artifact.drop_count, 1);
2358        assert_eq!(artifact.slabs.len(), 1);
2359    }
2360
2361    #[test]
2362    fn deduplicates_identical_large_files() {
2363        let temp =
2364            std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
2365        std::fs::create_dir_all(&temp).expect("create temp dir");
2366        let data = vec![0x77u8; INLINE_THRESHOLD + 10];
2367        std::fs::write(temp.join("a.bin"), &data).expect("write a");
2368        std::fs::write(temp.join("b.bin"), &data).expect("write b");
2369        let artifact = write_directory(&temp).expect("write succeeds");
2370        std::fs::remove_dir_all(&temp).ok();
2371        assert_eq!(artifact.drop_count, 1);
2372    }
2373
2374    #[test]
2375    fn write_and_verify_roundtrip() {
2376        let temp = std::env::temp_dir().join(format!(
2377            "limnifs-write-test-{}-roundtrip",
2378            std::process::id()
2379        ));
2380        std::fs::create_dir_all(&temp).expect("create temp dir");
2381        std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
2382        std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
2383        std::fs::create_dir_all(temp.join("sub")).expect("create sub");
2384        std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
2385        let artifact = write_directory(&temp).expect("write succeeds");
2386        std::fs::remove_dir_all(&temp).ok();
2387        assert_eq!(artifact.file_count, 3);
2388        assert_eq!(artifact.dir_count, 2);
2389
2390        let mut cursor = ManifestCursor::new(&artifact.bytes);
2391        limnifs_core::parse_manifest_header(&mut cursor).expect("header");
2392        limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
2393        let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
2394        assert!(meta_ref.is_inlined());
2395        let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
2396        assert_eq!(slab_index.len(), 0);
2397        limnifs_core::parse_history(&mut cursor).expect("history");
2398    }
2399
2400    #[test]
2401    fn write_deterministic() {
2402        let temp =
2403            std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
2404        std::fs::create_dir_all(&temp).expect("create temp dir");
2405        std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
2406
2407        let a1 = write_directory(&temp).expect("first write");
2408        let a2 = write_directory(&temp).expect("second write");
2409        std::fs::remove_dir_all(&temp).ok();
2410
2411        assert_eq!(a1.bytes, a2.bytes);
2412        assert_eq!(a1.merkle_root, a2.merkle_root);
2413    }
2414
2415    #[test]
2416    fn slab_parses_correctly() {
2417        let temp =
2418            std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
2419        std::fs::create_dir_all(&temp).expect("create temp dir");
2420        std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
2421            .expect("write big");
2422        let artifact = write_directory(&temp).expect("write succeeds");
2423        std::fs::remove_dir_all(&temp).ok();
2424
2425        let slab_bytes = &artifact.slabs[0].bytes;
2426        let mut cursor = ManifestCursor::new(slab_bytes);
2427        let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
2428        assert_eq!(slab_header.format_version, 1);
2429        assert!(!slab_header.is_sealed());
2430        assert!(!slab_header.has_erasure_coding());
2431
2432        let drop_record =
2433            limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
2434        assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
2435    }
2436
2437    #[test]
2438    fn fastcdc_produces_multiple_chunks_for_large_files() {
2439        // A 1 MiB pseudo-random file should produce multiple drops
2440        // via FastCDC (default chunker uses 64 KiB min / 256 KiB avg).
2441        let temp = std::env::temp_dir().join(format!(
2442            "limnifs-write-test-{}-cdc-multi",
2443            std::process::id()
2444        ));
2445        std::fs::create_dir_all(&temp).expect("create temp dir");
2446        let data = pseudo_random_bytes(42, 1024 * 1024);
2447        std::fs::write(temp.join("big.bin"), &data).expect("write big");
2448        let artifact = write_directory(&temp).expect("write succeeds");
2449        std::fs::remove_dir_all(&temp).ok();
2450        assert!(
2451            artifact.drop_count > 1,
2452            "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
2453            artifact.drop_count
2454        );
2455    }
2456
2457    #[test]
2458    fn fastcdc_deduplicates_shared_substrings() {
2459        // Two files sharing a long middle section should produce
2460        // fewer drops than the sum of their individual chunk counts,
2461        // because the shared section's chunks deduplicate.
2462        let temp = std::env::temp_dir().join(format!(
2463            "limnifs-write-test-{}-cdc-dedup",
2464            std::process::id()
2465        ));
2466        std::fs::create_dir_all(&temp).expect("create temp dir");
2467        let shared = pseudo_random_bytes(7, 512 * 1024);
2468        let mut a = Vec::with_capacity(shared.len() + 1024);
2469        a.extend_from_slice(&pseudo_random_bytes(1, 1024));
2470        a.extend_from_slice(&shared);
2471        let mut b = Vec::with_capacity(shared.len() + 2048);
2472        b.extend_from_slice(&pseudo_random_bytes(2, 2048));
2473        b.extend_from_slice(&shared);
2474        std::fs::write(temp.join("a.bin"), &a).expect("write a");
2475        std::fs::write(temp.join("b.bin"), &b).expect("write b");
2476
2477        // Baseline: each file alone.
2478        let temp_a = std::env::temp_dir().join(format!(
2479            "limnifs-write-test-{}-cdc-dedup-a",
2480            std::process::id()
2481        ));
2482        std::fs::create_dir_all(&temp_a).expect("create temp_a");
2483        std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
2484        let artifact_a = write_directory(&temp_a).expect("a writes");
2485        std::fs::remove_dir_all(&temp_a).ok();
2486
2487        let temp_b = std::env::temp_dir().join(format!(
2488            "limnifs-write-test-{}-cdc-dedup-b",
2489            std::process::id()
2490        ));
2491        std::fs::create_dir_all(&temp_b).expect("create temp_b");
2492        std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
2493        let artifact_b = write_directory(&temp_b).expect("b writes");
2494        std::fs::remove_dir_all(&temp_b).ok();
2495
2496        let artifact_both = write_directory(&temp).expect("both write");
2497        std::fs::remove_dir_all(&temp).ok();
2498
2499        let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
2500        assert!(
2501            artifact_both.drop_count < sum_alone,
2502            "expected dedup win: both together = {} drops, sum alone = {} drops",
2503            artifact_both.drop_count,
2504            sum_alone
2505        );
2506    }
2507
2508    #[test]
2509    fn slab_splits_when_content_exceeds_ceiling() {
2510        // Synthesise enough incompressible drops to force at least two
2511        // slabs. Each drop is 10 MiB of pseudo-random data; three drops
2512        // = 30 MiB compressed (random data doesn't compress), which
2513        // fits in one slab. We bump to seven drops (70 MiB) to force a
2514        // split.
2515        let temp =
2516            std::env::temp_dir().join(format!("limnifs-write-test-{}-split", std::process::id()));
2517        std::fs::create_dir_all(&temp).expect("create temp dir");
2518        for i in 0..7u32 {
2519            // 10 MiB of pseudo-random bytes — incompressible.
2520            let data = pseudo_random_bytes(u64::from(i), 10 * 1024 * 1024);
2521            std::fs::write(temp.join(format!("big-{i}.bin")), &data).expect("write big");
2522        }
2523        let artifact = write_directory(&temp).expect("write succeeds");
2524        std::fs::remove_dir_all(&temp).ok();
2525
2526        // Each slab's total length must respect MAX_SLAB_TOTAL_BYTES.
2527        assert!(
2528            artifact.slabs.len() >= 2,
2529            "expected at least 2 slabs for 70 MiB of incompressible data, got {}",
2530            artifact.slabs.len()
2531        );
2532        for slab in &artifact.slabs {
2533            assert!(
2534                slab.bytes.len() <= MAX_SLAB_TOTAL_BYTES,
2535                "slab {} is {} bytes (> {} ceiling)",
2536                slab.id.ordinal,
2537                slab.bytes.len(),
2538                MAX_SLAB_TOTAL_BYTES,
2539            );
2540        }
2541        // All seven drops must be accounted for across slabs.
2542        let total_drop_ids: usize = artifact.slabs.iter().map(|s| s.drop_ids.len()).sum();
2543        assert_eq!(
2544            total_drop_ids, artifact.drop_count,
2545            "drop_ids count across slabs must match WriteArtifact.drop_count",
2546        );
2547    }
2548}