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