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