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}