Skip to main content

mlt_core/encoder/
writer.rs

1use std::collections::HashMap;
2use std::{io, mem};
3
4use fsst::Compressor;
5use integer_encoding::VarIntWriter as _;
6
7use crate::decoder::{ColumnType, Morton};
8use crate::encoder::model::{CurveParams, ExplicitEncoder, StrEncoding, StreamCtx};
9use crate::encoder::{EncoderConfig, IntEncoder, VertexBufferType};
10use crate::utils::BinarySerializer as _;
11use crate::{MltError, MltResult};
12
13/// Stateful encoder that accumulates encoded layer bytes.
14///
15/// Logical temporary buffers live in `Codecs` and are passed alongside
16/// the encoder while a stream is being transformed and serialized. Physical
17/// encoders live here with their own scratch buffers, then copy complete
18/// payloads into [`data`](Encoder::data).
19///
20/// # Buffer layout
21///
22/// The MLT layer wire format is:
23///
24/// ```text
25/// [varint(body_len + 1)] [tag = 1]
26/// [name: string] [extent: varint] [column_count: varint]   <- hdr
27/// [col_type₁] [col_type₂] … [col_typeN]                    <- meta
28/// [col₁ stream data] [col₂ stream data] … [colN stream data] <- data
29/// ```
30///
31/// The three sections are accumulated into separate buffers so they can be
32/// combined at the end *without* any in-place insertion or extra copies:
33///
34/// * `hdr` - layer header (name, extent, `column_count`).
35/// * [`meta`] - column-type bytes (one byte + optional name per column).
36/// * [`data`] - encoded stream data; also the target of [`impl Write`].
37///
38/// # Sort-strategy trialing
39///
40/// Create one `Encoder` per sort-strategy trial, encode the layer into it,
41/// and keep the one whose `total_len()` is smallest:
42///
43/// ```rust,ignore
44/// let mut codecs = Codecs::default();
45/// let mut best: Option<Encoder> = None;
46/// for strategy in strategies {
47///     let mut enc = Encoder::new(cfg);
48///     layer.write_to(&mut enc, &mut codecs)?;
49///     if best.as_ref().is_none_or(|b| enc.total_len() < b.total_len()) {
50///         best = Some(enc);
51///     }
52/// }
53/// return best.unwrap().into_layer_bytes();
54/// ```
55///
56/// # Stream-level encoding alternatives
57///
58/// Use [`Encoder::try_alternatives`] to open a competition,
59/// then submit each candidate via `AltSession::with`.  The guard's `Drop`
60/// impl finalises the competition automatically:
61///
62/// ```rust,ignore
63/// let mut alt = enc.try_alternatives();
64/// alt.with(|enc| write_stream_as_varint(data, enc))?;
65/// alt.with(|enc| write_stream_as_fastpfor(data, enc))?;
66/// // alt drops -> keeps whichever was shorter
67/// ```
68///
69/// [`meta`]: Encoder::meta
70/// [`data`]: Encoder::data
71/// [`impl Write`]: Encoder#impl-Write
72#[derive(Default)]
73pub struct Encoder {
74    /// Encoding configuration: controls which optimization strategies are tried
75    /// (sort orders, compression algorithms, etc.).
76    ///
77    /// Set once at construction time via [`Encoder::new`]; propagated
78    /// automatically to all sub-encoders so individual encode methods do not
79    /// need a separate `cfg` argument.
80    cfg: EncoderConfig,
81
82    /// When [`Some`], property / ID / geometry encoders use `ExplicitEncoder`
83    /// callbacks instead of trying candidate encodings. When [`None`], the
84    /// automatic optimization path runs.
85    pub(crate) explicit: Option<ExplicitEncoder>,
86
87    /// Layer header bytes: `name`, `extent`, `column_count`.
88    ///
89    /// This section comes first in the wire format and is never subject to alternatives.
90    hdr: Vec<u8>,
91
92    /// Column-type metadata bytes.
93    ///
94    /// Each column contributes one type byte (plus a name string for property
95    /// columns).  Written by the `write_columns_meta_to` methods, which write
96    /// directly to `enc.meta`.  This section comes second in the wire format
97    /// and is never subject to alternatives (column types are fixed).
98    meta: Vec<u8>,
99
100    /// Encoded stream data.
101    ///
102    /// All stream counts, per-stream encoding-metadata bytes, and encoded
103    /// data bytes land here via [`impl Write`].  This section comes last in
104    /// the wire format and is where stream-level alternatives compete.
105    ///
106    /// [`impl Write`]: Encoder#impl-Write
107    data: Vec<u8>,
108
109    /// Morton parameters for this layer's vertex set; `None` if the extent
110    /// exceeds 16 bits per axis (Morton encoding is unusable in that case).
111    /// Pre-populated by [`StagedLayer::encode_into`](crate::encoder::StagedLayer::encode_into).
112    pub(crate) morton_cache: Option<Morton>,
113
114    /// Hilbert curve parameters for this layer's vertex set. Pre-populated by
115    /// [`StagedLayer::encode_into`](crate::encoder::StagedLayer::encode_into).
116    pub(crate) hilbert_cache: Option<CurveParams>,
117
118    /// Cached FSST compressor per string column, keyed by column name.
119    /// `None` means training found FSST not viable for that column.
120    /// Trained on deduplicated values on the first sort trial, reused on subsequent trials.
121    pub(crate) fsst_cache: HashMap<String, Option<Compressor>>,
122
123    /// The stream count a v2 decoder would infer from context at the current
124    /// write position: the layer's `feature_count`, or the presence popcount
125    /// while an optional column's data stream is being written.
126    ///
127    /// Read by the v2 stream-header codec to decide whether an explicit count
128    /// varint must be emitted; ignored entirely for v1 layers.
129    #[cfg(feature = "unstable-v2")]
130    pub(crate) count_context: u32,
131
132    // -----------------------------------------------------------------------
133    // Alternatives state - a stack that supports nested competitions.
134    //
135    // Invariant between candidates at any level:
136    //   data.len() == level.data_start + level.best_data_size.unwrap_or(0)
137    //   meta.len() == level.meta_start + level.best_meta_size.unwrap_or(0)
138    //
139    // Empty stack <-> no competition in progress.
140    // -----------------------------------------------------------------------
141    /// Stack of active encoding competitions, innermost last.
142    ///
143    /// Empty while no [`Encoder::try_alternatives`] session
144    /// is in progress.
145    alt_stack: Vec<AltLevel>,
146}
147
148impl Encoder {
149    /// Create a new encoder with the given [`EncoderConfig`].
150    ///
151    /// Use [`Encoder::default()`] when the default configuration is sufficient.
152    #[inline]
153    #[must_use]
154    pub fn new(cfg: EncoderConfig) -> Self {
155        Self {
156            cfg,
157            ..Self::default()
158        }
159    }
160
161    /// Like [`Self::new`] but with the explicit encoder set for deterministic encoding
162    /// (tests, synthetics). Use with `StagedLayer::encode_explicit`.
163    #[inline]
164    #[must_use]
165    pub fn with_explicit(cfg: EncoderConfig, explicit: ExplicitEncoder) -> Self {
166        Self {
167            cfg,
168            explicit: Some(explicit),
169            ..Self::default()
170        }
171    }
172
173    /// Ensure this encoder is in the good state, and moves results to a new instance.
174    /// This allows current instance to be reused for other experiment, avoiding repeat of some operations.
175    #[must_use]
176    pub(crate) fn preserve_results(&mut self) -> Self {
177        assert_eq!(self.alt_stack.len(), 0, "Alternatives stack is not empty");
178        Self {
179            // Keep the config: the archived result still needs it to frame the
180            // layer with the right tag in `into_layer_bytes`.
181            cfg: self.cfg,
182            explicit: None,
183            hdr: mem::take(&mut self.hdr),
184            meta: mem::take(&mut self.meta),
185            data: mem::take(&mut self.data),
186            morton_cache: None,
187            hilbert_cache: None,
188            fsst_cache: HashMap::new(),
189            #[cfg(feature = "unstable-v2")]
190            count_context: 0,
191            alt_stack: vec![],
192        }
193    }
194
195    #[inline]
196    pub(crate) fn write_column_type(&mut self, column_type: ColumnType) -> MltResult<()> {
197        column_type.write_to(&mut self.meta).map_err(MltError::from)
198    }
199
200    #[inline]
201    pub(crate) fn write_column_name(&mut self, name: &str) -> MltResult<()> {
202        self.meta.write_string(name).map_err(MltError::from)
203    }
204
205    #[inline]
206    #[must_use]
207    pub fn config(&self) -> EncoderConfig {
208        self.cfg
209    }
210
211    #[inline]
212    #[must_use]
213    pub fn data(&self) -> &[u8] {
214        &self.data
215    }
216
217    #[inline]
218    pub(crate) fn data_mut(&mut self) -> &mut Vec<u8> {
219        &mut self.data
220    }
221
222    #[inline]
223    #[must_use]
224    pub fn meta(&self) -> &[u8] {
225        &self.meta
226    }
227
228    #[inline]
229    pub(crate) fn meta_mut(&mut self) -> &mut Vec<u8> {
230        &mut self.meta
231    }
232
233    #[inline]
234    #[must_use]
235    pub fn section_lens(&self) -> (usize, usize, usize) {
236        (self.hdr.len(), self.meta.len(), self.data.len())
237    }
238
239    #[inline]
240    pub(crate) fn write_column_header(
241        &mut self,
242        column_type: ColumnType,
243        name: &str,
244    ) -> MltResult<()> {
245        self.write_column_type(column_type)?;
246        self.write_column_name(name)
247    }
248
249    /// Write the v1 layer header (`name`, `extent`, `column_count`) to `hdr`.
250    ///
251    /// Must be called exactly once per layer, after all column meta and data.
252    #[hotpath::measure]
253    pub fn write_header01(
254        &mut self,
255        name: &str,
256        extent: u32,
257        column_count: usize,
258    ) -> MltResult<()> {
259        if name.is_empty() {
260            return Err(MltError::MissingLayerName);
261        }
262        debug_assert!(
263            self.alt_stack.is_empty(),
264            "write_header called with an open alternatives session"
265        );
266        let name_len = u32::try_from(name.len())?;
267        let column_count = u32::try_from(column_count)?;
268        self.hdr.write_varint(name_len).map_err(MltError::from)?;
269        self.hdr.extend_from_slice(name.as_bytes());
270        self.hdr.write_varint(extent).map_err(MltError::from)?;
271        self.hdr
272            .write_varint(column_count)
273            .map_err(MltError::from)?;
274        Ok(())
275    }
276
277    /// Write the v2 layer header (`name`, `extent`, `feature_count`) to `hdr`.
278    ///
279    /// Unlike v1, `column_count` is not part of the header - it precedes the
280    /// counted columns in the data section, after the geometry section.
281    #[cfg(feature = "unstable-v2")]
282    #[hotpath::measure]
283    pub(crate) fn write_header02(
284        &mut self,
285        name: &str,
286        extent: u32,
287        feature_count: u32,
288    ) -> MltResult<()> {
289        if name.is_empty() {
290            return Err(MltError::MissingLayerName);
291        }
292        debug_assert!(
293            self.alt_stack.is_empty(),
294            "write_header02 called with an open alternatives session"
295        );
296        self.hdr.write_string(name).map_err(MltError::from)?;
297        self.hdr.write_varint(extent).map_err(MltError::from)?;
298        self.hdr
299            .write_varint(feature_count)
300            .map_err(MltError::from)?;
301        Ok(())
302    }
303
304    /// When [`Self::explicit`] is [`Some`], returns the callback-chosen [`IntEncoder`].
305    /// [`None`] means run automatic candidate selection for that stream.
306    #[inline]
307    pub(crate) fn override_int_enc(&self, ctx: &StreamCtx<'_>) -> Option<IntEncoder> {
308        self.explicit.as_ref().map(|e| (e.get_int_encoder)(ctx))
309    }
310
311    /// When [`Self::explicit`] is [`Some`], returns the callback-chosen [`StrEncoding`].
312    /// [`None`] means run automatic string / shared-dict corpus selection.
313    #[inline]
314    pub(crate) fn override_str_enc(&self, name: &str) -> Option<StrEncoding> {
315        self.explicit.as_ref().map(|e| (e.get_str_encoding)(name))
316    }
317
318    /// Pinned vertex layout when an explicit encoder is active.
319    #[inline]
320    #[allow(clippy::unused_self)]
321    pub(crate) fn override_vertex_buffer_type(&self) -> Option<VertexBufferType> {
322        self.explicit.as_ref().map(|e| e.vertex_buffer_type)
323    }
324
325    /// Whether to force writing a geometry stream even when its data is empty.
326    ///
327    /// Delegates to [`ExplicitEncoder::force_stream`]; returns `false` when no explicit
328    /// encoder is active (the default "skip empty streams" behavior).
329    #[inline]
330    pub(crate) fn force_stream(&self, ctx: &StreamCtx<'_>) -> bool {
331        self.explicit
332            .as_ref()
333            .is_some_and(|e| (e.force_stream)(ctx))
334    }
335
336    /// Total encoded bytes across all three sections (`hdr + meta + data`).
337    #[inline]
338    #[must_use]
339    pub fn total_len(&self) -> usize {
340        self.hdr.len() + self.meta.len() + self.data.len()
341    }
342
343    /// Empty the output buffers so this encoder can be reused for the next sort trial.
344    /// Keeps allocated capacity and the seeded curve/FSST caches.
345    /// Used when a trial loses; [`Self::preserve_results`] handles the winning case instead.
346    pub(crate) fn clear_results(&mut self) {
347        debug_assert!(self.alt_stack.is_empty(), "Alternatives stack is not empty");
348        self.hdr.clear();
349        self.meta.clear();
350        self.data.clear();
351    }
352
353    /// Concatenate `hdr + meta + data` into a single buffer **without** a
354    /// tag/size prefix.
355    ///
356    /// Use this when the caller expects raw layer body bytes (without the size/tag framing)
357    /// rather than a complete framed wire record - see [`Self::into_layer_bytes`] for the framed form.
358    #[must_use]
359    pub fn into_raw_bytes(mut self) -> Vec<u8> {
360        if self.hdr.is_empty() && self.meta.is_empty() {
361            return self.data;
362        }
363        let mut out = Vec::with_capacity(self.hdr.len() + self.meta.len() + self.data.len());
364        out.append(&mut self.hdr);
365        out.append(&mut self.meta);
366        out.append(&mut self.data);
367        out
368    }
369
370    /// Assemble the complete layer record.
371    pub fn into_layer_bytes(self) -> MltResult<Vec<u8>> {
372        let tag = self.cfg.wire_version().tag();
373        self.into_layer_bytes_with_tag(tag)
374    }
375
376    /// Assemble a complete layer record for the given `tag`:
377    /// `[varint(body_len + 1)][tag][hdr][meta][data]`.
378    fn into_layer_bytes_with_tag(mut self, tag: u8) -> MltResult<Vec<u8>> {
379        debug_assert!(
380            self.alt_stack.is_empty(),
381            "into_layer_bytes_with_tag called with an open alternatives session"
382        );
383        let body_len = self.hdr.len() + self.meta.len() + self.data.len();
384        let size = u32::try_from(body_len + 1)?; // +1 for the tag byte
385        let mut out = Vec::with_capacity(5 + 1 + body_len);
386        out.write_varint(size).map_err(MltError::from)?;
387        out.push(tag);
388        out.append(&mut self.hdr);
389        out.append(&mut self.meta);
390        out.append(&mut self.data);
391        Ok(out)
392    }
393
394    /// Begin a new encoding competition.
395    ///
396    /// Returns an `AltSession` guard.  Submit each candidate via
397    /// `AltSession::with`; the guard's `Drop` impl finalises
398    /// the competition and retains the shortest candidate automatically.
399    ///
400    /// Nesting is supported: calling `try_alternatives` inside a
401    /// `with` closure opens an inner competition on the same stack,
402    /// resolved before the outer candidate is committed.
403    ///
404    /// # Example
405    ///
406    /// ```rust,ignore
407    /// let mut alt = enc.try_alternatives();
408    /// for cand in candidates {
409    ///     alt.with(|enc| write_candidate(cand, enc))?;
410    /// }
411    /// // alt drops -> finalises the competition
412    /// ```
413    pub fn try_alternatives(&mut self) -> AltSession<'_> {
414        self.alt_stack.push(AltLevel {
415            data_start: self.data.len(),
416            meta_start: self.meta.len(),
417            best_data: None,
418            best_meta: None,
419        });
420        AltSession { enc: self }
421    }
422
423    /// Commit the current candidate at the innermost competition level.
424    ///
425    /// Compares bytes written since the last commit against the running best
426    /// by **total** (`data + meta`) size; keeps the shorter one.
427    ///
428    /// Called internally by `AltSession::with` on `Ok`.
429    fn alt_commit(&mut self) {
430        debug_assert!(
431            !self.alt_stack.is_empty(),
432            "alt_commit called outside an active AltSession"
433        );
434        let (data, meta, stack) = (&mut self.data, &mut self.meta, &mut self.alt_stack);
435        let level = stack.last_mut().unwrap();
436        Self::close_candidate(data, meta, level);
437    }
438
439    /// Finalize the innermost competition and pop it from the stack.
440    ///
441    /// Any bytes written since the last `alt_commit` are evaluated as a
442    /// final candidate; if no pending bytes exist and a best is already
443    /// recorded this is a cheap stack-pop.
444    fn alt_pop(&mut self) {
445        debug_assert!(
446            !self.alt_stack.is_empty(),
447            "alt_pop called outside an active AltSession"
448        );
449        {
450            let (data, meta, stack) = (&mut self.data, &mut self.meta, &mut self.alt_stack);
451            let level = stack.last_mut().unwrap();
452            let data_pending = data.len() - (level.data_start + level.best_data.unwrap_or(0));
453            let meta_pending = meta.len() - (level.meta_start + level.best_meta.unwrap_or(0));
454            if data_pending > 0 || meta_pending > 0 || level.best_data.is_none() {
455                Self::close_candidate(data, meta, level);
456            }
457        }
458        self.alt_stack.pop();
459    }
460
461    /// Shared compare-and-keep logic used by both `alt_commit` and `alt_pop`.
462    ///
463    /// Compares the bytes written since the last committed candidate against
464    /// the current best by **total** (`data + meta`) size.
465    /// Keeps the shorter one; ties preserve the existing best.
466    fn close_candidate(data: &mut Vec<u8>, meta: &mut Vec<u8>, level: &mut AltLevel) {
467        let best_data_end = level.data_start + level.best_data.unwrap_or(0);
468        let best_meta_end = level.meta_start + level.best_meta.unwrap_or(0);
469        let cand_data = data.len() - best_data_end;
470        let cand_meta = meta.len() - best_meta_end;
471        let cand_total = cand_data + cand_meta;
472        let best_total = level.best_data.unwrap_or(0) + level.best_meta.unwrap_or(0);
473        if level.best_data.is_none_or(|_| cand_total < best_total) {
474            // New best: shift data candidate bytes to data_start.
475            if level.best_data.is_some() {
476                data.copy_within(best_data_end..best_data_end + cand_data, level.data_start);
477                meta.copy_within(best_meta_end..best_meta_end + cand_meta, level.meta_start);
478            }
479            data.truncate(level.data_start + cand_data);
480            meta.truncate(level.meta_start + cand_meta);
481            level.best_data = Some(cand_data);
482            level.best_meta = Some(cand_meta);
483        } else {
484            // Not an improvement: discard.
485            data.truncate(best_data_end);
486            meta.truncate(best_meta_end);
487        }
488    }
489}
490
491/// State for one level of an encoding competition.
492///
493/// Tracks the starting position in both the [`data`](Encoder::data) and
494/// [`meta`](Encoder::meta) buffers, and the byte count of the best candidate
495/// committed so far.
496///
497/// Candidates are compared by **total** bytes (`data + meta`); the shorter one
498/// wins, with ties resolved in favor of the earlier candidate.
499#[derive(Debug, Default, Clone)]
500struct AltLevel {
501    data_start: usize,
502    meta_start: usize,
503    /// Byte count appended to `data` by the current best candidate.
504    best_data: Option<usize>,
505    /// Byte count appended to `meta` by the current best candidate.
506    best_meta: Option<usize>,
507}
508
509/// RAII guard for a stream-encoding competition opened by [`Encoder::try_alternatives`].
510///
511/// Submit each candidate via [`with`](AltSession::with); on `Ok` the candidate is
512/// committed (compared against the running best and kept if shorter); on `Err`
513/// the partial write is rolled back and the error propagates.  The guard's
514/// `Drop` impl finalises the competition automatically, so the [`Encoder`] is
515/// always left in a consistent state even when an error exits the loop early.
516///
517/// Nesting is allowed: calling [`Encoder::try_alternatives`] inside a
518/// `with` closure opens an inner competition that is fully
519/// resolved before the outer candidate is committed.
520#[must_use = "AltSession must be used; drop it to finalise the competition"]
521pub struct AltSession<'a> {
522    enc: &'a mut Encoder,
523}
524
525impl AltSession<'_> {
526    /// Encode one candidate.
527    ///
528    /// - **`Ok`** - commits the candidate; replaces the running best if shorter.
529    /// - **`Err`** - truncates the partial write back to the pre-call checkpoint
530    ///   and returns the error.  The guard's `Drop` still finalises the
531    ///   competition cleanly using whichever candidates succeeded so far.
532    #[hotpath::measure]
533    pub fn with<F>(&mut self, f: F) -> MltResult<()>
534    where
535        F: FnOnce(&mut Encoder) -> MltResult<()>,
536    {
537        let data_cp = self.enc.data.len();
538        let meta_cp = self.enc.meta.len();
539        match f(self.enc) {
540            Ok(()) => {
541                self.enc.alt_commit();
542                Ok(())
543            }
544            Err(e) => {
545                self.enc.data.truncate(data_cp);
546                self.enc.meta.truncate(meta_cp);
547                Err(e)
548            }
549        }
550    }
551}
552
553impl Drop for AltSession<'_> {
554    fn drop(&mut self) {
555        self.enc.alt_pop();
556    }
557}
558
559/// Writes bytes to [`Encoder::data`].
560///
561/// This blanket implementation makes `Encoder` compatible with all
562/// `BinarySerializer`, `VarIntWriter`, and other `Write`-based utilities so that
563/// stream-data methods do not need a separate code path.
564impl io::Write for Encoder {
565    #[inline]
566    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
567        self.data.write(buf)
568    }
569
570    #[inline]
571    fn flush(&mut self) -> io::Result<()> {
572        Ok(())
573    }
574
575    #[inline]
576    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
577        self.data.write_all(buf)
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    /// Helper: directly extend `enc.data` with raw bytes (simulates a stream write).
586    fn push(enc: &mut Encoder, bytes: &[u8]) {
587        enc.data.extend_from_slice(bytes);
588    }
589
590    // ── basic single-level behavior ──────────────────────────────────────
591
592    /// The shortest candidate wins.
593    #[test]
594    fn alternatives_keeps_shortest() {
595        let mut enc = Encoder::default();
596        push(&mut enc, b"prefix");
597
598        let mut alt = enc.try_alternatives();
599        alt.with(|enc| {
600            push(enc, b"longer");
601            Ok(())
602        })
603        .unwrap(); // 6 bytes
604        alt.with(|enc| {
605            push(enc, b"ab");
606            Ok(())
607        })
608        .unwrap(); // 2 bytes - shortest
609        alt.with(|enc| {
610            push(enc, b"xyz");
611            Ok(())
612        })
613        .unwrap(); // 3 bytes
614        drop(alt);
615
616        assert_eq!(enc.data, b"prefixab");
617    }
618
619    /// On a tie the first candidate is kept (strict `<`, not `<=`).
620    #[test]
621    fn alternatives_tie_keeps_first() {
622        let mut enc = Encoder::default();
623
624        let mut alt = enc.try_alternatives();
625        alt.with(|enc| {
626            push(enc, b"aaa");
627            Ok(())
628        })
629        .unwrap(); // 3 bytes
630        alt.with(|enc| {
631            push(enc, b"bbb");
632            Ok(())
633        })
634        .unwrap(); // 3 bytes - equal
635        drop(alt);
636
637        assert_eq!(enc.data, b"aaa");
638    }
639
640    /// A single candidate is unconditionally the winner.
641    #[test]
642    fn alternatives_single_candidate() {
643        let mut enc = Encoder::default();
644
645        let mut alt = enc.try_alternatives();
646        alt.with(|enc| {
647            push(enc, b"only");
648            Ok(())
649        })
650        .unwrap();
651        drop(alt);
652
653        assert_eq!(enc.data, b"only");
654    }
655
656    /// Bytes written before `try_alternatives` are left intact throughout.
657    #[test]
658    fn prefix_bytes_are_preserved() {
659        let mut enc = Encoder::default();
660        push(&mut enc, b"HDR");
661
662        let mut alt = enc.try_alternatives();
663        alt.with(|enc| {
664            push(enc, b"long_encoding");
665            Ok(())
666        })
667        .unwrap(); // 13 bytes
668        alt.with(|enc| {
669            push(enc, b"short");
670            Ok(())
671        })
672        .unwrap(); // 5 bytes - winner
673        drop(alt);
674
675        assert_eq!(&enc.data[..3], b"HDR");
676        assert_eq!(&enc.data[3..], b"short");
677    }
678
679    /// Dropping the guard after all candidates are committed is a cheap stack-pop.
680    #[test]
681    fn drop_after_all_committed_is_noop() {
682        let mut enc = Encoder::default();
683
684        let mut alt = enc.try_alternatives();
685        alt.with(|enc| {
686            push(enc, b"best");
687            Ok(())
688        })
689        .unwrap();
690        drop(alt); // all candidates committed; drop just pops the stack
691
692        assert!(enc.alt_stack.is_empty(), "stack empty after drop");
693        assert_eq!(enc.data, b"best");
694    }
695
696    // ── nesting ───────────────────────────────────────────────────────────
697
698    /// An inner competition is resolved before the outer candidate is committed.
699    #[test]
700    fn nested_alternatives() {
701        let mut enc = Encoder::default();
702
703        let mut outer = enc.try_alternatives();
704
705        // Outer candidate A: header bytes + inner competition.
706        outer
707            .with(|enc| {
708                push(enc, b"A:");
709                let mut inner = enc.try_alternatives(); // inner level pushed
710                inner.with(|enc| {
711                    push(enc, b"long_inner");
712                    Ok(())
713                })?; // 10 bytes
714                inner.with(|enc| {
715                    push(enc, b"in");
716                    Ok(())
717                })?; // 2 bytes - inner winner
718                drop(inner); // inner done; enc = b"A:in"
719                push(enc, b"!");
720                Ok(())
721            })
722            .unwrap(); // outer candidate A = b"A:in!" (5 bytes)
723
724        // Outer candidate B: shorter overall.
725        outer
726            .with(|enc| {
727                push(enc, b"B");
728                Ok(())
729            })
730            .unwrap(); // 1 byte - winner
731        drop(outer);
732
733        assert_eq!(enc.data, b"B");
734    }
735
736    /// Stack depth tracks nesting level; inner guard drops before outer closure returns.
737    #[test]
738    fn nesting_depth_reflected_in_stack() {
739        let mut enc = Encoder::default();
740
741        assert_eq!(enc.alt_stack.len(), 0);
742        let mut outer = enc.try_alternatives();
743
744        outer
745            .with(|enc| {
746                assert_eq!(enc.alt_stack.len(), 1); // outer level on stack
747                let mut inner = enc.try_alternatives();
748                inner.with(|enc| {
749                    assert_eq!(enc.alt_stack.len(), 2); // both levels on stack
750                    push(enc, b"x");
751                    Ok(())
752                })?;
753                drop(inner); // inner popped
754                assert_eq!(enc.alt_stack.len(), 1);
755                push(enc, b"y");
756                Ok(())
757            })
758            .unwrap();
759
760        drop(outer); // outer popped
761        assert_eq!(enc.alt_stack.len(), 0);
762    }
763
764    // ── meta buffer tracking ──────────────────────────────────────────────
765
766    /// Writes to both `data` and `meta` are rolled back for the losing
767    /// candidate and kept for the winner, measured by total bytes.
768    #[test]
769    fn alternatives_tracks_meta_and_data() {
770        let mut enc = Encoder::default();
771        enc.data.extend_from_slice(b"D");
772        enc.meta.extend_from_slice(b"M");
773
774        let mut alt = enc.try_alternatives();
775        // Candidate A: 4 data + 2 meta = 6 total
776        alt.with(|enc| {
777            push(enc, b"DDDD");
778            enc.meta.extend_from_slice(b"mm");
779            Ok(())
780        })
781        .unwrap();
782        // Candidate B: 1 data + 1 meta = 2 total - winner
783        alt.with(|enc| {
784            push(enc, b"d");
785            enc.meta.extend_from_slice(b"n");
786            Ok(())
787        })
788        .unwrap();
789        drop(alt);
790
791        assert_eq!(enc.data, b"Dd");
792        assert_eq!(enc.meta, b"Mn");
793    }
794
795    // ── error rollback ────────────────────────────────────────────────────
796
797    /// A failing candidate is rolled back; prior best is preserved.
798    #[test]
799    fn error_candidate_is_rolled_back() {
800        let mut enc = Encoder::default();
801
802        let mut alt = enc.try_alternatives();
803        alt.with(|enc| {
804            push(enc, b"ok");
805            Ok(())
806        })
807        .unwrap();
808        let _ = alt.with(|enc| {
809            push(enc, b"partial");
810            Err(MltError::IntegerOverflow) // simulated failure
811        });
812        drop(alt);
813
814        assert_eq!(enc.data, b"ok"); // "partial" was rolled back; "ok" kept
815    }
816}