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