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