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