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