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