Skip to main content

questdb/ingress/column_sender/
numpy_wire.rs

1/*******************************************************************************
2 *     ___                  _   ____  ____
3 *    / _ \ _   _  ___  ___| |_|  _ \| __ )
4 *   | | | | | | |/ _ \/ __| __| | | |  _ \
5 *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
6 *    \__\_\\__,_|\___||___/\__|____/|____/
7 *
8 *  Copyright (c) 2014-2019 Appsicle
9 *  Copyright (c) 2019-2025 QuestDB
10 *
11 *  Licensed under the Apache License, Version 2.0 (the "License");
12 *  you may not use this file except in compliance with the License.
13 *  You may obtain a copy of the License at
14 *
15 *  http://www.apache.org/licenses/LICENSE-2.0
16 *
17 *  Unless required by applicable law or agreed to in writing, software
18 *  distributed under the License is distributed on an "AS IS" BASIS,
19 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 *  See the License for the specific language governing permissions and
21 *  limitations under the License.
22 *
23 ******************************************************************************/
24
25//! Numpy-side wire encoder. Walks a raw, contiguous, native-endian numpy
26//! buffer described by [`NumpyDtype`] and writes the QWP column body
27//! straight into the connection's outbound buffer.
28//!
29//! This module is intentionally **independent of arrow-rs**: it shares
30//! the QWP wire-format constants with [`super::wire`] and the
31//! [`ValidityDescriptor`] shape with [`super::chunk`], and nothing
32//! else. The numpy entry point can build (and run at full coverage)
33//! without the `arrow` Cargo feature.
34
35use std::slice;
36
37use crate::ingress::{MAX_ARRAY_DIMS, MAX_NDARRAY_LEAF_ELEMS};
38use crate::{Result, error};
39
40use super::chunk::ValidityDescriptor;
41use super::wire::{
42    F32_NULL, F64_NULL, I8_NULL, I16_NULL, I32_NULL, I64_NULL, QWP_TYPE_BOOLEAN, QWP_TYPE_BYTE,
43    QWP_TYPE_CHAR, QWP_TYPE_DATE, QWP_TYPE_DECIMAL64, QWP_TYPE_DECIMAL128, QWP_TYPE_DECIMAL256,
44    QWP_TYPE_DOUBLE, QWP_TYPE_DOUBLE_ARRAY, QWP_TYPE_FLOAT, QWP_TYPE_GEOHASH, QWP_TYPE_INT,
45    QWP_TYPE_IPV4, QWP_TYPE_LONG, QWP_TYPE_LONG256, QWP_TYPE_SHORT, QWP_TYPE_TIMESTAMP,
46    QWP_TYPE_TIMESTAMP_NANOS, QWP_TYPE_UUID, write_qwp_varint,
47};
48
49/// Numpy source-dtype tag. The chunk's `NumpyDeferred` variant stores
50/// one; the encoder walks it at flush.
51///
52/// Scale (decimal) and bit-width (geohash) values must be validated by
53/// the caller (push_numpy_deferred / the FFI dispatcher) before being
54/// embedded — emit code trusts them and does not re-check ranges.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum NumpyDtype {
58    // ---- Direct (zero-copy bulk emit) ----
59    I64Direct,
60    F64Direct,
61    DateI64Direct,
62    TimestampMicrosDirect,
63    TimestampNanosDirect,
64    LongDirect,
65    UuidDirect,
66    Long256Direct,
67    Ipv4Direct,
68    CharDirect,
69
70    // ---- Direct narrow signed integers (sentinel-encoded; BYTE/SHORT
71    // ----- use value 0 as the null sentinel) ----
72    I8Direct,
73    I16Direct,
74    I32Direct,
75
76    // ---- Signed widen to next-up signed wire to avoid sentinel
77    // ----- collision with source value range ----
78    I8WidenToI32,
79    I16WidenToI32,
80    I32WidenToI64,
81
82    // ---- Unsigned widen to smallest signed wire that holds the source
83    // ----- range WITHOUT colliding with the null sentinel ----
84    U8WidenToI32,
85    U16WidenToI32,
86    U32WidenToI64,
87    U64WidenToI64,
88
89    // ---- f16 widen (no f16 wire type); f32 direct ----
90    F32Direct,
91    F16Widen,
92
93    // ---- Other per-row conversions ----
94    Bool,
95    DatetimeSecToMicros,
96    DatetimeMinuteToMicros,
97    DatetimeHourToMicros,
98    DatetimeDayToMicros,
99    DatetimeWeekToMicros,
100    DatetimeMonthToMicros,
101    DatetimeYearToMicros,
102
103    // ---- Decimal (scale carried) ----
104    Decimal64 {
105        scale: u8,
106    },
107    Decimal128 {
108        scale: u8,
109    },
110    Decimal256 {
111        scale: u8,
112    },
113
114    // ---- Geohash (bits carried) ----
115    GeohashI8 {
116        bits: u8,
117    },
118    GeohashI16 {
119        bits: u8,
120    },
121    GeohashI32 {
122        bits: u8,
123    },
124    GeohashI64 {
125        bits: u8,
126    },
127
128    /// f64 ndarray: rectangular tensor of shape `(row_count, dim[0], dim[1], …)`.
129    /// `ndim` is `1..=MAX_ARRAY_DIMS`; only the first `ndim` entries of
130    /// `shape` are meaningful — trailing entries are zero. All rows share
131    /// this shape (numpy ndarrays are rectangular).
132    F64Ndarray {
133        ndim: u8,
134        shape: [u32; MAX_ARRAY_DIMS],
135    },
136}
137
138impl NumpyDtype {
139    /// QWP wire-type byte for the column slot this dtype produces.
140    pub fn wire_type(&self) -> u8 {
141        use NumpyDtype as D;
142        match self {
143            D::I8Direct => QWP_TYPE_BYTE,
144            D::I16Direct => QWP_TYPE_SHORT,
145            D::I32Direct
146            | D::I8WidenToI32
147            | D::I16WidenToI32
148            | D::U8WidenToI32
149            | D::U16WidenToI32 => QWP_TYPE_INT,
150            D::I64Direct
151            | D::LongDirect
152            | D::I32WidenToI64
153            | D::U32WidenToI64
154            | D::U64WidenToI64 => QWP_TYPE_LONG,
155            D::F64Direct => QWP_TYPE_DOUBLE,
156            D::F32Direct | D::F16Widen => QWP_TYPE_FLOAT,
157            D::Bool => QWP_TYPE_BOOLEAN,
158            D::DateI64Direct => QWP_TYPE_DATE,
159            D::TimestampMicrosDirect
160            | D::DatetimeSecToMicros
161            | D::DatetimeMinuteToMicros
162            | D::DatetimeHourToMicros
163            | D::DatetimeDayToMicros
164            | D::DatetimeWeekToMicros
165            | D::DatetimeMonthToMicros
166            | D::DatetimeYearToMicros => QWP_TYPE_TIMESTAMP,
167            D::TimestampNanosDirect => QWP_TYPE_TIMESTAMP_NANOS,
168            D::UuidDirect => QWP_TYPE_UUID,
169            D::Long256Direct => QWP_TYPE_LONG256,
170            D::Ipv4Direct => QWP_TYPE_IPV4,
171            D::CharDirect => QWP_TYPE_CHAR,
172            D::Decimal64 { .. } => QWP_TYPE_DECIMAL64,
173            D::Decimal128 { .. } => QWP_TYPE_DECIMAL128,
174            D::Decimal256 { .. } => QWP_TYPE_DECIMAL256,
175            D::GeohashI8 { .. }
176            | D::GeohashI16 { .. }
177            | D::GeohashI32 { .. }
178            | D::GeohashI64 { .. } => QWP_TYPE_GEOHASH,
179            D::F64Ndarray { .. } => QWP_TYPE_DOUBLE_ARRAY,
180        }
181    }
182
183    /// Per-row wire payload size for the upfront frame-size estimate.
184    /// Bool is bit-packed so the true cost is `row_count.div_ceil(8)`;
185    /// reporting 1 here keeps the estimate as a (correct) over-bound.
186    /// The leading scale / bits byte for decimal / geohash is a fixed
187    /// +1 per column and is rolled into the column's null-overhead
188    /// allowance by the caller.
189    pub fn bytes_per_row(&self) -> usize {
190        use NumpyDtype as D;
191        match self {
192            D::Bool | D::I8Direct => 1,
193            D::I16Direct | D::CharDirect => 2,
194            D::I32Direct
195            | D::I8WidenToI32
196            | D::I16WidenToI32
197            | D::U8WidenToI32
198            | D::U16WidenToI32
199            | D::F32Direct
200            | D::F16Widen
201            | D::Ipv4Direct => 4,
202            D::I64Direct
203            | D::F64Direct
204            | D::LongDirect
205            | D::DateI64Direct
206            | D::TimestampMicrosDirect
207            | D::TimestampNanosDirect
208            | D::DatetimeSecToMicros
209            | D::DatetimeMinuteToMicros
210            | D::DatetimeHourToMicros
211            | D::DatetimeDayToMicros
212            | D::DatetimeWeekToMicros
213            | D::DatetimeMonthToMicros
214            | D::DatetimeYearToMicros
215            | D::I32WidenToI64
216            | D::U32WidenToI64
217            | D::U64WidenToI64
218            | D::Decimal64 { .. } => 8,
219            D::UuidDirect | D::Decimal128 { .. } => 16,
220            D::Long256Direct | D::Decimal256 { .. } => 32,
221            D::GeohashI8 { .. } => 1,
222            D::GeohashI16 { .. } => 2,
223            D::GeohashI32 { .. } => 4,
224            D::GeohashI64 { .. } => 8,
225            D::F64Ndarray { ndim, shape } => {
226                // Per-row: ndim u8 + (dim u32) × ndim + (value f64) × prod(dims).
227                // `nd` is clamped so an unvalidated out-of-range ndim can't
228                // index past the fixed-size `shape`; `validate` rejects it.
229                let nd = (*ndim as usize).min(shape.len());
230                let mut leaf: usize = 1;
231                for &d in &shape[..nd] {
232                    leaf = leaf.saturating_mul(d as usize);
233                }
234                (1usize)
235                    .saturating_add(4usize.saturating_mul(nd))
236                    .saturating_add(8usize.saturating_mul(leaf))
237            }
238        }
239    }
240
241    /// Reject dtype configurations that the encoder cannot safely
242    /// allocate for. Currently bounds `F64Ndarray`'s shape to
243    /// `1..=MAX_ARRAY_DIMS` dimensions, non-zero per-dimension extents,
244    /// and `prod(shape) <= MAX_NDARRAY_LEAF_ELEMS` to keep the per-row
245    /// reservation well under `isize::MAX`. All other variants are
246    /// inherently bounded by their wire-type encoding.
247    pub fn validate(&self) -> Result<()> {
248        if let NumpyDtype::F64Ndarray { ndim, shape } = self {
249            let nd = *ndim as usize;
250            if nd == 0 {
251                return Err(error::fmt!(InvalidApiCall, "F64Ndarray ndim must be >= 1"));
252            }
253            if nd > MAX_ARRAY_DIMS {
254                return Err(error::fmt!(
255                    InvalidApiCall,
256                    "F64Ndarray ndim must be <= {} (MAX_ARRAY_DIMS), got {}",
257                    MAX_ARRAY_DIMS,
258                    nd
259                ));
260            }
261            let mut leaf_count: usize = 1;
262            for (i, &dim) in shape[..nd].iter().enumerate() {
263                if dim == 0 {
264                    return Err(error::fmt!(
265                        InvalidApiCall,
266                        "F64Ndarray shape[{}] must be >= 1, got 0",
267                        i
268                    ));
269                }
270                leaf_count = leaf_count.checked_mul(dim as usize).ok_or_else(|| {
271                    error::fmt!(InvalidApiCall, "F64Ndarray shape product overflows usize")
272                })?;
273                if leaf_count > MAX_NDARRAY_LEAF_ELEMS {
274                    return Err(error::fmt!(
275                        InvalidApiCall,
276                        "F64Ndarray shape product exceeds MAX_NDARRAY_LEAF_ELEMS ({}) at dim {}",
277                        MAX_NDARRAY_LEAF_ELEMS,
278                        i
279                    ));
280                }
281            }
282        }
283        let geohash_bits = match self {
284            NumpyDtype::GeohashI8 { bits } => Some((*bits, 8u8)),
285            NumpyDtype::GeohashI16 { bits } => Some((*bits, 16u8)),
286            NumpyDtype::GeohashI32 { bits } => Some((*bits, 32u8)),
287            NumpyDtype::GeohashI64 { bits } => Some((*bits, 60u8)),
288            _ => None,
289        };
290        if let Some((bits, max_bits)) = geohash_bits
291            && (bits == 0 || bits > max_bits)
292        {
293            return Err(error::fmt!(
294                InvalidApiCall,
295                "geohash bits must be in 1..={}, got {}",
296                max_bits,
297                bits
298            ));
299        }
300        let decimal_scale = match self {
301            NumpyDtype::Decimal64 { scale } => Some((*scale, 18u8)),
302            NumpyDtype::Decimal128 { scale } => Some((*scale, 38u8)),
303            NumpyDtype::Decimal256 { scale } => Some((*scale, 76u8)),
304            _ => None,
305        };
306        if let Some((scale, max_scale)) = decimal_scale
307            && scale > max_scale
308        {
309            return Err(error::fmt!(
310                InvalidApiCall,
311                "decimal scale must be <= {}, got {}",
312                max_scale,
313                scale
314            ));
315        }
316        Ok(())
317    }
318
319    /// Source-buffer stride in bytes per row — how many bytes the
320    /// flush-time encoder (`emit_into_wire`) reads per row from the
321    /// caller's `data` pointer. This is the *source* element width, which
322    /// is decoupled from the wire width (e.g. `U8WidenToI32` reads 1
323    /// source byte but emits 4): the bounds check must use the read
324    /// stride, never the wire stride.
325    ///
326    /// Callers use this to validate a caller-supplied buffer byte length
327    /// against `row_count` *before* parking the raw pointer for deferred,
328    /// zero-copy encode — without it a mis-tagged dtype or an inflated
329    /// `row_count` would walk the pointer past the real allocation at
330    /// flush time (host-memory info-leak onto the wire, or a segfault).
331    ///
332    /// For [`NumpyDtype::F64Ndarray`] each row is a full tensor, so the
333    /// stride is `prod(shape[..ndim]) * size_of::<f64>()`. The shape is
334    /// already range-bounded by [`Self::validate`] (`prod <=
335    /// MAX_NDARRAY_LEAF_ELEMS`, `ndim <= MAX_ARRAY_DIMS`), so the multiply
336    /// cannot overflow once validated; the `checked_mul` is defensive in
337    /// case this is ever called on an unvalidated value.
338    pub fn source_elem_size(&self) -> Result<usize> {
339        use NumpyDtype as D;
340        let n = match self {
341            D::I8Direct | D::I8WidenToI32 | D::U8WidenToI32 | D::Bool | D::GeohashI8 { .. } => 1,
342            D::I16Direct
343            | D::I16WidenToI32
344            | D::U16WidenToI32
345            | D::F16Widen
346            | D::CharDirect
347            | D::GeohashI16 { .. } => 2,
348            D::I32Direct
349            | D::I32WidenToI64
350            | D::U32WidenToI64
351            | D::F32Direct
352            | D::Ipv4Direct
353            | D::GeohashI32 { .. } => 4,
354            D::I64Direct
355            | D::F64Direct
356            | D::LongDirect
357            | D::DateI64Direct
358            | D::TimestampMicrosDirect
359            | D::TimestampNanosDirect
360            | D::U64WidenToI64
361            | D::DatetimeSecToMicros
362            | D::DatetimeMinuteToMicros
363            | D::DatetimeHourToMicros
364            | D::DatetimeDayToMicros
365            | D::DatetimeWeekToMicros
366            | D::DatetimeMonthToMicros
367            | D::DatetimeYearToMicros
368            | D::GeohashI64 { .. }
369            | D::Decimal64 { .. } => 8,
370            D::UuidDirect | D::Decimal128 { .. } => 16,
371            D::Long256Direct | D::Decimal256 { .. } => 32,
372            D::F64Ndarray { ndim, shape } => {
373                let nd = *ndim as usize;
374                if nd == 0 || nd > MAX_ARRAY_DIMS {
375                    return Err(error::fmt!(
376                        InvalidApiCall,
377                        "F64Ndarray ndim must be in 1..={}, got {}",
378                        MAX_ARRAY_DIMS,
379                        nd
380                    ));
381                }
382                let leaf: usize = shape[..nd]
383                    .iter()
384                    .copied()
385                    .map(|d| d as usize)
386                    .try_fold(1usize, |acc, d| acc.checked_mul(d))
387                    .ok_or_else(|| {
388                        error::fmt!(InvalidApiCall, "F64Ndarray shape product overflows usize")
389                    })?;
390                return leaf.checked_mul(8).ok_or_else(|| {
391                    error::fmt!(InvalidApiCall, "F64Ndarray row size overflows usize")
392                });
393            }
394        };
395        Ok(n)
396    }
397}
398
399/// Encode one numpy column body straight into `out`.
400///
401/// # Safety
402///
403/// `data` must be either NULL with `row_count == 0`, or point to at
404/// least `row_count * size_of(<source dtype>)` valid contiguous bytes
405/// (one byte per row for `Bool`; for the n-dimensional `F64Ndarray`
406/// variant each row is a full tensor, so the requirement is
407/// `row_count * prod(shape) * 8` bytes). `validity`, if present, must
408/// reference a bitmap of at least `ceil(row_count / 8)` bytes; the caller
409/// is responsible for keeping all referenced memory alive for the
410/// duration of the call.
411pub(crate) unsafe fn emit_into_wire(
412    out: &mut Vec<u8>,
413    dtype: NumpyDtype,
414    data: *const u8,
415    row_count: usize,
416    validity: Option<&ValidityDescriptor>,
417) -> Result<()> {
418    use NumpyDtype as D;
419    match dtype {
420        // ---- Direct sentinel-encoded LE ----
421        D::I64Direct | D::LongDirect => unsafe {
422            emit_sentinel_le::<i64, 8>(
423                out,
424                data,
425                row_count,
426                validity,
427                I64_NULL.to_le_bytes(),
428                i64::to_le_bytes,
429            )
430        },
431        D::F64Direct => unsafe {
432            emit_sentinel_le::<f64, 8>(
433                out,
434                data,
435                row_count,
436                validity,
437                F64_NULL.to_le_bytes(),
438                f64::to_le_bytes,
439            )
440        },
441        D::CharDirect => unsafe {
442            emit_sentinel_le::<u16, 2>(out, data, row_count, validity, [0u8; 2], u16::to_le_bytes)
443        },
444
445        // ---- Direct bitmap-encoded LE ----
446        D::DateI64Direct => unsafe {
447            emit_bitmap_le::<i64, 8>(out, data, row_count, validity, i64::to_le_bytes)
448        },
449        D::TimestampMicrosDirect | D::TimestampNanosDirect => unsafe {
450            emit_bitmap_le::<i64, 8>(out, data, row_count, validity, i64::to_le_bytes)
451        },
452        D::Ipv4Direct => unsafe {
453            emit_bitmap_le::<u32, 4>(out, data, row_count, validity, u32::to_le_bytes)
454        },
455        D::UuidDirect => unsafe { emit_bitmap_fsb::<16>(out, data, row_count, validity) },
456        D::Long256Direct => unsafe { emit_bitmap_fsb::<32>(out, data, row_count, validity) },
457
458        // ---- Direct narrow signed integers (sentinel LE) ----
459        D::I8Direct => unsafe {
460            emit_sentinel_le::<i8, 1>(out, data, row_count, validity, [I8_NULL as u8], |v| {
461                [v as u8]
462            })
463        },
464        D::I16Direct => unsafe {
465            emit_sentinel_le::<i16, 2>(
466                out,
467                data,
468                row_count,
469                validity,
470                I16_NULL.to_le_bytes(),
471                i16::to_le_bytes,
472            )
473        },
474        D::I32Direct => unsafe {
475            emit_sentinel_le::<i32, 4>(
476                out,
477                data,
478                row_count,
479                validity,
480                I32_NULL.to_le_bytes(),
481                i32::to_le_bytes,
482            )
483        },
484
485        // ---- Signed widen (sentinel-safe; mirrors unsigned widen) ----
486        D::I8WidenToI32 => unsafe {
487            emit_widen_i32_sentinel::<i8>(out, data, row_count, validity, I32_NULL, |v| v as i32)
488        },
489        D::I16WidenToI32 => unsafe {
490            emit_widen_i32_sentinel::<i16>(out, data, row_count, validity, I32_NULL, |v| v as i32)
491        },
492        D::I32WidenToI64 => unsafe {
493            emit_widen_i64_sentinel::<i32>(out, data, row_count, validity, I64_NULL, |v| v as i64)
494        },
495
496        // ---- Unsigned widen to smallest signed wire that avoids the
497        // ----- null-sentinel collision (BYTE/SHORT use value 0 as null).
498        D::U8WidenToI32 => unsafe {
499            emit_widen_i32_sentinel::<u8>(out, data, row_count, validity, I32_NULL, |v| v as i32)
500        },
501        D::U16WidenToI32 => unsafe {
502            emit_widen_i32_sentinel::<u16>(out, data, row_count, validity, I32_NULL, |v| v as i32)
503        },
504        D::U32WidenToI64 => unsafe {
505            emit_widen_i64_sentinel::<u32>(out, data, row_count, validity, I64_NULL, |v| v as i64)
506        },
507        D::U64WidenToI64 => unsafe { emit_u64_widen_i64_checked(out, data, row_count, validity)? },
508
509        // ---- f32 sentinel FLOAT ----
510        D::F32Direct => unsafe {
511            emit_sentinel_le::<f32, 4>(
512                out,
513                data,
514                row_count,
515                validity,
516                F32_NULL.to_le_bytes(),
517                f32::to_le_bytes,
518            )
519        },
520
521        // ---- f16 → f32 sentinel FLOAT ----
522        D::F16Widen => unsafe { emit_f16_to_f32(out, data, row_count, validity) },
523
524        // ---- Bool (byte-per-row → packed LSB-first bitmap) ----
525        D::Bool => unsafe { emit_bool(out, data, row_count, validity) },
526
527        // ---- datetime64[s/m/h/D] → ×K → TIMESTAMP (bitmap) ----
528        D::DatetimeSecToMicros => unsafe {
529            emit_i64_to_micros(out, data, row_count, validity, "s", |v| {
530                v.checked_mul(1_000_000)
531            })?
532        },
533        D::DatetimeMinuteToMicros => unsafe {
534            emit_i64_to_micros(out, data, row_count, validity, "m", |v| {
535                v.checked_mul(60_000_000)
536            })?
537        },
538        D::DatetimeHourToMicros => unsafe {
539            emit_i64_to_micros(out, data, row_count, validity, "h", |v| {
540                v.checked_mul(3_600_000_000)
541            })?
542        },
543        D::DatetimeDayToMicros => unsafe {
544            emit_i64_to_micros(out, data, row_count, validity, "D", |v| {
545                v.checked_mul(86_400_000_000)
546            })?
547        },
548        D::DatetimeWeekToMicros => unsafe {
549            emit_i64_to_micros(out, data, row_count, validity, "W", |v| {
550                v.checked_mul(604_800_000_000)
551            })?
552        },
553        // ---- datetime64[M/Y] → calendar → TIMESTAMP (bitmap) ----
554        // `days_from_civil` is comparatively expensive (a few divisions);
555        // most numpy datetime arrays are sorted or near-sorted, so a
556        // single-slot last-value cache absorbs the bulk of repeated
557        // (year, month) inputs without affecting random-data correctness.
558        D::DatetimeMonthToMicros => unsafe {
559            let mut last: Option<(i64, i64)> = None;
560            emit_i64_to_micros(out, data, row_count, validity, "M", |v| {
561                if let Some((k, r)) = last
562                    && k == v
563                {
564                    return Some(r);
565                }
566                let r = month_offset_to_micros(v)?;
567                last = Some((v, r));
568                Some(r)
569            })?
570        },
571        D::DatetimeYearToMicros => unsafe {
572            let mut last: Option<(i64, i64)> = None;
573            emit_i64_to_micros(out, data, row_count, validity, "Y", |v| {
574                if let Some((k, r)) = last
575                    && k == v
576                {
577                    return Some(r);
578                }
579                let r = year_offset_to_micros(v)?;
580                last = Some((v, r));
581                Some(r)
582            })?
583        },
584
585        // ---- Decimal (scale byte + bitmap-encoded fixed-width) ----
586        D::Decimal64 { scale } => unsafe {
587            emit_decimal::<8>(out, scale, data, row_count, validity)
588        },
589        D::Decimal128 { scale } => unsafe {
590            emit_decimal::<16>(out, scale, data, row_count, validity)
591        },
592        D::Decimal256 { scale } => unsafe {
593            emit_decimal::<32>(out, scale, data, row_count, validity)
594        },
595
596        // ---- Geohash (bits byte + bitmap-encoded width-N rows) ----
597        D::GeohashI8 { bits } => unsafe {
598            emit_geohash::<1>(out, bits, data, row_count, validity)?
599        },
600        D::GeohashI16 { bits } => unsafe {
601            emit_geohash::<2>(out, bits, data, row_count, validity)?
602        },
603        D::GeohashI32 { bits } => unsafe {
604            emit_geohash::<4>(out, bits, data, row_count, validity)?
605        },
606        D::GeohashI64 { bits } => unsafe {
607            emit_geohash::<8>(out, bits, data, row_count, validity)?
608        },
609
610        // ---- f64 ndarray (DOUBLE_ARRAY, bitmap-encoded nulls) ----
611        D::F64Ndarray { ndim, shape } => unsafe {
612            emit_f64_ndarray(out, ndim, shape, data, row_count, validity)?
613        },
614    }
615    Ok(())
616}
617
618// ===========================================================================
619// Shared primitives
620// ===========================================================================
621
622/// Sentinel-encoded wire format: `null_flag = 0` + dense `N`-byte rows
623/// (null rows write `sentinel`).
624#[inline]
625unsafe fn emit_sentinel_le<T, const N: usize>(
626    out: &mut Vec<u8>,
627    data: *const u8,
628    row_count: usize,
629    validity: Option<&ValidityDescriptor>,
630    sentinel: [u8; N],
631    to_le: impl Fn(T) -> [u8; N],
632) where
633    T: Copy,
634{
635    out.push(0);
636    out.reserve(N * row_count);
637    let typed = data as *const T;
638    let data_start = out.len();
639    if cfg!(target_endian = "little") {
640        if row_count > 0 {
641            let bytes = unsafe { slice::from_raw_parts(data, row_count * N) };
642            out.extend_from_slice(bytes);
643        }
644    } else {
645        for i in 0..row_count {
646            out.extend_from_slice(&to_le(unsafe { typed.add(i).read_unaligned() }));
647        }
648    }
649    // memcpy the whole slab above, then overwrite only the null slots with the
650    // sentinel — skipping all-valid (0xFF) bitmap bytes 8 rows at a time.
651    let Some(v) = validity.filter(|v| v.has_nulls()) else {
652        return;
653    };
654    let mut i = 0usize;
655    while i < row_count {
656        let byte_idx = i / 8;
657        let bit_off = i % 8;
658        if bit_off == 0 && i + 8 <= row_count && unsafe { *v.bits.add(byte_idx) } == 0xFF {
659            i += 8;
660            continue;
661        }
662        if !unsafe { v.is_valid(i) } {
663            let off = data_start + i * N;
664            out[off..off + N].copy_from_slice(&sentinel);
665        }
666        i += 1;
667    }
668}
669
670/// Bitmap-encoded wire format: `null_flag` (0 or 1) + optional bitmap +
671/// dense `N`-byte rows (non-null only when bitmap present, all rows
672/// otherwise).
673#[inline]
674unsafe fn emit_bitmap_le<T, const N: usize>(
675    out: &mut Vec<u8>,
676    data: *const u8,
677    row_count: usize,
678    validity: Option<&ValidityDescriptor>,
679    to_le: impl Fn(T) -> [u8; N],
680) where
681    T: Copy,
682{
683    let typed = data as *const T;
684    match validity.filter(|v| v.has_nulls()) {
685        None => {
686            out.push(0);
687            out.reserve(N * row_count);
688            if cfg!(target_endian = "little") {
689                if row_count > 0 {
690                    let bytes = unsafe { slice::from_raw_parts(data, row_count * N) };
691                    out.extend_from_slice(bytes);
692                }
693            } else {
694                for i in 0..row_count {
695                    let value = unsafe { typed.add(i).read_unaligned() };
696                    out.extend_from_slice(&to_le(value));
697                }
698            }
699        }
700        Some(v) => {
701            out.push(1);
702            unsafe { write_qwp_bitmap_from_validity(out, v) };
703            out.reserve(N * v.non_null_count);
704            for i in 0..row_count {
705                if unsafe { v.is_valid(i) } {
706                    let value = unsafe { typed.add(i).read_unaligned() };
707                    out.extend_from_slice(&to_le(value));
708                }
709            }
710        }
711    }
712}
713
714/// Bitmap-encoded fixed-size-binary rows (no per-element conversion).
715#[inline]
716unsafe fn emit_bitmap_fsb<const N: usize>(
717    out: &mut Vec<u8>,
718    data: *const u8,
719    row_count: usize,
720    validity: Option<&ValidityDescriptor>,
721) {
722    match validity.filter(|v| v.has_nulls()) {
723        None => {
724            out.push(0);
725            out.reserve(N * row_count);
726            if row_count > 0 {
727                let bytes = unsafe { slice::from_raw_parts(data, N * row_count) };
728                out.extend_from_slice(bytes);
729            }
730        }
731        Some(v) => {
732            out.push(1);
733            unsafe { write_qwp_bitmap_from_validity(out, v) };
734            out.reserve(N * v.non_null_count);
735            for i in 0..row_count {
736                if unsafe { v.is_valid(i) } {
737                    let row_start = unsafe { data.add(i * N) };
738                    let row = unsafe { slice::from_raw_parts(row_start, N) };
739                    out.extend_from_slice(row);
740                }
741            }
742        }
743    }
744}
745
746/// Widen each source value through `widen` (monomorphised per source
747/// dtype), then emit as a sentinel-encoded LE i32 column.
748#[inline]
749unsafe fn emit_widen_i32_sentinel<T>(
750    out: &mut Vec<u8>,
751    data: *const u8,
752    row_count: usize,
753    validity: Option<&ValidityDescriptor>,
754    sentinel: i32,
755    widen: impl Fn(T) -> i32,
756) where
757    T: Copy,
758{
759    out.push(0);
760    out.reserve(4 * row_count);
761    let typed = data as *const T;
762    let sentinel_bytes = sentinel.to_le_bytes();
763    match validity {
764        None => {
765            for i in 0..row_count {
766                let v = unsafe { typed.add(i).read_unaligned() };
767                out.extend_from_slice(&widen(v).to_le_bytes());
768            }
769        }
770        Some(v) => {
771            for i in 0..row_count {
772                if unsafe { v.is_valid(i) } {
773                    let raw = unsafe { typed.add(i).read_unaligned() };
774                    out.extend_from_slice(&widen(raw).to_le_bytes());
775                } else {
776                    out.extend_from_slice(&sentinel_bytes);
777                }
778            }
779        }
780    }
781}
782
783/// Widen each source value through `widen` (monomorphised per source
784/// dtype), then emit as a sentinel-encoded LE i64 column.
785#[inline]
786unsafe fn emit_widen_i64_sentinel<T>(
787    out: &mut Vec<u8>,
788    data: *const u8,
789    row_count: usize,
790    validity: Option<&ValidityDescriptor>,
791    sentinel: i64,
792    widen: impl Fn(T) -> i64,
793) where
794    T: Copy,
795{
796    out.push(0);
797    out.reserve(8 * row_count);
798    let typed = data as *const T;
799    let sentinel_bytes = sentinel.to_le_bytes();
800    match validity {
801        None => {
802            for i in 0..row_count {
803                let v = unsafe { typed.add(i).read_unaligned() };
804                out.extend_from_slice(&widen(v).to_le_bytes());
805            }
806        }
807        Some(v) => {
808            for i in 0..row_count {
809                if unsafe { v.is_valid(i) } {
810                    let raw = unsafe { typed.add(i).read_unaligned() };
811                    out.extend_from_slice(&widen(raw).to_le_bytes());
812                } else {
813                    out.extend_from_slice(&sentinel_bytes);
814                }
815            }
816        }
817    }
818}
819
820#[inline]
821fn u64_to_i64_checked(v: u64, row: usize) -> Result<i64> {
822    if v > i64::MAX as u64 {
823        return Err(error::fmt!(
824            InvalidApiCall,
825            "u64 value {} at row {} does not fit QuestDB LONG (max i64::MAX)",
826            v,
827            row
828        ));
829    }
830    Ok(v as i64)
831}
832
833unsafe fn emit_u64_widen_i64_checked(
834    out: &mut Vec<u8>,
835    data: *const u8,
836    row_count: usize,
837    validity: Option<&ValidityDescriptor>,
838) -> Result<()> {
839    let typed = data as *const u64;
840    if validity.is_none() && row_count > 0 {
841        let mut acc: u64 = 0;
842        for i in 0..row_count {
843            acc |= unsafe { typed.add(i).read_unaligned() };
844        }
845        if acc < (1u64 << 63) {
846            unsafe {
847                emit_widen_i64_sentinel::<u64>(out, data, row_count, validity, I64_NULL, |v| {
848                    v as i64
849                })
850            };
851            return Ok(());
852        }
853    }
854    out.push(0);
855    out.reserve(8 * row_count);
856    let sentinel_bytes = I64_NULL.to_le_bytes();
857    match validity {
858        None => {
859            for i in 0..row_count {
860                let v = unsafe { typed.add(i).read_unaligned() };
861                out.extend_from_slice(&u64_to_i64_checked(v, i)?.to_le_bytes());
862            }
863        }
864        Some(v) => {
865            for i in 0..row_count {
866                if unsafe { v.is_valid(i) } {
867                    let raw = unsafe { typed.add(i).read_unaligned() };
868                    out.extend_from_slice(&u64_to_i64_checked(raw, i)?.to_le_bytes());
869                } else {
870                    out.extend_from_slice(&sentinel_bytes);
871                }
872            }
873        }
874    }
875    Ok(())
876}
877
878/// f16 → f32 (sentinel FLOAT). Implements the IEEE-754 half-precision
879/// → single-precision expansion inline so the module has no `half` /
880/// `arrow_buffer` dependency. Preserves bit-patterns (signaling NaN
881/// bits may differ between platforms; this matches what `half::f16::to_f32`
882/// would emit on x86/aarch64).
883unsafe fn emit_f16_to_f32(
884    out: &mut Vec<u8>,
885    data: *const u8,
886    row_count: usize,
887    validity: Option<&ValidityDescriptor>,
888) {
889    out.push(0);
890    out.reserve(4 * row_count);
891    let typed = data as *const u16;
892    let sentinel = F32_NULL.to_le_bytes();
893    match validity {
894        None => {
895            for i in 0..row_count {
896                let bits = unsafe { typed.add(i).read_unaligned() };
897                out.extend_from_slice(&f16_bits_to_f32(bits).to_le_bytes());
898            }
899        }
900        Some(v) => {
901            for i in 0..row_count {
902                if unsafe { v.is_valid(i) } {
903                    let bits = unsafe { typed.add(i).read_unaligned() };
904                    out.extend_from_slice(&f16_bits_to_f32(bits).to_le_bytes());
905                } else {
906                    out.extend_from_slice(&sentinel);
907                }
908            }
909        }
910    }
911}
912
913/// IEEE-754 binary16 → binary32. Branchless on the common non-special
914/// path; subnormals and NaN/Inf get a per-case fixup. Reproduces the
915/// algorithm `half::f16::to_f32_const` uses.
916#[inline]
917fn f16_bits_to_f32(bits: u16) -> f32 {
918    let sign = ((bits >> 15) as u32) << 31;
919    let exp = ((bits >> 10) & 0x1F) as u32;
920    let mant = (bits & 0x3FF) as u32;
921    let f32_bits = match exp {
922        0 => {
923            if mant == 0 {
924                // +/- zero
925                sign
926            } else {
927                // Subnormal: normalise by shifting until the leading
928                // bit is in position 10, then bias-adjust.
929                let mut m = mant;
930                let mut e: i32 = -14;
931                while (m & 0x400) == 0 {
932                    m <<= 1;
933                    e -= 1;
934                }
935                m &= 0x3FF;
936                let exp_f32 = ((e + 127) as u32) << 23;
937                sign | exp_f32 | (m << 13)
938            }
939        }
940        31 => {
941            // Inf / NaN: f32 exponent all-ones; preserve mantissa.
942            sign | (0xFFu32 << 23) | (mant << 13)
943        }
944        _ => {
945            let exp_f32 = (exp + (127 - 15)) << 23;
946            sign | exp_f32 | (mant << 13)
947        }
948    };
949    f32::from_bits(f32_bits)
950}
951
952/// Bool: numpy byte-per-row (0 == false, non-zero == true) → packed
953/// LSB-first bitmap → BOOLEAN.
954unsafe fn emit_bool(
955    out: &mut Vec<u8>,
956    data: *const u8,
957    row_count: usize,
958    validity: Option<&ValidityDescriptor>,
959) {
960    out.push(0);
961    let bitmap_bytes = row_count.div_ceil(8);
962    out.reserve(bitmap_bytes);
963    if validity.is_none() {
964        let full_chunks = row_count / 8;
965        let tail = row_count % 8;
966        for chunk_idx in 0..full_chunks {
967            let base = chunk_idx * 8;
968            let src = unsafe { data.add(base) };
969            let b0 = unsafe { *src };
970            let b1 = unsafe { *src.add(1) };
971            let b2 = unsafe { *src.add(2) };
972            let b3 = unsafe { *src.add(3) };
973            let b4 = unsafe { *src.add(4) };
974            let b5 = unsafe { *src.add(5) };
975            let b6 = unsafe { *src.add(6) };
976            let b7 = unsafe { *src.add(7) };
977            let packed = u8::from(b0 != 0)
978                | (u8::from(b1 != 0) << 1)
979                | (u8::from(b2 != 0) << 2)
980                | (u8::from(b3 != 0) << 3)
981                | (u8::from(b4 != 0) << 4)
982                | (u8::from(b5 != 0) << 5)
983                | (u8::from(b6 != 0) << 6)
984                | (u8::from(b7 != 0) << 7);
985            out.push(packed);
986        }
987        if tail != 0 {
988            let base = full_chunks * 8;
989            let mut packed = 0u8;
990            for i in 0..tail {
991                let b = unsafe { *data.add(base + i) };
992                if b != 0 {
993                    packed |= 1u8 << i;
994                }
995            }
996            out.push(packed);
997        }
998        return;
999    }
1000    let v = validity.unwrap();
1001    let mut packed = 0u8;
1002    let mut bit_idx = 0u8;
1003    for i in 0..row_count {
1004        let raw = unsafe { *data.add(i) };
1005        if unsafe { v.is_valid(i) } && raw != 0 {
1006            packed |= 1u8 << bit_idx;
1007        }
1008        bit_idx += 1;
1009        if bit_idx == 8 {
1010            out.push(packed);
1011            packed = 0;
1012            bit_idx = 0;
1013        }
1014    }
1015    if bit_idx != 0 {
1016        out.push(packed);
1017    }
1018}
1019
1020/// datetime64[unit] → TIMESTAMP (microseconds, bitmap-encoded). The
1021/// `convert` closure maps one source `i64` to a microsecond `i64`,
1022/// returning `None` on overflow / out-of-range so the caller surfaces a
1023/// `InvalidApiCall` error pointing at the offending row.
1024#[inline]
1025unsafe fn emit_i64_to_micros<F>(
1026    out: &mut Vec<u8>,
1027    data: *const u8,
1028    row_count: usize,
1029    validity: Option<&ValidityDescriptor>,
1030    unit_label: &str,
1031    mut convert: F,
1032) -> Result<()>
1033where
1034    F: FnMut(i64) -> Option<i64>,
1035{
1036    let typed = data as *const i64;
1037    let make_err = |i: usize, value: i64| {
1038        error::fmt!(
1039            InvalidApiCall,
1040            "datetime64[{}] value at row {} ({}) overflows i64 when converted to microseconds",
1041            unit_label,
1042            i,
1043            value
1044        )
1045    };
1046    // numpy NaT is `i64::MIN`, which is also QuestDB's i64 null sentinel
1047    // (`I64_NULL`). Map it straight through to null so an in-band NaT is
1048    // treated consistently with the direct (already-µs) paths instead of
1049    // failing the whole batch on conversion overflow.
1050    match validity.filter(|v| v.has_nulls()) {
1051        None => {
1052            out.push(0);
1053            out.reserve(8 * row_count);
1054            for i in 0..row_count {
1055                let value = unsafe { typed.add(i).read_unaligned() };
1056                let micros = if value == I64_NULL {
1057                    I64_NULL
1058                } else {
1059                    convert(value).ok_or_else(|| make_err(i, value))?
1060                };
1061                out.extend_from_slice(&micros.to_le_bytes());
1062            }
1063        }
1064        Some(v) => {
1065            out.push(1);
1066            unsafe { write_qwp_bitmap_from_validity(out, v) };
1067            out.reserve(8 * v.non_null_count);
1068            for i in 0..row_count {
1069                if !unsafe { v.is_valid(i) } {
1070                    continue;
1071                }
1072                let value = unsafe { typed.add(i).read_unaligned() };
1073                let micros = if value == I64_NULL {
1074                    I64_NULL
1075                } else {
1076                    convert(value).ok_or_else(|| make_err(i, value))?
1077                };
1078                out.extend_from_slice(&micros.to_le_bytes());
1079            }
1080        }
1081    }
1082    Ok(())
1083}
1084
1085/// Microseconds at the start of `1970 + year_offset` (proleptic
1086/// Gregorian). Returns `None` on overflow.
1087fn year_offset_to_micros(year_offset: i64) -> Option<i64> {
1088    // Cap so the final `days * 86_400_000_000` always fits in i64.
1089    // i64::MAX / 86_400_000_000 ≈ 1.067e8 days ≈ 292_277 years.
1090    if !(-292_277..=292_277).contains(&year_offset) {
1091        return None;
1092    }
1093    let year = 1970 + year_offset;
1094    let days = days_from_civil(year, 1, 1);
1095    days.checked_mul(86_400_000_000)
1096}
1097
1098/// Microseconds at the start of `(1970-01) + month_offset` (proleptic
1099/// Gregorian). Negative offsets are calendar-correct via euclidean mod.
1100fn month_offset_to_micros(month_offset: i64) -> Option<i64> {
1101    let year_offset = month_offset.div_euclid(12);
1102    let month_in_year = month_offset.rem_euclid(12) as u32 + 1; // 1..=12
1103    if !(-292_277..=292_277).contains(&year_offset) {
1104        return None;
1105    }
1106    let year = 1970 + year_offset;
1107    let days = days_from_civil(year, month_in_year, 1);
1108    days.checked_mul(86_400_000_000)
1109}
1110
1111/// Days from the Unix epoch (1970-01-01) to the given proleptic
1112/// Gregorian (year, month, day). Howard Hinnant's `days_from_civil`
1113/// (public-domain algorithm,
1114/// <http://howardhinnant.github.io/date_algorithms.html>).
1115/// Safe for `|year| < ~2.5e16`; callers above cap year first.
1116fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1117    let y = if m <= 2 { y - 1 } else { y };
1118    let era = if y >= 0 { y } else { y - 399 } / 400;
1119    let yoe = (y - era * 400) as u64; // [0, 399]
1120    let m_adj = if m > 2 { m - 3 } else { m + 9 } as u64;
1121    let doy = (153 * m_adj + 2) / 5 + d as u64 - 1; // [0, 365]
1122    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146_096]
1123    era * 146_097 + doe as i64 - 719_468
1124}
1125
1126/// Decimal wire: `null_flag` + optional bitmap + `scale` byte + dense
1127/// `N`-byte mantissas (only non-nulls when bitmap present, full row
1128/// count otherwise). Reproduces the arrow-side `write_decimal*_payload`
1129/// shape exactly: the scale byte is written **after** the bitmap.
1130#[inline]
1131unsafe fn emit_decimal<const N: usize>(
1132    out: &mut Vec<u8>,
1133    scale: u8,
1134    data: *const u8,
1135    row_count: usize,
1136    validity: Option<&ValidityDescriptor>,
1137) {
1138    match validity.filter(|v| v.has_nulls()) {
1139        None => {
1140            out.push(0);
1141            out.reserve(1 + N * row_count);
1142            out.push(scale);
1143            if row_count > 0 {
1144                let bytes = unsafe { slice::from_raw_parts(data, N * row_count) };
1145                out.extend_from_slice(bytes);
1146            }
1147        }
1148        Some(v) => {
1149            out.push(1);
1150            unsafe { write_qwp_bitmap_from_validity(out, v) };
1151            out.reserve(1 + N * v.non_null_count);
1152            out.push(scale);
1153            for i in 0..row_count {
1154                if unsafe { v.is_valid(i) } {
1155                    let row_start = unsafe { data.add(i * N) };
1156                    let row = unsafe { slice::from_raw_parts(row_start, N) };
1157                    out.extend_from_slice(row);
1158                }
1159            }
1160        }
1161    }
1162}
1163
1164/// Geohash wire: `null_flag` + optional bitmap + `bits` byte + dense
1165/// `elem`-byte rows (only non-nulls when bitmap present, full row count
1166/// otherwise). `SRC` is the source-int width (1/2/4/8 bytes); `elem` is
1167/// the wire-element width derived from `bits` (`bits.div_ceil(8)`),
1168/// which is always `<= SRC`.
1169///
1170/// The encoder writes the low `elem` bytes of each source int, matching
1171/// `arrow_batch::write_geohash_payload`. Caller has validated `bits` is
1172/// within the source dtype's representable range.
1173#[inline]
1174unsafe fn emit_geohash<const SRC: usize>(
1175    out: &mut Vec<u8>,
1176    bits: u8,
1177    data: *const u8,
1178    row_count: usize,
1179    validity: Option<&ValidityDescriptor>,
1180) -> Result<()> {
1181    let elem = (bits as usize).div_ceil(8);
1182    if elem > SRC {
1183        return Err(error::fmt!(
1184            InvalidApiCall,
1185            "numpy geohash bits ({bits}) exceeds source dtype width ({SRC} bytes)"
1186        ));
1187    }
1188    match validity.filter(|v| v.has_nulls()) {
1189        None => {
1190            out.push(0);
1191            out.reserve(1 + elem * row_count);
1192            write_qwp_varint(out, bits as u64);
1193            if elem == SRC && row_count > 0 {
1194                let bytes = unsafe { slice::from_raw_parts(data, SRC * row_count) };
1195                out.extend_from_slice(bytes);
1196            } else {
1197                for i in 0..row_count {
1198                    let row_start = unsafe { data.add(i * SRC) };
1199                    let row = unsafe { slice::from_raw_parts(row_start, elem) };
1200                    out.extend_from_slice(row);
1201                }
1202            }
1203        }
1204        Some(v) => {
1205            out.push(1);
1206            unsafe { write_qwp_bitmap_from_validity(out, v) };
1207            out.reserve(1 + elem * v.non_null_count);
1208            write_qwp_varint(out, bits as u64);
1209            for i in 0..row_count {
1210                if unsafe { v.is_valid(i) } {
1211                    let row_start = unsafe { data.add(i * SRC) };
1212                    let row = unsafe { slice::from_raw_parts(row_start, elem) };
1213                    out.extend_from_slice(row);
1214                }
1215            }
1216        }
1217    }
1218    Ok(())
1219}
1220
1221/// f64 ndarray (DOUBLE_ARRAY): `null_flag` + optional bitmap, then for
1222/// each non-null row `ndim u8 + (dim u32) × ndim + (value f64) × prod(dims)`.
1223/// Source layout is `row_count` contiguous tensors of `prod(shape[..ndim])`
1224/// f64s in C-order; null rows still occupy that many source bytes and are
1225/// skipped on emit, not on read.
1226#[inline]
1227unsafe fn emit_f64_ndarray(
1228    out: &mut Vec<u8>,
1229    ndim: u8,
1230    shape: [u32; MAX_ARRAY_DIMS],
1231    data: *const u8,
1232    row_count: usize,
1233    validity: Option<&ValidityDescriptor>,
1234) -> Result<()> {
1235    let nd = ndim as usize;
1236    if nd == 0 || nd > MAX_ARRAY_DIMS {
1237        return Err(error::fmt!(
1238            InvalidApiCall,
1239            "F64Ndarray ndim {} must be in 1..={}",
1240            nd,
1241            MAX_ARRAY_DIMS
1242        ));
1243    }
1244    let leaf_count: usize = shape[..nd]
1245        .iter()
1246        .copied()
1247        .map(|d| d as usize)
1248        .try_fold(1usize, usize::checked_mul)
1249        .ok_or_else(|| error::fmt!(InvalidApiCall, "F64Ndarray shape overflows usize"))?;
1250    if leaf_count > MAX_NDARRAY_LEAF_ELEMS {
1251        return Err(error::fmt!(
1252            InvalidApiCall,
1253            "F64Ndarray shape product {} exceeds MAX_NDARRAY_LEAF_ELEMS ({})",
1254            leaf_count,
1255            MAX_NDARRAY_LEAF_ELEMS
1256        ));
1257    }
1258    let row_payload = 1usize
1259        .checked_add(4usize.saturating_mul(nd))
1260        .and_then(|v| v.checked_add(8usize.saturating_mul(leaf_count)))
1261        .ok_or_else(|| error::fmt!(InvalidApiCall, "F64Ndarray row payload overflows usize"))?;
1262    let row_bytes = leaf_count
1263        .checked_mul(8)
1264        .ok_or_else(|| error::fmt!(InvalidApiCall, "F64Ndarray row size overflows usize"))?;
1265
1266    let validity = validity.filter(|v| v.has_nulls());
1267    let non_null_rows = match validity {
1268        None => {
1269            out.push(0);
1270            row_count
1271        }
1272        Some(v) => {
1273            out.push(1);
1274            unsafe { write_qwp_bitmap_from_validity(out, v) };
1275            v.non_null_count
1276        }
1277    };
1278    let reserve_bytes = non_null_rows.checked_mul(row_payload).ok_or_else(|| {
1279        error::fmt!(
1280            InvalidApiCall,
1281            "F64Ndarray reservation overflows usize ({} rows * {} bytes/row)",
1282            non_null_rows,
1283            row_payload
1284        )
1285    })?;
1286    out.try_reserve(reserve_bytes).map_err(|_| {
1287        error::fmt!(
1288            InvalidApiCall,
1289            "F64Ndarray reservation of {} bytes failed",
1290            reserve_bytes
1291        )
1292    })?;
1293
1294    let header_len = 1 + 4 * nd;
1295    let mut header: [u8; 1 + 4 * MAX_ARRAY_DIMS] = [0u8; 1 + 4 * MAX_ARRAY_DIMS];
1296    header[0] = ndim;
1297    for (i, &d) in shape[..nd].iter().enumerate() {
1298        let off = 1 + 4 * i;
1299        header[off..off + 4].copy_from_slice(&d.to_le_bytes());
1300    }
1301    let header = &header[..header_len];
1302
1303    for row in 0..row_count {
1304        if let Some(v) = validity
1305            && !unsafe { v.is_valid(row) }
1306        {
1307            continue;
1308        }
1309        out.extend_from_slice(header);
1310        let src = unsafe { data.add(row * row_bytes) };
1311        if cfg!(target_endian = "little") {
1312            if row_bytes > 0 {
1313                out.extend_from_slice(unsafe { slice::from_raw_parts(src, row_bytes) });
1314            }
1315        } else {
1316            for i in 0..leaf_count {
1317                let bits = unsafe { (src.add(i * 8) as *const u64).read_unaligned() };
1318                out.extend_from_slice(&bits.to_le_bytes());
1319            }
1320        }
1321    }
1322    Ok(())
1323}
1324
1325/// Append `validity` as a QWP-shape bitmap (bit = 1 → NULL).
1326unsafe fn write_qwp_bitmap_from_validity(out: &mut Vec<u8>, v: &ValidityDescriptor) {
1327    let src = unsafe { slice::from_raw_parts(v.bits, v.byte_len()) };
1328    super::wire::write_qwp_bitmap_invert(out, src, v.bit_len);
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::super::Validity;
1334    use super::super::chunk::Chunk;
1335    use super::super::encoder::{EncodeScratch, encode_chunk_into};
1336    use super::*;
1337    use crate::ingress::TimestampUnit;
1338    use crate::ingress::buffer::SymbolGlobalDict;
1339
1340    fn encode(chunk: &Chunk<'_>) -> Vec<u8> {
1341        let mut out = Vec::new();
1342        let mut dict = SymbolGlobalDict::new();
1343        let mut scratch = EncodeScratch::new();
1344        encode_chunk_into(&mut out, chunk, &mut dict, &mut scratch, false).unwrap();
1345        out
1346    }
1347
1348    fn encode_err(chunk: &Chunk<'_>) -> crate::Error {
1349        let mut out = Vec::new();
1350        let mut dict = SymbolGlobalDict::new();
1351        let mut scratch = EncodeScratch::new();
1352        encode_chunk_into(&mut out, chunk, &mut dict, &mut scratch, false).unwrap_err()
1353    }
1354
1355    #[test]
1356    fn chunk_row_count_above_max_rejected_before_read() {
1357        // The encoder must reject an oversized row_count before touching the
1358        // column buffer, so a deliberately tiny backing buffer paired with a
1359        // huge claimed length is never dereferenced.
1360        let buf = [0u8; 8];
1361        let mut chunk = Chunk::new("t");
1362        unsafe {
1363            chunk
1364                .push_numpy_deferred(
1365                    "v",
1366                    NumpyDtype::I8Direct,
1367                    buf.as_ptr(),
1368                    super::super::MAX_CHUNK_ROWS + 1,
1369                    None,
1370                )
1371                .unwrap();
1372        }
1373        let err = encode_err(&chunk);
1374        assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
1375        assert!(err.msg().contains("MAX_CHUNK_ROWS"), "{}", err.msg());
1376    }
1377
1378    #[test]
1379    fn source_elem_size_matches_read_stride() {
1380        use NumpyDtype as D;
1381        // Source-read stride per row (what emit_into_wire dereferences),
1382        // NOT the wire width: widening dtypes read the narrow source.
1383        assert_eq!(D::I8Direct.source_elem_size().unwrap(), 1);
1384        assert_eq!(D::Bool.source_elem_size().unwrap(), 1);
1385        assert_eq!(D::I8WidenToI32.source_elem_size().unwrap(), 1); // 1B src, 4B wire
1386        assert_eq!(D::U8WidenToI32.source_elem_size().unwrap(), 1);
1387        assert_eq!(D::F16Widen.source_elem_size().unwrap(), 2); // 2B src, 4B wire
1388        assert_eq!(D::CharDirect.source_elem_size().unwrap(), 2);
1389        assert_eq!(D::I32WidenToI64.source_elem_size().unwrap(), 4); // 4B src, 8B wire
1390        assert_eq!(D::Ipv4Direct.source_elem_size().unwrap(), 4);
1391        assert_eq!(D::F32Direct.source_elem_size().unwrap(), 4);
1392        assert_eq!(D::I64Direct.source_elem_size().unwrap(), 8);
1393        assert_eq!(D::U64WidenToI64.source_elem_size().unwrap(), 8);
1394        assert_eq!(D::DatetimeSecToMicros.source_elem_size().unwrap(), 8);
1395        assert_eq!(D::UuidDirect.source_elem_size().unwrap(), 16);
1396        assert_eq!(D::Long256Direct.source_elem_size().unwrap(), 32);
1397        assert_eq!(D::Decimal64 { scale: 0 }.source_elem_size().unwrap(), 8);
1398        assert_eq!(D::Decimal128 { scale: 0 }.source_elem_size().unwrap(), 16);
1399        assert_eq!(D::Decimal256 { scale: 0 }.source_elem_size().unwrap(), 32);
1400        // Geohash stride is the source int width (not bits/8).
1401        assert_eq!(D::GeohashI8 { bits: 1 }.source_elem_size().unwrap(), 1);
1402        assert_eq!(D::GeohashI64 { bits: 1 }.source_elem_size().unwrap(), 8);
1403        // Ndarray: prod(shape[..ndim]) * 8 bytes per row.
1404        let mut shape = [0u32; MAX_ARRAY_DIMS];
1405        shape[0] = 2;
1406        shape[1] = 3;
1407        assert_eq!(
1408            D::F64Ndarray { ndim: 2, shape }.source_elem_size().unwrap(),
1409            2 * 3 * 8
1410        );
1411    }
1412
1413    #[test]
1414    fn geohash_dtype_rejects_invalid_bits() {
1415        assert!(NumpyDtype::GeohashI8 { bits: 0 }.validate().is_err());
1416        assert!(NumpyDtype::GeohashI8 { bits: 9 }.validate().is_err());
1417        assert!(NumpyDtype::GeohashI64 { bits: 61 }.validate().is_err());
1418        assert!(NumpyDtype::GeohashI8 { bits: 8 }.validate().is_ok());
1419        assert!(NumpyDtype::GeohashI64 { bits: 60 }.validate().is_ok());
1420    }
1421
1422    #[test]
1423    fn decimal_dtype_rejects_scale_above_width_max() {
1424        assert!(NumpyDtype::Decimal64 { scale: 18 }.validate().is_ok());
1425        assert!(NumpyDtype::Decimal128 { scale: 38 }.validate().is_ok());
1426        assert!(NumpyDtype::Decimal256 { scale: 76 }.validate().is_ok());
1427
1428        for dtype in [
1429            NumpyDtype::Decimal64 { scale: 19 },
1430            NumpyDtype::Decimal128 { scale: 39 },
1431            NumpyDtype::Decimal256 { scale: 77 },
1432        ] {
1433            let err = dtype.validate().unwrap_err();
1434            assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
1435            assert!(err.msg().contains("decimal scale"), "{}", err.msg());
1436        }
1437    }
1438
1439    #[test]
1440    fn f16_bits_to_f32_matches_half_crate_for_all_bit_patterns() {
1441        // The hand-written `f16_bits_to_f32` must agree bit-for-bit with
1442        // `half::f16::to_f32` (used by the Arrow path) on every finite,
1443        // zero, subnormal and infinity bit pattern. NaN payloads are not
1444        // guaranteed identical (`half` forces the quiet bit; this impl
1445        // preserves the raw mantissa), so for NaN we only require both to
1446        // report `is_nan()`.
1447        for bits in 0u32..=0xFFFFu32 {
1448            let bits = bits as u16;
1449            let local = f16_bits_to_f32(bits);
1450            let reference = half::f16::from_bits(bits).to_f32();
1451            if reference.is_nan() {
1452                assert!(local.is_nan(), "bits={:#06x}", bits);
1453            } else {
1454                assert_eq!(local.to_bits(), reference.to_bits(), "bits={:#06x}", bits);
1455            }
1456        }
1457    }
1458
1459    #[test]
1460    fn i8_direct_matches_column_i8() {
1461        let src = [1i8, -2, 3];
1462        let ts = [10i64, 20, 30];
1463
1464        let mut a = Chunk::new("t");
1465        unsafe {
1466            a.push_numpy_deferred(
1467                "v",
1468                NumpyDtype::I8Direct,
1469                src.as_ptr() as *const u8,
1470                src.len(),
1471                None,
1472            )
1473            .unwrap();
1474        }
1475        a.at_nanos(&ts).unwrap();
1476        let bytes_a = encode(&a);
1477
1478        let mut b = Chunk::new("t");
1479        b.column_i8("v", &src, None).unwrap();
1480        b.at_nanos(&ts).unwrap();
1481        let bytes_b = encode(&b);
1482
1483        assert_eq!(
1484            bytes_a, bytes_b,
1485            "I8Direct must produce byte-identical wire to column_i8"
1486        );
1487    }
1488
1489    #[test]
1490    fn i16_direct_matches_column_i16() {
1491        let src = [1i16, -2, 3];
1492        let ts = [10i64, 20, 30];
1493
1494        let mut a = Chunk::new("t");
1495        unsafe {
1496            a.push_numpy_deferred(
1497                "v",
1498                NumpyDtype::I16Direct,
1499                src.as_ptr() as *const u8,
1500                src.len(),
1501                None,
1502            )
1503            .unwrap();
1504        }
1505        a.at_nanos(&ts).unwrap();
1506        let bytes_a = encode(&a);
1507
1508        let mut b = Chunk::new("t");
1509        b.column_i16("v", &src, None).unwrap();
1510        b.at_nanos(&ts).unwrap();
1511        let bytes_b = encode(&b);
1512
1513        assert_eq!(
1514            bytes_a, bytes_b,
1515            "I16Direct must produce byte-identical wire to column_i16"
1516        );
1517    }
1518
1519    #[test]
1520    fn i32_direct_matches_column_i32() {
1521        let src = [1i32, -2, 3];
1522        let ts = [10i64, 20, 30];
1523
1524        let mut a = Chunk::new("t");
1525        unsafe {
1526            a.push_numpy_deferred(
1527                "v",
1528                NumpyDtype::I32Direct,
1529                src.as_ptr() as *const u8,
1530                src.len(),
1531                None,
1532            )
1533            .unwrap();
1534        }
1535        a.at_nanos(&ts).unwrap();
1536        let bytes_a = encode(&a);
1537
1538        let mut b = Chunk::new("t");
1539        b.column_i32("v", &src, None).unwrap();
1540        b.at_nanos(&ts).unwrap();
1541        let bytes_b = encode(&b);
1542
1543        assert_eq!(
1544            bytes_a, bytes_b,
1545            "I32Direct must produce byte-identical wire to column_i32"
1546        );
1547    }
1548
1549    #[test]
1550    fn u8_widen_matches_column_i32() {
1551        // u8 widens to INT (not SHORT) to avoid SHORT's null sentinel
1552        // value 0 silently swallowing source values of 0.
1553        let src = [0u8, 1, 200, 255];
1554        let widened: [i32; 4] = [0, 1, 200, 255];
1555        let ts = [10i64, 20, 30, 40];
1556
1557        let mut a = Chunk::new("t");
1558        unsafe {
1559            a.push_numpy_deferred("v", NumpyDtype::U8WidenToI32, src.as_ptr(), src.len(), None)
1560                .unwrap();
1561        }
1562        a.at_nanos(&ts).unwrap();
1563        let bytes_a = encode(&a);
1564
1565        let mut b = Chunk::new("t");
1566        b.column_i32("v", &widened, None).unwrap();
1567        b.at_nanos(&ts).unwrap();
1568        let bytes_b = encode(&b);
1569
1570        assert_eq!(
1571            bytes_a, bytes_b,
1572            "U8WidenToI32 must produce byte-identical wire to column_i32 over the widened data"
1573        );
1574    }
1575
1576    #[test]
1577    fn u16_widen_matches_column_i32() {
1578        let src = [0u16, 1, 30000, 65535];
1579        let widened: [i32; 4] = [0, 1, 30000, 65535];
1580        let ts = [10i64, 20, 30, 40];
1581
1582        let mut a = Chunk::new("t");
1583        unsafe {
1584            a.push_numpy_deferred(
1585                "v",
1586                NumpyDtype::U16WidenToI32,
1587                src.as_ptr() as *const u8,
1588                src.len(),
1589                None,
1590            )
1591            .unwrap();
1592        }
1593        a.at_nanos(&ts).unwrap();
1594        let bytes_a = encode(&a);
1595
1596        let mut b = Chunk::new("t");
1597        b.column_i32("v", &widened, None).unwrap();
1598        b.at_nanos(&ts).unwrap();
1599        let bytes_b = encode(&b);
1600
1601        assert_eq!(
1602            bytes_a, bytes_b,
1603            "U16WidenToI32 must produce byte-identical wire to column_i32 over the widened data"
1604        );
1605    }
1606
1607    #[test]
1608    fn i8_widen_matches_column_i32() {
1609        // i8 widens to INT (not BYTE) so source value 0 does not collide
1610        // with BYTE's null sentinel (which is 0).
1611        let src = [-128i8, -1, 0, 1, 127];
1612        let widened: [i32; 5] = [-128, -1, 0, 1, 127];
1613        let ts = [10i64, 20, 30, 40, 50];
1614
1615        let mut a = Chunk::new("t");
1616        unsafe {
1617            a.push_numpy_deferred(
1618                "v",
1619                NumpyDtype::I8WidenToI32,
1620                src.as_ptr() as *const u8,
1621                src.len(),
1622                None,
1623            )
1624            .unwrap();
1625        }
1626        a.at_nanos(&ts).unwrap();
1627        let bytes_a = encode(&a);
1628
1629        let mut b = Chunk::new("t");
1630        b.column_i32("v", &widened, None).unwrap();
1631        b.at_nanos(&ts).unwrap();
1632        let bytes_b = encode(&b);
1633
1634        assert_eq!(
1635            bytes_a, bytes_b,
1636            "I8WidenToI32 must produce byte-identical wire to column_i32 over the widened data"
1637        );
1638    }
1639
1640    #[test]
1641    fn i16_widen_matches_column_i32() {
1642        let src = [i16::MIN, -1, 0, 1, i16::MAX];
1643        let widened: [i32; 5] = [i16::MIN as i32, -1, 0, 1, i16::MAX as i32];
1644        let ts = [10i64, 20, 30, 40, 50];
1645
1646        let mut a = Chunk::new("t");
1647        unsafe {
1648            a.push_numpy_deferred(
1649                "v",
1650                NumpyDtype::I16WidenToI32,
1651                src.as_ptr() as *const u8,
1652                src.len(),
1653                None,
1654            )
1655            .unwrap();
1656        }
1657        a.at_nanos(&ts).unwrap();
1658        let bytes_a = encode(&a);
1659
1660        let mut b = Chunk::new("t");
1661        b.column_i32("v", &widened, None).unwrap();
1662        b.at_nanos(&ts).unwrap();
1663        let bytes_b = encode(&b);
1664
1665        assert_eq!(
1666            bytes_a, bytes_b,
1667            "I16WidenToI32 must produce byte-identical wire to column_i32 over the widened data"
1668        );
1669    }
1670
1671    #[test]
1672    fn i32_widen_matches_column_i64() {
1673        // i32 widens to LONG so source value i32::MIN does not collide with
1674        // INT's null sentinel (which is i32::MIN).
1675        let src = [i32::MIN, -1, 0, 1, i32::MAX];
1676        let widened: [i64; 5] = [i32::MIN as i64, -1, 0, 1, i32::MAX as i64];
1677        let ts = [10i64, 20, 30, 40, 50];
1678
1679        let mut a = Chunk::new("t");
1680        unsafe {
1681            a.push_numpy_deferred(
1682                "v",
1683                NumpyDtype::I32WidenToI64,
1684                src.as_ptr() as *const u8,
1685                src.len(),
1686                None,
1687            )
1688            .unwrap();
1689        }
1690        a.at_nanos(&ts).unwrap();
1691        let bytes_a = encode(&a);
1692
1693        let mut b = Chunk::new("t");
1694        b.column_i64("v", &widened, None).unwrap();
1695        b.at_nanos(&ts).unwrap();
1696        let bytes_b = encode(&b);
1697
1698        assert_eq!(
1699            bytes_a, bytes_b,
1700            "I32WidenToI64 must produce byte-identical wire to column_i64 over the widened data"
1701        );
1702    }
1703
1704    #[test]
1705    fn u64_widen_within_i64_range_matches_column_i64() {
1706        let src = [0u64, 42, i64::MAX as u64];
1707        let widened: [i64; 3] = [0, 42, i64::MAX];
1708        let ts = [10i64, 20, 30];
1709
1710        let mut a = Chunk::new("t");
1711        unsafe {
1712            a.push_numpy_deferred(
1713                "v",
1714                NumpyDtype::U64WidenToI64,
1715                src.as_ptr() as *const u8,
1716                src.len(),
1717                None,
1718            )
1719            .unwrap();
1720        }
1721        a.at_nanos(&ts).unwrap();
1722        let bytes_a = encode(&a);
1723
1724        let mut b = Chunk::new("t");
1725        b.column_i64("v", &widened, None).unwrap();
1726        b.at_nanos(&ts).unwrap();
1727        let bytes_b = encode(&b);
1728
1729        assert_eq!(
1730            bytes_a, bytes_b,
1731            "U64WidenToI64 must produce signed LONG wire for values within i64::MAX"
1732        );
1733    }
1734
1735    #[test]
1736    fn u64_widen_above_i64_max_rejects() {
1737        let src = [i64::MAX as u64 + 1];
1738        let ts = [10i64];
1739
1740        let mut chunk = Chunk::new("t");
1741        unsafe {
1742            chunk
1743                .push_numpy_deferred(
1744                    "v",
1745                    NumpyDtype::U64WidenToI64,
1746                    src.as_ptr() as *const u8,
1747                    src.len(),
1748                    None,
1749                )
1750                .unwrap();
1751        }
1752        chunk.at_nanos(&ts).unwrap();
1753        let err = encode_err(&chunk);
1754        assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
1755        assert!(
1756            err.msg().contains("does not fit QuestDB LONG"),
1757            "{}",
1758            err.msg()
1759        );
1760    }
1761
1762    #[test]
1763    fn nullable_u64_widen_above_i64_max_rejects() {
1764        let src = [0u64, i64::MAX as u64 + 1];
1765        let ts = [10i64, 20];
1766        let validity_bits = [0b0000_0010u8];
1767        let validity = Validity::from_bitmap(&validity_bits, src.len()).unwrap();
1768
1769        let mut chunk = Chunk::new("t");
1770        unsafe {
1771            chunk
1772                .push_numpy_deferred(
1773                    "v",
1774                    NumpyDtype::U64WidenToI64,
1775                    src.as_ptr() as *const u8,
1776                    src.len(),
1777                    Some(&validity),
1778                )
1779                .unwrap();
1780        }
1781        chunk.at_nanos(&ts).unwrap();
1782        let err = encode_err(&chunk);
1783        assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
1784        assert!(
1785            err.msg().contains("does not fit QuestDB LONG"),
1786            "{}",
1787            err.msg()
1788        );
1789    }
1790
1791    #[test]
1792    fn f32_direct_matches_column_f32() {
1793        let src = [1.5f32, -2.25, 3.125, f32::NAN];
1794        let ts = [10i64, 20, 30, 40];
1795
1796        let mut a = Chunk::new("t");
1797        unsafe {
1798            a.push_numpy_deferred(
1799                "v",
1800                NumpyDtype::F32Direct,
1801                src.as_ptr() as *const u8,
1802                src.len(),
1803                None,
1804            )
1805            .unwrap();
1806        }
1807        a.at_nanos(&ts).unwrap();
1808        let bytes_a = encode(&a);
1809
1810        let mut b = Chunk::new("t");
1811        b.column_f32("v", &src, None).unwrap();
1812        b.at_nanos(&ts).unwrap();
1813        let bytes_b = encode(&b);
1814
1815        assert_eq!(
1816            bytes_a, bytes_b,
1817            "F32Direct must produce byte-identical wire to column_f32"
1818        );
1819    }
1820
1821    #[test]
1822    fn bool_with_null_matches_column_bool() {
1823        let raw = [1u8, 0, 1, 1];
1824        let ts = [1i64, 2, 3, 4];
1825        // Arrow-shape validity: bit = 1 means valid. Mark row 2 null.
1826        let v_bits = [0b0000_1011u8];
1827        let v = Validity::from_bitmap(&v_bits, 4).unwrap();
1828
1829        let mut a = Chunk::new("t");
1830        unsafe {
1831            a.push_numpy_deferred("b", NumpyDtype::Bool, raw.as_ptr(), raw.len(), Some(&v))
1832                .unwrap();
1833        }
1834        a.at_nanos(&ts).unwrap();
1835        let bytes_a = encode(&a);
1836
1837        let mut packed = vec![0u8; raw.len().div_ceil(8)];
1838        for (i, &b) in raw.iter().enumerate() {
1839            if b != 0 {
1840                packed[i / 8] |= 1u8 << (i % 8);
1841            }
1842        }
1843        let mut b = Chunk::new("t");
1844        b.column_bool("b", &packed, raw.len(), Some(&v)).unwrap();
1845        b.at_nanos(&ts).unwrap();
1846        let bytes_b = encode(&b);
1847
1848        assert_eq!(
1849            bytes_a, bytes_b,
1850            "Bool numpy emit must match column_bool over the equivalent packed bitmap"
1851        );
1852    }
1853
1854    #[test]
1855    fn timestamp_nanos_direct_matches_column_ts_nanos() {
1856        let src = [1_000i64, 2_000, 3_000];
1857        let ts = [1i64, 2, 3];
1858
1859        let mut a = Chunk::new("t");
1860        unsafe {
1861            a.push_numpy_deferred(
1862                "ts",
1863                NumpyDtype::TimestampNanosDirect,
1864                src.as_ptr() as *const u8,
1865                src.len(),
1866                None,
1867            )
1868            .unwrap();
1869        }
1870        a.at_nanos(&ts).unwrap();
1871        let bytes_a = encode(&a);
1872
1873        let mut b = Chunk::new("t");
1874        b.column_ts("ts", &src, TimestampUnit::Nanos, None).unwrap();
1875        b.at_nanos(&ts).unwrap();
1876        let bytes_b = encode(&b);
1877
1878        assert_eq!(
1879            bytes_a, bytes_b,
1880            "TimestampNanosDirect must produce byte-identical wire to column_ts(Nanos)"
1881        );
1882    }
1883
1884    /// Helper: encode one numpy datetime column + a fixed ts, return wire bytes.
1885    fn encode_datetime_col(dtype: NumpyDtype, src_le_bytes: &[u8], row_count: usize) -> Vec<u8> {
1886        let ts: Vec<i64> = (0..row_count as i64).collect();
1887        let mut chunk = Chunk::new("t");
1888        unsafe {
1889            chunk
1890                .push_numpy_deferred("v", dtype, src_le_bytes.as_ptr(), row_count, None)
1891                .unwrap();
1892        }
1893        chunk.at_nanos(&ts).unwrap();
1894        encode(&chunk)
1895    }
1896
1897    /// Helper: encode `column_ts(values, Micros)` + fixed ts, return wire bytes.
1898    fn encode_micros_col(values: &[i64]) -> Vec<u8> {
1899        let ts: Vec<i64> = (0..values.len() as i64).collect();
1900        let mut chunk = Chunk::new("t");
1901        chunk
1902            .column_ts("v", values, TimestampUnit::Micros, None)
1903            .unwrap();
1904        chunk.at_nanos(&ts).unwrap();
1905        encode(&chunk)
1906    }
1907
1908    #[test]
1909    fn datetime_day_matches_column_ts_micros() {
1910        let src = [0i64, 1, 18262]; // epoch, +1d, 2020-01-01
1911        let expected = [0i64, 86_400_000_000, 18262 * 86_400_000_000];
1912        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1913        assert_eq!(
1914            encode_datetime_col(NumpyDtype::DatetimeDayToMicros, &raw, src.len()),
1915            encode_micros_col(&expected),
1916        );
1917    }
1918
1919    #[test]
1920    fn datetime_nat_maps_to_null_not_error() {
1921        // numpy NaT is `i64::MIN`, which is also QuestDB's i64 null
1922        // sentinel (`I64_NULL`). The converting path must pass it through
1923        // as null rather than failing the whole batch on overflow.
1924        let src = [0i64, i64::MIN, 1];
1925        let expected = [0i64, i64::MIN, 86_400_000_000];
1926        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1927        assert_eq!(
1928            encode_datetime_col(NumpyDtype::DatetimeDayToMicros, &raw, src.len()),
1929            encode_micros_col(&expected),
1930        );
1931    }
1932
1933    #[test]
1934    fn datetime_hour_matches_column_ts_micros() {
1935        let src = [0i64, 1, 24];
1936        let expected = [0i64, 3_600_000_000, 24 * 3_600_000_000];
1937        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1938        assert_eq!(
1939            encode_datetime_col(NumpyDtype::DatetimeHourToMicros, &raw, src.len()),
1940            encode_micros_col(&expected),
1941        );
1942    }
1943
1944    #[test]
1945    fn datetime_minute_matches_column_ts_micros() {
1946        let src = [0i64, 1, 60];
1947        let expected = [0i64, 60_000_000, 60 * 60_000_000];
1948        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1949        assert_eq!(
1950            encode_datetime_col(NumpyDtype::DatetimeMinuteToMicros, &raw, src.len()),
1951            encode_micros_col(&expected),
1952        );
1953    }
1954
1955    #[test]
1956    fn datetime_year_matches_calendar() {
1957        // y=0 → 1970-01-01, y=50 → 2020-01-01 (18262 days), y=-1 → 1969-01-01 (-365 days)
1958        let src = [0i64, 50, -1];
1959        let expected = [0i64, 18262 * 86_400_000_000, -365 * 86_400_000_000];
1960        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1961        assert_eq!(
1962            encode_datetime_col(NumpyDtype::DatetimeYearToMicros, &raw, src.len()),
1963            encode_micros_col(&expected),
1964        );
1965    }
1966
1967    #[test]
1968    fn datetime_month_matches_calendar() {
1969        // m=0 → 1970-01-01, m=1 → 1970-02-01 (31 days), m=13 → 1971-02-01 (365+31 days),
1970        // m=-1 → 1969-12-01 (-31 days)
1971        let src = [0i64, 1, 13, -1];
1972        let expected = [
1973            0i64,
1974            31 * 86_400_000_000,
1975            (365 + 31) * 86_400_000_000,
1976            -31 * 86_400_000_000,
1977        ];
1978        let raw: Vec<u8> = src.iter().flat_map(|v| v.to_le_bytes()).collect();
1979        assert_eq!(
1980            encode_datetime_col(NumpyDtype::DatetimeMonthToMicros, &raw, src.len()),
1981            encode_micros_col(&expected),
1982        );
1983    }
1984
1985    #[test]
1986    fn datetime_year_out_of_range_rejected() {
1987        let bad = [10_000_000i64]; // far beyond the ±292_277 cap
1988        let ts = [1i64];
1989        let mut chunk = Chunk::new("t");
1990        unsafe {
1991            chunk
1992                .push_numpy_deferred(
1993                    "ts",
1994                    NumpyDtype::DatetimeYearToMicros,
1995                    bad.as_ptr() as *const u8,
1996                    bad.len(),
1997                    None,
1998                )
1999                .unwrap();
2000        }
2001        chunk.at_nanos(&ts).unwrap();
2002        let err = {
2003            let mut out = Vec::new();
2004            let mut dict = SymbolGlobalDict::new();
2005            let mut scratch = EncodeScratch::new();
2006            encode_chunk_into(&mut out, &chunk, &mut dict, &mut scratch, false).unwrap_err()
2007        };
2008        assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
2009        assert!(err.msg().contains("overflows"));
2010    }
2011
2012    #[test]
2013    fn datetime_sec_overflow_rejected() {
2014        let bad = [i64::MAX];
2015        let ts = [1i64];
2016
2017        let mut chunk = Chunk::new("t");
2018        unsafe {
2019            chunk
2020                .push_numpy_deferred(
2021                    "ts",
2022                    NumpyDtype::DatetimeSecToMicros,
2023                    bad.as_ptr() as *const u8,
2024                    bad.len(),
2025                    None,
2026                )
2027                .unwrap();
2028        }
2029        chunk.at_nanos(&ts).unwrap();
2030        let err = {
2031            let mut out = Vec::new();
2032            let mut dict = SymbolGlobalDict::new();
2033            let mut scratch = EncodeScratch::new();
2034            encode_chunk_into(&mut out, &chunk, &mut dict, &mut scratch, false).unwrap_err()
2035        };
2036        assert_eq!(err.code(), crate::ErrorCode::InvalidApiCall);
2037        assert!(err.msg().contains("overflows"));
2038    }
2039
2040    #[test]
2041    fn f64_ndarray_1d_no_validity_layout() {
2042        // 2 rows, ndim=1, shape=[3] — wire body per row is
2043        // [ndim:u8=1, dim:u32 LE=3, 3×f64 LE values]. Two non-null
2044        // rows + leading null_flag=0 gives a deterministic byte image
2045        // we can construct and compare against.
2046        let rows: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
2047        let ts = [10i64, 20];
2048        let mut shape = [0u32; MAX_ARRAY_DIMS];
2049        shape[0] = 3;
2050
2051        let mut chunk = Chunk::new("t");
2052        unsafe {
2053            chunk
2054                .push_numpy_deferred(
2055                    "v",
2056                    NumpyDtype::F64Ndarray { ndim: 1, shape },
2057                    rows.as_ptr() as *const u8,
2058                    2,
2059                    None,
2060                )
2061                .unwrap();
2062        }
2063        chunk.at_nanos(&ts).unwrap();
2064        let bytes = encode(&chunk);
2065
2066        // The full frame contains schema / header bytes too; assert the
2067        // column body subsequence appears exactly once.
2068        let mut body: Vec<u8> = Vec::new();
2069        body.push(0u8); // null_flag = 0 (no validity)
2070        for row_chunk in rows.chunks_exact(3) {
2071            body.push(1u8); // ndim
2072            body.extend_from_slice(&3u32.to_le_bytes()); // dim
2073            for &v in row_chunk {
2074                body.extend_from_slice(&v.to_le_bytes());
2075            }
2076        }
2077        assert!(
2078            bytes.windows(body.len()).any(|w| w == body.as_slice()),
2079            "expected ndarray column body subsequence in encoded frame"
2080        );
2081    }
2082
2083    #[test]
2084    fn f16_bits_to_f32_known_values() {
2085        // 0.0
2086        assert_eq!(f16_bits_to_f32(0x0000), 0.0f32);
2087        // -0.0
2088        assert_eq!(f16_bits_to_f32(0x8000).to_bits(), (-0.0f32).to_bits());
2089        // 1.0
2090        assert_eq!(f16_bits_to_f32(0x3C00), 1.0f32);
2091        // -2.0
2092        assert_eq!(f16_bits_to_f32(0xC000), -2.0f32);
2093        // +inf
2094        assert!(f16_bits_to_f32(0x7C00).is_infinite() && f16_bits_to_f32(0x7C00) > 0.0);
2095        // smallest positive subnormal: 2^-24
2096        let v = f16_bits_to_f32(0x0001);
2097        assert_eq!(v, 2.0f32.powi(-24));
2098    }
2099}