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}