Skip to main content

vortex_layout/layouts/dict/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::Context;
7use std::task::Poll;
8
9use async_stream::stream;
10use async_stream::try_stream;
11use async_trait::async_trait;
12use futures::FutureExt;
13use futures::Stream;
14use futures::StreamExt;
15use futures::TryStreamExt;
16use futures::future::BoxFuture;
17use futures::pin_mut;
18use futures::stream::BoxStream;
19use futures::stream::once;
20use futures::try_join;
21use vortex_array::ArrayContext;
22use vortex_array::ArrayRef;
23use vortex_array::ExecutionCtx;
24use vortex_array::IntoArray;
25use vortex_array::VortexSessionExecute;
26use vortex_array::arrays::Dict;
27use vortex_array::builders::dict::DictConstraints;
28use vortex_array::builders::dict::DictEncoder;
29use vortex_array::builders::dict::dict_encoder;
30use vortex_array::dtype::DType;
31use vortex_array::dtype::Nullability;
32use vortex_array::dtype::PType;
33use vortex_error::VortexError;
34use vortex_error::VortexExpect;
35use vortex_error::VortexResult;
36use vortex_error::vortex_err;
37use vortex_io::kanal_ext::KanalExt;
38use vortex_io::session::RuntimeSessionExt;
39use vortex_session::VortexSession;
40
41use crate::IntoLayout;
42use crate::LayoutRef;
43use crate::LayoutStrategy;
44use crate::OwnedLayoutChildren;
45use crate::layouts::chunked::ChunkedLayout;
46use crate::layouts::compressed::CompressorPlugin;
47use crate::layouts::dict::DictLayout;
48use crate::segments::SegmentSinkRef;
49use crate::sequence::SendableSequentialStream;
50use crate::sequence::SequenceId;
51use crate::sequence::SequencePointer;
52use crate::sequence::SequentialStream;
53use crate::sequence::SequentialStreamAdapter;
54use crate::sequence::SequentialStreamExt;
55
56/// Constraints for dictionary layout encoding.
57///
58/// Note that [`max_len`](Self::max_len) is limited to `u16` (65,535 entries) by design. Since
59/// layout chunks are typically ~8k elements, having more than 64k unique values in a dictionary
60/// means dictionary encoding provides little compression benefit. If a column has very high
61/// cardinality, the fallback encoding strategy should be used instead.
62#[derive(Clone)]
63pub struct DictLayoutConstraints {
64    /// Maximum size of the dictionary in bytes.
65    pub max_bytes: usize,
66    /// Maximum dictionary length. Limited to `u16` because dictionaries with more than 64k unique
67    /// values provide diminishing compression returns given typical chunk sizes (~8k elements).
68    ///
69    /// The codes dtype is determined upfront from this constraint:
70    /// - [`PType::U8`] when max_len <= 255
71    /// - [`PType::U16`] when max_len > 255
72    ///
73    /// Vortex encoders must always produce unsigned integer codes; signed codes are only accepted for external compatibility.
74    pub max_len: u16,
75}
76
77impl From<DictLayoutConstraints> for DictConstraints {
78    fn from(value: DictLayoutConstraints) -> Self {
79        DictConstraints {
80            max_bytes: value.max_bytes,
81            max_len: value.max_len as usize,
82        }
83    }
84}
85
86impl Default for DictLayoutConstraints {
87    fn default() -> Self {
88        Self {
89            max_bytes: 1024 * 1024,
90            max_len: u16::MAX,
91        }
92    }
93}
94
95#[derive(Clone, Default)]
96pub struct DictLayoutOptions {
97    pub constraints: DictLayoutConstraints,
98}
99
100/// A layout strategy that encodes chunk into values and codes, if found
101/// appropriate by the btrblocks compressor. Current implementation only
102/// checks the first chunk to decide whether to apply dict layout and
103/// encodes chunks into dictionaries. When the dict constraints are hit, a
104/// new dictionary is created.
105#[derive(Clone)]
106pub struct DictStrategy {
107    codes: Arc<dyn LayoutStrategy>,
108    values: Arc<dyn LayoutStrategy>,
109    fallback: Arc<dyn LayoutStrategy>,
110    options: DictLayoutOptions,
111    probe_compressor: Arc<dyn CompressorPlugin>,
112}
113
114impl DictStrategy {
115    pub fn new<Codes: LayoutStrategy, Values: LayoutStrategy, Fallback: LayoutStrategy>(
116        codes: Codes,
117        values: Values,
118        fallback: Fallback,
119        options: DictLayoutOptions,
120        probe_compressor: Arc<dyn CompressorPlugin>,
121    ) -> Self {
122        Self {
123            codes: Arc::new(codes),
124            values: Arc::new(values),
125            fallback: Arc::new(fallback),
126            options,
127            probe_compressor,
128        }
129    }
130}
131
132#[async_trait]
133impl LayoutStrategy for DictStrategy {
134    async fn write_stream(
135        &self,
136        ctx: ArrayContext,
137        segment_sink: SegmentSinkRef,
138        stream: SendableSequentialStream,
139        mut eof: SequencePointer,
140        session: &VortexSession,
141    ) -> VortexResult<LayoutRef> {
142        // Fallback if dtype is not supported
143        if !dict_layout_supported(stream.dtype()) {
144            return self
145                .fallback
146                .write_stream(ctx, segment_sink, stream, eof, session)
147                .await;
148        }
149
150        let options = self.options.clone();
151        let dtype = stream.dtype().clone();
152
153        // 0. decide if chunks are eligible for dict encoding
154        let (stream, first_chunk) = peek_first_chunk(stream).await?;
155        let stream = SequentialStreamAdapter::new(dtype.clone(), stream).sendable();
156
157        let should_fallback = match first_chunk {
158            None => true, // empty stream
159            Some(chunk) => {
160                let mut exec_ctx = session.create_execution_ctx();
161                let compressed = self
162                    .probe_compressor
163                    .compress_chunk(&chunk, &mut exec_ctx)?;
164                !compressed.is::<Dict>()
165            }
166        };
167        if should_fallback {
168            // first chunk did not compress to dict, or did not exist. Skip dict layout
169            return self
170                .fallback
171                .write_stream(ctx, segment_sink, stream, eof, session)
172                .await;
173        }
174
175        // 1. from a chunk stream, create a stream that yields codes
176        // followed by a single value chunk when dict constraints are hit.
177        // (a1, a2) -> (code(c1), code(c2), values(v1), code(c3), ...)
178        let dict_stream = dict_encode_stream(
179            stream,
180            options.constraints.into(),
181            session.create_execution_ctx(),
182        );
183
184        // Wrap up the dict stream to yield pairs of (codes_stream, values_future).
185        // Each of these pairs becomes a child dict layout.
186        let runs = DictionaryTransformer::new(dict_stream);
187
188        let handle = session.handle();
189        let dtype2 = dtype.clone();
190        let child_layouts = stream! {
191            pin_mut!(runs);
192
193            while let Some((codes_stream, values_fut)) = runs.next().await {
194                let codes = Arc::clone(&self.codes);
195                let codes_eof = eof.split_off();
196                let ctx2 = ctx.clone();
197                let segment_sink2 = Arc::clone(&segment_sink);
198                let session2 = session.clone();
199                let codes_fut = handle.spawn_nested(move |h| async move {
200                    let session2 = session2.with_handle(h);
201                    codes.write_stream(
202                        ctx2,
203                        segment_sink2,
204                        codes_stream.sendable(),
205                        codes_eof,
206                        &session2,
207                    ).await
208                });
209
210                let values = Arc::clone(&self.values);
211                let values_eof = eof.split_off();
212                let ctx2 = ctx.clone();
213                let segment_sink2 = Arc::clone(&segment_sink);
214                let dtype2 = dtype2.clone();
215                let session2 = session.clone();
216                let values_layout = handle.spawn_nested(move |h| async move {
217                    let session2 = session2.with_handle(h);
218                    values.write_stream(
219                        ctx2,
220                        segment_sink2,
221                        SequentialStreamAdapter::new(dtype2, once(values_fut)).sendable(),
222                        values_eof,
223                        &session2,
224                    ).await
225                });
226
227                yield async move {
228                    try_join!(codes_fut, values_layout)
229                }.boxed();
230            }
231        };
232
233        let mut child_layouts = child_layouts
234            .buffered(usize::MAX)
235            .map(|result| {
236                let (codes_layout, values_layout) = result?;
237                // All values are referenced when created via dictionary encoding
238                Ok::<_, VortexError>(DictLayout::new(values_layout, codes_layout).into_layout())
239            })
240            .try_collect::<Vec<_>>()
241            .await?;
242
243        if child_layouts.len() == 1 {
244            return Ok(child_layouts.remove(0));
245        }
246
247        let row_count = child_layouts.iter().map(|child| child.row_count()).sum();
248        Ok(ChunkedLayout::new(
249            row_count,
250            dtype,
251            OwnedLayoutChildren::layout_children(child_layouts),
252        )
253        .into_layout())
254    }
255
256    fn buffered_bytes(&self) -> u64 {
257        self.codes.buffered_bytes() + self.values.buffered_bytes() + self.fallback.buffered_bytes()
258    }
259}
260
261enum DictionaryChunk {
262    Codes {
263        seq_id: SequenceId,
264        codes: ArrayRef,
265        codes_ptype: PType,
266    },
267    Values((SequenceId, ArrayRef)),
268}
269
270type DictionaryStream = BoxStream<'static, VortexResult<DictionaryChunk>>;
271
272fn dict_encode_stream(
273    input: SendableSequentialStream,
274    constraints: DictConstraints,
275    mut exec_ctx: ExecutionCtx,
276) -> DictionaryStream {
277    Box::pin(try_stream! {
278        let mut state = DictStreamState {
279            encoder: None,
280            constraints,
281        };
282
283        let input = input.peekable();
284        pin_mut!(input);
285
286        while let Some(item) = input.next().await {
287            let (sequence_id, chunk) = item?;
288
289            // labeler potentially creates sub sequences, we must
290            // create it on both arms to avoid having a SequencePointer
291            // between await points
292            match input.as_mut().peek().await {
293                Some(_) => {
294                    let mut labeler = DictChunkLabeler::new(sequence_id);
295                    let chunks = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
296                    drop(labeler);
297                    for dict_chunk in chunks {
298                        yield dict_chunk;
299                    }
300                }
301                None => {
302                    // this is the last element, encode and drain chunks
303                    let mut labeler = DictChunkLabeler::new(sequence_id);
304                    let encoded = state.encode(&mut labeler, chunk, &mut exec_ctx)?;
305                    let drained = state.drain_values(&mut labeler);
306                    drop(labeler);
307                    for dict_chunk in encoded.into_iter().chain(drained.into_iter()) {
308                        yield dict_chunk;
309                    }
310                }
311            }
312        }
313    })
314}
315
316struct DictStreamState {
317    encoder: Option<Box<dyn DictEncoder>>,
318    constraints: DictConstraints,
319}
320
321impl DictStreamState {
322    fn encode(
323        &mut self,
324        labeler: &mut DictChunkLabeler,
325        chunk: ArrayRef,
326        exec_ctx: &mut ExecutionCtx,
327    ) -> VortexResult<Vec<DictionaryChunk>> {
328        let mut res = Vec::new();
329        let mut to_be_encoded = Some(chunk);
330        while let Some(remaining) = to_be_encoded.take() {
331            match self.encoder.take() {
332                None => match start_encoding(&self.constraints, &remaining, exec_ctx)? {
333                    EncodingState::Continue((encoder, encoded)) => {
334                        let ptype = encoder.codes_ptype();
335                        res.push(labeler.codes(encoded, ptype));
336                        self.encoder = Some(encoder);
337                    }
338                    EncodingState::Done((values, encoded, unencoded)) => {
339                        // Encoder was created and consumed within start_encoding
340                        let ptype = PType::try_from(encoded.dtype())
341                            .vortex_expect("codes should be primitive");
342                        res.push(labeler.codes(encoded, ptype));
343                        res.push(labeler.values(values));
344                        to_be_encoded = Some(unencoded);
345                    }
346                },
347                Some(encoder) => {
348                    let ptype = encoder.codes_ptype();
349                    match encode_chunk(encoder, &remaining, exec_ctx)? {
350                        EncodingState::Continue((encoder, encoded)) => {
351                            res.push(labeler.codes(encoded, ptype));
352                            self.encoder = Some(encoder);
353                        }
354                        EncodingState::Done((values, encoded, unencoded)) => {
355                            res.push(labeler.codes(encoded, ptype));
356                            res.push(labeler.values(values));
357                            to_be_encoded = Some(unencoded);
358                        }
359                    }
360                }
361            }
362        }
363        Ok(res)
364    }
365
366    fn drain_values(&mut self, labeler: &mut DictChunkLabeler) -> Vec<DictionaryChunk> {
367        match self.encoder.as_mut() {
368            None => Vec::new(),
369            Some(encoder) => vec![labeler.values(encoder.reset())],
370        }
371    }
372}
373
374struct DictChunkLabeler {
375    sequence_pointer: SequencePointer,
376}
377
378impl DictChunkLabeler {
379    fn new(starting_id: SequenceId) -> Self {
380        let sequence_pointer = starting_id.descend();
381        Self { sequence_pointer }
382    }
383
384    fn codes(&mut self, chunk: ArrayRef, ptype: PType) -> DictionaryChunk {
385        DictionaryChunk::Codes {
386            seq_id: self.sequence_pointer.advance(),
387            codes: chunk,
388            codes_ptype: ptype,
389        }
390    }
391
392    fn values(&mut self, chunk: ArrayRef) -> DictionaryChunk {
393        DictionaryChunk::Values((self.sequence_pointer.advance(), chunk))
394    }
395}
396
397type SequencedChunk = VortexResult<(SequenceId, ArrayRef)>;
398
399struct DictionaryTransformer {
400    input: DictionaryStream,
401    active_codes_tx: Option<kanal::AsyncSender<SequencedChunk>>,
402    active_values_tx: Option<oneshot::Sender<SequencedChunk>>,
403    pending_send: Option<BoxFuture<'static, Result<(), kanal::SendError>>>,
404}
405
406impl DictionaryTransformer {
407    fn new(input: DictionaryStream) -> Self {
408        Self {
409            input,
410            active_codes_tx: None,
411            active_values_tx: None,
412            pending_send: None,
413        }
414    }
415}
416
417impl Stream for DictionaryTransformer {
418    type Item = (SendableSequentialStream, BoxFuture<'static, SequencedChunk>);
419
420    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
421        loop {
422            // First, try to complete any pending send
423            if let Some(mut send_fut) = self.pending_send.take() {
424                match send_fut.poll_unpin(cx) {
425                    Poll::Ready(Ok(())) => {
426                        // Send completed, continue processing
427                    }
428                    Poll::Ready(Err(_)) => {
429                        // Receiver dropped, close this group
430                        self.active_codes_tx = None;
431                        if let Some(values_tx) = self.active_values_tx.take() {
432                            drop(values_tx.send(Err(vortex_err!("values receiver dropped"))));
433                        }
434                    }
435                    Poll::Pending => {
436                        // Still pending, save it and return
437                        self.pending_send = Some(send_fut);
438                        return Poll::Pending;
439                    }
440                }
441            }
442
443            match self.input.poll_next_unpin(cx) {
444                Poll::Ready(Some(Ok(DictionaryChunk::Codes {
445                    seq_id,
446                    codes,
447                    codes_ptype,
448                }))) => {
449                    if self.active_codes_tx.is_none() {
450                        // Start a new group
451                        let (codes_tx, codes_rx) = kanal::bounded_async::<SequencedChunk>(1);
452                        let (values_tx, values_rx) = oneshot::channel();
453
454                        self.active_codes_tx = Some(codes_tx.clone());
455                        self.active_values_tx = Some(values_tx);
456
457                        // Use passed codes_ptype instead of getting from array
458                        let codes_dtype = DType::Primitive(codes_ptype, Nullability::NonNullable);
459
460                        // Send first codes.
461                        self.pending_send =
462                            Some(Box::pin(
463                                async move { codes_tx.send(Ok((seq_id, codes))).await },
464                            ));
465
466                        // Create output streams.
467                        let codes_stream = SequentialStreamAdapter::new(
468                            codes_dtype,
469                            codes_rx.into_stream().boxed(),
470                        )
471                        .sendable();
472
473                        let values_future = async move {
474                            values_rx
475                                .await
476                                .map_err(|e| vortex_err!("values sender dropped: {}", e))
477                                .flatten()
478                        }
479                        .boxed();
480
481                        return Poll::Ready(Some((codes_stream, values_future)));
482                    }
483
484                    // Continue streaming codes to existing group
485                    if let Some(tx) = &self.active_codes_tx {
486                        let tx = tx.clone();
487                        self.pending_send =
488                            Some(Box::pin(async move { tx.send(Ok((seq_id, codes))).await }));
489                    }
490                }
491                Poll::Ready(Some(Ok(DictionaryChunk::Values(values)))) => {
492                    // Complete the current group
493                    if let Some(values_tx) = self.active_values_tx.take() {
494                        drop(values_tx.send(Ok(values)));
495                    }
496                    self.active_codes_tx = None; // Close codes stream
497                }
498                Poll::Ready(Some(Err(e))) => {
499                    // Send error to active channels if any
500                    if let Some(values_tx) = self.active_values_tx.take() {
501                        drop(values_tx.send(Err(e)));
502                    }
503                    self.active_codes_tx = None;
504                    // And terminate the stream
505                    return Poll::Ready(None);
506                }
507                Poll::Ready(None) => {
508                    // Handle any incomplete group
509                    if let Some(values_tx) = self.active_values_tx.take() {
510                        drop(values_tx.send(Err(vortex_err!("Incomplete dictionary group"))));
511                    }
512                    self.active_codes_tx = None;
513                    return Poll::Ready(None);
514                }
515                Poll::Pending => return Poll::Pending,
516            }
517        }
518    }
519}
520
521async fn peek_first_chunk(
522    mut stream: BoxStream<'static, SequencedChunk>,
523) -> VortexResult<(BoxStream<'static, SequencedChunk>, Option<ArrayRef>)> {
524    match stream.next().await {
525        None => Ok((stream.boxed(), None)),
526        Some(Err(e)) => Err(e),
527        Some(Ok((sequence_id, chunk))) => {
528            let chunk_clone = chunk.clone();
529            let reconstructed_stream =
530                once(async move { Ok((sequence_id, chunk_clone)) }).chain(stream);
531            Ok((reconstructed_stream.boxed(), Some(chunk)))
532        }
533    }
534}
535
536pub fn dict_layout_supported(dtype: &DType) -> bool {
537    matches!(
538        dtype,
539        DType::Primitive(..) | DType::Utf8(_) | DType::Binary(_)
540    )
541}
542
543#[derive(prost::Message)]
544pub struct DictLayoutMetadata {
545    #[prost(enumeration = "PType", tag = "1")]
546    // i32 is required for proto, use the generated getter to read this field.
547    codes_ptype: i32,
548}
549
550impl DictLayoutMetadata {
551    pub fn new(codes_ptype: PType) -> Self {
552        let mut metadata = Self::default();
553        metadata.set_codes_ptype(codes_ptype);
554        metadata
555    }
556}
557
558enum EncodingState {
559    Continue((Box<dyn DictEncoder>, ArrayRef)),
560    // (values, encoded, unencoded)
561    Done((ArrayRef, ArrayRef, ArrayRef)),
562}
563
564fn start_encoding(
565    constraints: &DictConstraints,
566    chunk: &ArrayRef,
567    ctx: &mut ExecutionCtx,
568) -> VortexResult<EncodingState> {
569    let encoder = dict_encoder(chunk, constraints);
570    encode_chunk(encoder, chunk, ctx)
571}
572
573fn encode_chunk(
574    mut encoder: Box<dyn DictEncoder>,
575    chunk: &ArrayRef,
576    ctx: &mut ExecutionCtx,
577) -> VortexResult<EncodingState> {
578    let encoded = encoder.encode(chunk, ctx)?.into_array();
579    match remainder(chunk, encoded.len())? {
580        None => Ok(EncodingState::Continue((encoder, encoded))),
581        Some(unencoded) => Ok(EncodingState::Done((encoder.reset(), encoded, unencoded))),
582    }
583}
584
585fn remainder(array: &ArrayRef, encoded_len: usize) -> VortexResult<Option<ArrayRef>> {
586    if encoded_len < array.len() {
587        Ok(Some(array.slice(encoded_len..array.len())?))
588    } else {
589        Ok(None)
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use std::sync::LazyLock;
596
597    use futures::StreamExt;
598    use vortex_array::IntoArray;
599    use vortex_array::VortexSessionExecute;
600    use vortex_array::arrays::VarBinArray;
601    use vortex_array::builders::dict::DictConstraints;
602    use vortex_array::dtype::DType;
603    use vortex_array::dtype::Nullability::NonNullable;
604    use vortex_array::dtype::PType;
605    use vortex_array::session::ArraySession;
606    use vortex_session::VortexSession;
607
608    use super::DictionaryTransformer;
609    use super::dict_encode_stream;
610    use crate::sequence::SequenceId;
611    use crate::sequence::SequentialStream;
612    use crate::sequence::SequentialStreamAdapter;
613    use crate::sequence::SequentialStreamExt;
614
615    static SESSION: LazyLock<VortexSession> =
616        LazyLock::new(|| VortexSession::empty().with::<ArraySession>());
617
618    /// Regression test for a bug where the codes stream dtype was hardcoded to U16 instead of
619    /// using the actual codes dtype from the array. When `max_len <= 255`, the dict encoder
620    /// produces U8 codes, but the stream was incorrectly typed as U16, causing a dtype mismatch
621    /// assertion failure in [`SequentialStreamAdapter`].
622    #[tokio::test]
623    async fn test_dict_transformer_uses_u8_for_small_dictionaries() {
624        // Use max_len = 100 to force U8 codes (since 100 <= 255).
625        let constraints = DictConstraints {
626            max_bytes: 1024 * 1024,
627            max_len: 100,
628        };
629
630        // Create a simple string array with a few unique values.
631        let arr = VarBinArray::from(vec!["hello", "world", "hello", "world"]).into_array();
632
633        // Wrap into a sequential stream.
634        let mut pointer = SequenceId::root();
635        let input_stream = SequentialStreamAdapter::new(
636            arr.dtype().clone(),
637            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
638        )
639        .sendable();
640
641        // Encode into dict chunks.
642        let dict_stream =
643            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
644
645        // Transform into codes/values streams.
646        let mut transformer = DictionaryTransformer::new(dict_stream);
647
648        // Get the first (and only) run.
649        let (codes_stream, _values_fut) = transformer
650            .next()
651            .await
652            .expect("expected at least one dictionary run");
653
654        // The key assertion: codes stream dtype should be U8, not U16.
655        assert_eq!(
656            codes_stream.dtype(),
657            &DType::Primitive(PType::U8, NonNullable),
658            "codes stream should use U8 dtype for small dictionaries, not U16"
659        );
660    }
661
662    /// Test that the codes stream uses U16 dtype when the dictionary has more than 255 entries.
663    #[tokio::test]
664    async fn test_dict_transformer_uses_u16_for_large_dictionaries() {
665        // Use max_len = 1000 to allow U16 codes (since 1000 > 255).
666        let constraints = DictConstraints {
667            max_bytes: 1024 * 1024,
668            max_len: 1000,
669        };
670
671        // Create an array with more than 255 distinct values to force U16 codes.
672        let values: Vec<String> = (0..300).map(|i| format!("value_{i}")).collect();
673        let arr =
674            VarBinArray::from(values.iter().map(|s| s.as_str()).collect::<Vec<_>>()).into_array();
675
676        // Wrap into a sequential stream.
677        let mut pointer = SequenceId::root();
678        let input_stream = SequentialStreamAdapter::new(
679            arr.dtype().clone(),
680            futures::stream::once(async move { Ok((pointer.advance(), arr)) }),
681        )
682        .sendable();
683
684        // Encode into dict chunks.
685        let dict_stream =
686            dict_encode_stream(input_stream, constraints, SESSION.create_execution_ctx());
687
688        // Transform into codes/values streams.
689        let mut transformer = DictionaryTransformer::new(dict_stream);
690
691        // Get the first (and only) run.
692        let (codes_stream, _values_fut) = transformer
693            .next()
694            .await
695            .expect("expected at least one dictionary run");
696
697        // Codes stream dtype should be U16 since we have more than 255 distinct values.
698        assert_eq!(
699            codes_stream.dtype(),
700            &DType::Primitive(PType::U16, NonNullable),
701            "codes stream should use U16 dtype for dictionaries with >255 entries"
702        );
703    }
704}