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