Skip to main content

common_query/
native_histogram.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Shared native histogram field contract.
16//!
17//! Prom remote-write v2 stores these names as children of one Struct field, while
18//! metric-engine uses the same contract to recognize native histogram tables.
19//! [`NativeHistogram`] is the query-time representation and therefore normalizes
20//! integer and floating-point payloads to absolute `f64` counts.
21
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25use datafusion::arrow::array::{
26    Array, ArrayRef, Float64Array, Int32Array, ListArray, PrimitiveArray, StructArray,
27    TimestampMillisecondArray, UInt64Array,
28};
29use datafusion::arrow::buffer::NullBuffer;
30use datafusion::arrow::datatypes::{
31    ArrowPrimitiveType, DataType as ArrowDataType, Field, Float64Type, Int32Type, Int64Type,
32    TimestampMillisecondType, UInt32Type, UInt64Type,
33};
34use datafusion_common::{DataFusionError, Result as DfResult};
35use datatypes::data_type::{ConcreteDataType, DataType};
36use datatypes::types::{StructField, StructType};
37use once_cell::sync::Lazy;
38
39use crate::prelude::greptime_native_histogram;
40
41pub const NATIVE_HISTOGRAM_FIELD: &str = "greptime_native_histogram";
42pub const SCHEMA_FIELD: &str = "schema";
43pub const ZERO_THRESHOLD_FIELD: &str = "zero_threshold";
44pub const SUM_FIELD: &str = "sum";
45pub const RESET_HINT_FIELD: &str = "reset_hint";
46pub const START_TIMESTAMP_FIELD: &str = "start_timestamp";
47pub const CUSTOM_VALUES_FIELD: &str = "custom_values";
48pub const POSITIVE_SPAN_OFFSETS_FIELD: &str = "positive_span_offsets";
49pub const POSITIVE_SPAN_LENGTHS_FIELD: &str = "positive_span_lengths";
50pub const NEGATIVE_SPAN_OFFSETS_FIELD: &str = "negative_span_offsets";
51pub const NEGATIVE_SPAN_LENGTHS_FIELD: &str = "negative_span_lengths";
52pub const COUNT_U64_FIELD: &str = "count_u64";
53pub const ZERO_COUNT_U64_FIELD: &str = "zero_count_u64";
54pub const POSITIVE_BUCKETS_I64_FIELD: &str = "positive_buckets_i64";
55pub const NEGATIVE_BUCKETS_I64_FIELD: &str = "negative_buckets_i64";
56pub const COUNT_F64_FIELD: &str = "count_f64";
57pub const ZERO_COUNT_F64_FIELD: &str = "zero_count_f64";
58pub const POSITIVE_BUCKETS_F64_FIELD: &str = "positive_buckets_f64";
59pub const NEGATIVE_BUCKETS_F64_FIELD: &str = "negative_buckets_f64";
60
61// Keep int and float payloads in separate columns. The populated family is the
62// type signal, so we don't need to persist an extra histogram-type tag.
63pub const NATIVE_HISTOGRAM_FIELD_NAMES: &[&str] = &[
64    SCHEMA_FIELD,
65    ZERO_THRESHOLD_FIELD,
66    SUM_FIELD,
67    RESET_HINT_FIELD,
68    START_TIMESTAMP_FIELD,
69    CUSTOM_VALUES_FIELD,
70    POSITIVE_SPAN_OFFSETS_FIELD,
71    POSITIVE_SPAN_LENGTHS_FIELD,
72    NEGATIVE_SPAN_OFFSETS_FIELD,
73    NEGATIVE_SPAN_LENGTHS_FIELD,
74    COUNT_U64_FIELD,
75    ZERO_COUNT_U64_FIELD,
76    POSITIVE_BUCKETS_I64_FIELD,
77    NEGATIVE_BUCKETS_I64_FIELD,
78    COUNT_F64_FIELD,
79    ZERO_COUNT_F64_FIELD,
80    POSITIVE_BUCKETS_F64_FIELD,
81    NEGATIVE_BUCKETS_F64_FIELD,
82];
83
84static NATIVE_HISTOGRAM_VALUE_TYPE: Lazy<ConcreteDataType> = Lazy::new(|| {
85    let fields = NATIVE_HISTOGRAM_FIELD_NAMES
86        .iter()
87        .filter_map(|name| {
88            let data_type = native_histogram_field_type(name)?;
89            Some(StructField::new((*name).to_string(), data_type, true))
90        })
91        .collect();
92    ConcreteDataType::struct_datatype(StructType::new(Arc::new(fields)))
93});
94
95/// Returns the exact Greptime type for a persisted native histogram field.
96pub fn native_histogram_field_type(name: &str) -> Option<ConcreteDataType> {
97    match name {
98        SCHEMA_FIELD | RESET_HINT_FIELD => Some(ConcreteDataType::int32_datatype()),
99        ZERO_THRESHOLD_FIELD | SUM_FIELD | COUNT_F64_FIELD | ZERO_COUNT_F64_FIELD => {
100            Some(ConcreteDataType::float64_datatype())
101        }
102        START_TIMESTAMP_FIELD => Some(ConcreteDataType::timestamp_millisecond_datatype()),
103        CUSTOM_VALUES_FIELD | POSITIVE_BUCKETS_F64_FIELD | NEGATIVE_BUCKETS_F64_FIELD => Some(
104            ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::float64_datatype())),
105        ),
106        POSITIVE_SPAN_OFFSETS_FIELD | NEGATIVE_SPAN_OFFSETS_FIELD => Some(
107            ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::int32_datatype())),
108        ),
109        POSITIVE_SPAN_LENGTHS_FIELD | NEGATIVE_SPAN_LENGTHS_FIELD => Some(
110            ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::uint32_datatype())),
111        ),
112        COUNT_U64_FIELD | ZERO_COUNT_U64_FIELD => Some(ConcreteDataType::uint64_datatype()),
113        POSITIVE_BUCKETS_I64_FIELD | NEGATIVE_BUCKETS_I64_FIELD => Some(
114            ConcreteDataType::list_datatype(Arc::new(ConcreteDataType::int64_datatype())),
115        ),
116        _ => None,
117    }
118}
119
120/// Returns the exact Greptime type for a complete native histogram value.
121pub fn native_histogram_value_type() -> &'static ConcreteDataType {
122    &NATIVE_HISTOGRAM_VALUE_TYPE
123}
124
125/// Returns whether `data_type` matches the native histogram value contract.
126pub fn is_native_histogram_value_type(data_type: &ConcreteDataType) -> bool {
127    data_type == native_histogram_value_type()
128}
129
130/// Returns whether a named column matches the configured native histogram contract.
131pub fn is_native_histogram_value_schema(name: &str, data_type: &ConcreteDataType) -> bool {
132    name == greptime_native_histogram() && is_native_histogram_value_type(data_type)
133}
134
135/// Schema identifier for native histograms with explicit custom bucket bounds.
136pub const CUSTOM_BUCKETS_SCHEMA: i32 = -53;
137const MIN_EXPONENTIAL_SCHEMA: i32 = -4;
138const MAX_EXPONENTIAL_SCHEMA: i32 = 8;
139
140/// Information carried by a native histogram about a possible counter reset.
141///
142/// The persisted and protobuf representation remains an `i32`. Unrecognized
143/// values are retained so data written by a newer producer can still round-trip.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum CounterResetHint {
146    /// The payload must be inspected to determine whether a reset occurred.
147    Unknown,
148    /// This is the first histogram after a counter reset.
149    CounterReset,
150    /// No counter reset occurred since the previous histogram.
151    NotCounterReset,
152    /// This is a gauge histogram, so counter resets do not apply.
153    Gauge,
154    /// A value not understood by this version.
155    Unrecognized(i32),
156}
157
158impl From<i32> for CounterResetHint {
159    fn from(value: i32) -> Self {
160        match value {
161            0 => Self::Unknown,
162            1 => Self::CounterReset,
163            2 => Self::NotCounterReset,
164            3 => Self::Gauge,
165            value => Self::Unrecognized(value),
166        }
167    }
168}
169
170impl From<CounterResetHint> for i32 {
171    fn from(value: CounterResetHint) -> Self {
172        match value {
173            CounterResetHint::Unknown => 0,
174            CounterResetHint::CounterReset => 1,
175            CounterResetHint::NotCounterReset => 2,
176            CounterResetHint::Gauge => 3,
177            CounterResetHint::Unrecognized(value) => value,
178        }
179    }
180}
181
182/// Reset hint indicating that the payload must be inspected.
183pub const UNKNOWN_COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::Unknown;
184/// Reset hint indicating the first histogram after a counter reset.
185pub const COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::CounterReset;
186/// Reset hint indicating that no counter reset occurred.
187pub const NOT_COUNTER_RESET_HINT: CounterResetHint = CounterResetHint::NotCounterReset;
188/// Reset hint identifying a gauge histogram.
189pub const GAUGE_RESET_HINT: CounterResetHint = CounterResetHint::Gauge;
190
191/// A contiguous run of populated sparse buckets.
192#[derive(Clone, Debug, PartialEq)]
193pub struct Span {
194    /// The first bucket index, or the gap after the preceding span.
195    pub offset: i32,
196    /// Number of consecutive buckets in the span.
197    pub length: u32,
198}
199
200/// Inclusion rules for a materialized bucket's lower and upper bounds.
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202enum BoundaryRule {
203    OpenLeft = 0,
204    OpenRight = 1,
205    ClosedBoth = 3,
206}
207
208/// A materialized bucket used by query-time estimators and renderers.
209#[derive(Clone, Debug, PartialEq)]
210struct Bucket {
211    lower: f64,
212    upper: f64,
213    count: f64,
214    boundary_rule: BoundaryRule,
215}
216
217/// Query-time representation of a Prometheus native histogram.
218///
219/// Bucket counts are absolute `f64` values even when the persisted payload used
220/// integer counts or deltas.
221#[derive(Clone, Debug, PartialEq)]
222pub struct NativeHistogram {
223    /// Exponential bucket schema, or [`CUSTOM_BUCKETS_SCHEMA`].
224    pub schema: i32,
225    /// Absolute width of the zero bucket.
226    pub zero_threshold: f64,
227    /// Sum of all observations.
228    pub sum: f64,
229    /// Counter reset information associated with this sample.
230    pub reset_hint: CounterResetHint,
231    /// Optional time in milliseconds when this histogram started counting.
232    pub start_timestamp: Option<i64>,
233    /// Inclusive upper bounds used when `schema` is [`CUSTOM_BUCKETS_SCHEMA`].
234    pub custom_values: Vec<f64>,
235    /// Sparse spans describing positive or custom buckets.
236    pub positive_spans: Vec<Span>,
237    /// Sparse spans describing negative buckets.
238    pub negative_spans: Vec<Span>,
239    /// Total number of observations.
240    pub count: f64,
241    /// Number of observations in the zero bucket.
242    pub zero_count: f64,
243    /// Absolute counts for positive or custom buckets.
244    pub positive_buckets: Vec<f64>,
245    /// Absolute counts for negative buckets.
246    pub negative_buckets: Vec<f64>,
247}
248
249impl NativeHistogram {
250    fn uses_custom_buckets(&self) -> bool {
251        self.schema == CUSTOM_BUCKETS_SCHEMA
252    }
253
254    fn compatible_with(&self, other: &Self) -> bool {
255        self.schema == other.schema
256            && self.zero_threshold == other.zero_threshold
257            && self.custom_values == other.custom_values
258    }
259
260    /// Returns a histogram with the same layout and metadata but zeroed payload values.
261    pub fn zero_like(&self) -> Self {
262        let mut result = self.clone();
263        result.count = 0.0;
264        result.zero_count = 0.0;
265        result.sum = 0.0;
266        result.positive_buckets.fill(0.0);
267        result.negative_buckets.fill(0.0);
268        result
269    }
270
271    fn combine_exact(
272        &self,
273        other: &Self,
274        reset_hint: CounterResetHint,
275        op: impl Fn(f64, f64) -> f64 + Copy,
276    ) -> Option<Self> {
277        if !self.compatible_with(other) {
278            return None;
279        }
280
281        let mut result = self.clone();
282        result.count = op(result.count, other.count);
283        result.zero_count = op(result.zero_count, other.zero_count);
284        result.sum = op(result.sum, other.sum);
285        result.reset_hint = reset_hint;
286        (result.positive_spans, result.positive_buckets) = merge_side(
287            &self.positive_spans,
288            &self.positive_buckets,
289            &other.positive_spans,
290            &other.positive_buckets,
291            op,
292        )?;
293        (result.negative_spans, result.negative_buckets) = merge_side(
294            &self.negative_spans,
295            &self.negative_buckets,
296            &other.negative_spans,
297            &other.negative_buckets,
298            op,
299        )?;
300        Some(result)
301    }
302
303    /// Adds two histograms after reconciling compatible bucket layouts.
304    ///
305    /// Returns `None` when the layouts are invalid or cannot be reconciled.
306    pub fn add(&self, other: &Self) -> Option<Self> {
307        let reset_hint = add_reset_hint(self.reset_hint, other.reset_hint);
308        if self.is_empty_payload() {
309            let mut result = other.clone();
310            result.reset_hint = reset_hint;
311            return result.compact();
312        }
313        if other.is_empty_payload() {
314            let mut result = self.clone();
315            result.reset_hint = reset_hint;
316            return result.compact();
317        }
318
319        let (left, right) = self.reconcile(other)?;
320        left.combine_exact(&right, reset_hint, |left, right| left + right)?
321            .compact()
322    }
323
324    /// Subtracts `other` after reconciling compatible bucket layouts.
325    ///
326    /// Returns `None` when the layouts are invalid or cannot be reconciled.
327    pub fn sub(&self, other: &Self) -> Option<Self> {
328        if self.is_empty_payload() {
329            return other.clone().negated().compact();
330        }
331        if other.is_empty_payload() {
332            return self.clone().into_gauge().compact();
333        }
334
335        let (left, right) = self.reconcile(other)?;
336        left.combine_exact(&right, CounterResetHint::Gauge, |left, right| left - right)?
337            .compact()
338    }
339
340    /// Negates every payload value and marks the result as a gauge histogram.
341    pub fn negated(self) -> Self {
342        self.scale(-1.0)
343    }
344
345    /// Marks this histogram as a gauge without changing its payload.
346    pub fn into_gauge(mut self) -> Self {
347        self.reset_hint = CounterResetHint::Gauge;
348        self
349    }
350
351    /// Returns whether the histograms carry explicit, contradictory reset hints.
352    pub fn counter_reset_hints_contradict(&self, other: &Self) -> bool {
353        matches!(
354            (self.reset_hint, other.reset_hint),
355            (
356                CounterResetHint::CounterReset,
357                CounterResetHint::NotCounterReset
358            ) | (
359                CounterResetHint::NotCounterReset,
360                CounterResetHint::CounterReset
361            )
362        )
363    }
364
365    /// Returns whether two custom histograms use different bucket bounds.
366    pub fn needs_custom_reconciliation(&self, other: &Self) -> bool {
367        self.uses_custom_buckets()
368            && other.uses_custom_buckets()
369            && self.custom_values != other.custom_values
370    }
371
372    /// Compares PromQL-visible payload values by their exact bit patterns.
373    ///
374    /// Reset hints, start timestamps, and sparse zero placement are ignored.
375    pub fn promql_eq(&self, other: &Self) -> bool {
376        self.schema == other.schema
377            && self.zero_threshold == other.zero_threshold
378            && self.custom_values == other.custom_values
379            && self.count.to_bits() == other.count.to_bits()
380            && self.zero_count.to_bits() == other.zero_count.to_bits()
381            && self.sum.to_bits() == other.sum.to_bits()
382            && side_counts_equal(
383                &self.positive_spans,
384                &self.positive_buckets,
385                &other.positive_spans,
386                &other.positive_buckets,
387            )
388            && side_counts_equal(
389                &self.negative_spans,
390                &self.negative_buckets,
391                &other.negative_spans,
392                &other.negative_buckets,
393            )
394    }
395
396    /// Formats this histogram using PromQL native histogram sample notation.
397    pub fn promql_string(&self) -> String {
398        let mut parts = vec![format!("count:{}", self.count), format!("sum:{}", self.sum)];
399        if let Some(buckets) = self.all_buckets() {
400            parts.extend(
401                buckets
402                    .into_iter()
403                    .filter(|bucket| bucket.count != 0.0)
404                    .map(|bucket| {
405                        let (left, right) = match bucket.boundary_rule {
406                            BoundaryRule::OpenLeft => ("(", "]"),
407                            BoundaryRule::OpenRight => ("[", ")"),
408                            BoundaryRule::ClosedBoth => ("[", "]"),
409                        };
410                        format!(
411                            "{}{},{}{}:{}",
412                            left, bucket.lower, bucket.upper, right, bucket.count
413                        )
414                    }),
415            );
416        }
417        format!("{{{}}}", parts.join(", "))
418    }
419
420    /// Estimates the population variance using each populated bucket's midpoint.
421    pub fn estimated_stdvar(&self) -> f64 {
422        if self.count == 0.0 {
423            return f64::NAN;
424        }
425        let mean = self.sum / self.count;
426        let Some(buckets) = self.all_buckets() else {
427            return f64::NAN;
428        };
429        buckets
430            .into_iter()
431            .map(|bucket| {
432                let midpoint = self.bucket_midpoint(&bucket);
433                bucket.count * (midpoint - mean).powi(2)
434            })
435            .sum::<f64>()
436            / self.count
437    }
438
439    /// Estimates the population standard deviation from [`Self::estimated_stdvar`].
440    pub fn estimated_stddev(&self) -> f64 {
441        self.estimated_stdvar().sqrt()
442    }
443
444    /// Multiplies every payload value by `factor`.
445    ///
446    /// A negative factor marks the result as a gauge histogram.
447    pub fn scale(mut self, factor: f64) -> Self {
448        self.count *= factor;
449        self.zero_count *= factor;
450        self.sum *= factor;
451        for count in &mut self.positive_buckets {
452            *count *= factor;
453        }
454        for count in &mut self.negative_buckets {
455            *count *= factor;
456        }
457        if factor < 0.0 {
458            self.reset_hint = CounterResetHint::Gauge;
459        }
460        self
461    }
462
463    /// Divides every payload value by `divisor`.
464    ///
465    /// A negative divisor marks the result as a gauge histogram.
466    pub fn divide_by(mut self, divisor: f64) -> Self {
467        self.count /= divisor;
468        self.zero_count /= divisor;
469        self.sum /= divisor;
470        if divisor == 0.0 {
471            self.positive_spans.clear();
472            self.positive_buckets.clear();
473            self.negative_spans.clear();
474            self.negative_buckets.clear();
475        } else {
476            for count in &mut self.positive_buckets {
477                *count /= divisor;
478            }
479            for count in &mut self.negative_buckets {
480                *count /= divisor;
481            }
482        }
483        if divisor < 0.0 {
484            self.reset_hint = CounterResetHint::Gauge;
485        }
486        self
487    }
488
489    fn compact(mut self) -> Option<Self> {
490        let (spans, buckets) = compact_side(&self.positive_spans, &self.positive_buckets)?;
491        self.positive_spans = spans;
492        self.positive_buckets = buckets;
493        let (spans, buckets) = compact_side(&self.negative_spans, &self.negative_buckets)?;
494        self.negative_spans = spans;
495        self.negative_buckets = buckets;
496        Some(self)
497    }
498
499    /// Detects a reset from the explicit hint and histogram payload.
500    pub fn detect_reset(&self, previous: &Self) -> bool {
501        match self.reset_hint {
502            CounterResetHint::CounterReset => return true,
503            CounterResetHint::NotCounterReset => return false,
504            CounterResetHint::Unknown
505            | CounterResetHint::Gauge
506            | CounterResetHint::Unrecognized(_) => {}
507        }
508
509        if self.count < previous.count {
510            return true;
511        }
512
513        match (self.uses_custom_buckets(), previous.uses_custom_buckets()) {
514            (true, true) => {
515                let Some((current, previous)) = reconcile_custom(self, previous) else {
516                    return true;
517                };
518                current.zero_count < previous.zero_count
519                    || current.side_has_reset(true, &previous)
520                    || current.side_has_reset(false, &previous)
521            }
522            (true, false) | (false, true) => true,
523            (false, false) => {
524                if self.schema > previous.schema || self.zero_threshold < previous.zero_threshold {
525                    return true;
526                }
527
528                let mut previous = previous.clone();
529                if self.zero_threshold > previous.zero_threshold {
530                    let Some(expanded) = previous.expanded_zero_threshold(self.zero_threshold)
531                    else {
532                        return true;
533                    };
534                    if expanded != self.zero_threshold
535                        || previous.grow_zero_threshold(self.zero_threshold).is_none()
536                    {
537                        return true;
538                    }
539                }
540                let Some(previous) = previous.copy_to_schema(self.schema) else {
541                    return true;
542                };
543
544                self.zero_count < previous.zero_count
545                    || self.side_has_reset(true, &previous)
546                    || self.side_has_reset(false, &previous)
547            }
548        }
549    }
550
551    fn detect_start_timestamp_reset(
552        &self,
553        previous: &Self,
554        previous_ts: i64,
555        current_ts: i64,
556    ) -> bool {
557        let current_start = self.start_timestamp.unwrap_or_default();
558        if current_start == 0 || current_start >= current_ts || current_start < previous_ts {
559            return false;
560        }
561        if current_start > previous_ts {
562            return true;
563        }
564
565        let previous_start = previous.start_timestamp.unwrap_or_default();
566        previous_start <= previous_ts && previous_start != 0 && previous_start != previous_ts
567    }
568
569    /// Detects a counter reset using start timestamps, hints, and payload values.
570    pub fn detect_counter_reset(&self, previous: &Self, previous_ts: i64, current_ts: i64) -> bool {
571        self.detect_start_timestamp_reset(previous, previous_ts, current_ts)
572            || self.detect_reset(previous)
573    }
574
575    fn all_buckets(&self) -> Option<Vec<Bucket>> {
576        let mut buckets = self.side_buckets(false)?;
577        buckets.reverse();
578        if self.zero_count != 0.0 {
579            buckets.push(Bucket {
580                lower: -self.zero_threshold,
581                upper: self.zero_threshold,
582                count: self.zero_count,
583                boundary_rule: BoundaryRule::ClosedBoth,
584            });
585        }
586        buckets.extend(self.side_buckets(true)?);
587        Some(buckets)
588    }
589
590    fn side_buckets(&self, positive: bool) -> Option<Vec<Bucket>> {
591        let (spans, counts) = if positive {
592            (&self.positive_spans, &self.positive_buckets)
593        } else {
594            (&self.negative_spans, &self.negative_buckets)
595        };
596
597        let mut result = Vec::with_capacity(counts.len());
598        for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(counts) {
599            let upper = get_bound(idx, self.schema, &self.custom_values)?;
600            let lower = get_bound(idx.checked_sub(1)?, self.schema, &self.custom_values)?;
601            if positive {
602                result.push(Bucket {
603                    lower,
604                    upper,
605                    count: *count,
606                    boundary_rule: BoundaryRule::OpenLeft,
607                });
608            } else {
609                result.push(Bucket {
610                    lower: -upper,
611                    upper: -lower,
612                    count: *count,
613                    boundary_rule: BoundaryRule::OpenRight,
614                });
615            }
616        }
617        Some(result)
618    }
619
620    /// Estimates the value at quantile `q`.
621    ///
622    /// Returns negative or positive infinity outside `[0, 1]`, and `NaN` when
623    /// the quantile cannot be estimated.
624    pub fn quantile(&self, q: f64) -> f64 {
625        if q < 0.0 {
626            return f64::NEG_INFINITY;
627        }
628        if q > 1.0 {
629            return f64::INFINITY;
630        }
631        if self.count == 0.0 || q.is_nan() {
632            return f64::NAN;
633        }
634
635        let Some(mut buckets) = self.all_buckets() else {
636            return f64::NAN;
637        };
638        let rank = q * self.count;
639        let mut count = 0.0;
640        for bucket in &mut buckets {
641            if bucket.count == 0.0 {
642                continue;
643            }
644            count += bucket.count;
645            if count < rank {
646                continue;
647            }
648
649            if !self.uses_custom_buckets() && bucket.lower < 0.0 && bucket.upper > 0.0 {
650                if self.negative_buckets.is_empty() && !self.positive_buckets.is_empty() {
651                    bucket.lower = 0.0;
652                } else if self.positive_buckets.is_empty() && !self.negative_buckets.is_empty() {
653                    bucket.upper = 0.0;
654                }
655            } else if self.uses_custom_buckets() {
656                if bucket.lower == f64::NEG_INFINITY {
657                    if bucket.upper <= 0.0 {
658                        return bucket.upper;
659                    }
660                    bucket.lower = 0.0;
661                } else if bucket.upper == f64::INFINITY {
662                    return bucket.lower;
663                }
664            }
665
666            let rank_in_bucket = rank - (count - bucket.count);
667            let fraction = rank_in_bucket / bucket.count;
668            if self.uses_custom_buckets() || (bucket.lower <= 0.0 && bucket.upper >= 0.0) {
669                return bucket.lower + (bucket.upper - bucket.lower) * fraction;
670            }
671
672            let log_lower = bucket.lower.abs().log2();
673            let log_upper = bucket.upper.abs().log2();
674            if bucket.lower > 0.0 {
675                return 2.0_f64.powf(log_lower + (log_upper - log_lower) * fraction);
676            }
677            return -2.0_f64.powf(log_upper + (log_lower - log_upper) * (1.0 - fraction));
678        }
679
680        f64::NAN
681    }
682
683    /// Estimates the fraction of observations between `lower` and `upper`.
684    pub fn fraction(&self, lower: f64, upper: f64) -> f64 {
685        if self.count == 0.0 || lower.is_nan() || upper.is_nan() {
686            return f64::NAN;
687        }
688        if lower >= upper {
689            return 0.0;
690        }
691
692        let Some(mut buckets) = self.all_buckets() else {
693            return f64::NAN;
694        };
695        let count = if self.sum.is_nan() {
696            buckets.iter().map(|bucket| bucket.count).sum()
697        } else {
698            self.count
699        };
700
701        let mut rank = 0.0;
702        let mut lower_rank = 0.0;
703        let mut upper_rank = 0.0;
704        let mut lower_set = false;
705        let mut upper_set = false;
706
707        for bucket in &mut buckets {
708            let zero_bucket = bucket.lower <= 0.0 && bucket.upper >= 0.0;
709            if zero_bucket {
710                if self.negative_buckets.is_empty() && !self.positive_buckets.is_empty() {
711                    bucket.lower = 0.0;
712                } else if self.positive_buckets.is_empty() && !self.negative_buckets.is_empty() {
713                    bucket.upper = 0.0;
714                }
715            }
716
717            if !lower_set && bucket.lower >= lower {
718                lower_rank = rank;
719                lower_set = true;
720            }
721            if !upper_set && bucket.lower >= upper {
722                upper_rank = rank;
723                upper_set = true;
724            }
725            if lower_set && upper_set {
726                break;
727            }
728            if !lower_set && bucket.lower < lower && bucket.upper > lower {
729                lower_rank = self.interpolate_rank(bucket, rank, lower, zero_bucket);
730                lower_set = true;
731            }
732            if !upper_set && bucket.lower < upper && bucket.upper > upper {
733                upper_rank = self.interpolate_rank(bucket, rank, upper, zero_bucket);
734                upper_set = true;
735            }
736            if lower_set && upper_set {
737                break;
738            }
739            rank += bucket.count;
740        }
741
742        if !lower_set || lower_rank > count {
743            lower_rank = count;
744        }
745        if !upper_set || upper_rank > count {
746            upper_rank = count;
747        }
748
749        (upper_rank - lower_rank) / self.count
750    }
751
752    /// Converts populated buckets to `(boundary rule, lower, upper, count)` strings.
753    pub fn to_prometheus_buckets(&self) -> Option<Vec<(u8, String, String, String)>> {
754        Some(
755            self.all_buckets()?
756                .into_iter()
757                .filter(|bucket| bucket.count != 0.0)
758                .map(|bucket| {
759                    (
760                        bucket.boundary_rule as u8,
761                        bucket.lower.to_string(),
762                        bucket.upper.to_string(),
763                        bucket.count.to_string(),
764                    )
765                })
766                .collect(),
767        )
768    }
769
770    fn interpolate_rank(&self, bucket: &Bucket, rank: f64, value: f64, zero_bucket: bool) -> f64 {
771        if self.uses_custom_buckets() || zero_bucket {
772            if bucket.lower == f64::NEG_INFINITY {
773                return bucket.count;
774            }
775            return rank + bucket.count * (value - bucket.lower) / (bucket.upper - bucket.lower);
776        }
777
778        let log_lower = bucket.lower.abs().log2();
779        let log_upper = bucket.upper.abs().log2();
780        let log_value = value.abs().log2();
781        let fraction = if value > 0.0 {
782            (log_value - log_lower) / (log_upper - log_lower)
783        } else {
784            1.0 - ((log_value - log_upper) / (log_lower - log_upper))
785        };
786        rank + bucket.count * fraction
787    }
788
789    fn bucket_midpoint(&self, bucket: &Bucket) -> f64 {
790        if bucket.lower == f64::NEG_INFINITY && bucket.upper == f64::INFINITY {
791            return 0.0;
792        }
793        if bucket.lower == f64::NEG_INFINITY {
794            return bucket.upper;
795        }
796        if bucket.upper == f64::INFINITY {
797            return bucket.lower;
798        }
799        if self.uses_custom_buckets() || (bucket.lower <= 0.0 && bucket.upper >= 0.0) {
800            return (bucket.lower + bucket.upper) / 2.0;
801        }
802        if bucket.upper < 0.0 {
803            -((bucket.lower.abs() * bucket.upper.abs()).sqrt())
804        } else {
805            (bucket.lower * bucket.upper).sqrt()
806        }
807    }
808
809    fn side_has_reset(&self, positive: bool, previous: &Self) -> bool {
810        let (current_spans, current_buckets, previous_spans, previous_buckets) = if positive {
811            (
812                &self.positive_spans,
813                &self.positive_buckets,
814                &previous.positive_spans,
815                &previous.positive_buckets,
816            )
817        } else {
818            (
819                &self.negative_spans,
820                &self.negative_buckets,
821                &previous.negative_spans,
822                &previous.negative_buckets,
823            )
824        };
825        let Some(current) = side_counts(current_spans, current_buckets) else {
826            return true;
827        };
828        let Some(previous) = side_counts(previous_spans, previous_buckets) else {
829            return true;
830        };
831        previous.keys().chain(current.keys()).any(|idx| {
832            current.get(idx).copied().unwrap_or_default()
833                < previous.get(idx).copied().unwrap_or_default()
834        })
835    }
836
837    fn reconcile(&self, other: &Self) -> Option<(Self, Self)> {
838        match (self.uses_custom_buckets(), other.uses_custom_buckets()) {
839            (true, true) => reconcile_custom(self, other),
840            (false, false) => reconcile_exponential(self, other),
841            _ => None,
842        }
843    }
844
845    fn is_empty_payload(&self) -> bool {
846        self.count == 0.0
847            && self.zero_count == 0.0
848            && self.sum == 0.0
849            && self.positive_buckets.iter().all(|count| *count == 0.0)
850            && self.negative_buckets.iter().all(|count| *count == 0.0)
851    }
852}
853
854fn reconcile_exponential(
855    left: &NativeHistogram,
856    right: &NativeHistogram,
857) -> Option<(NativeHistogram, NativeHistogram)> {
858    let schema = left.schema.min(right.schema);
859    let mut left = left.copy_to_schema(schema)?;
860    let mut right = right.copy_to_schema(schema)?;
861    let mut zero_threshold = left.zero_threshold.max(right.zero_threshold);
862    loop {
863        let expanded = left
864            .expanded_zero_threshold(zero_threshold)?
865            .max(right.expanded_zero_threshold(zero_threshold)?);
866        if expanded == zero_threshold {
867            break;
868        }
869        zero_threshold = expanded;
870    }
871    left.grow_zero_threshold(zero_threshold)?;
872    right.grow_zero_threshold(zero_threshold)?;
873    Some((left.compact()?, right.compact()?))
874}
875
876fn reconcile_custom(
877    left: &NativeHistogram,
878    right: &NativeHistogram,
879) -> Option<(NativeHistogram, NativeHistogram)> {
880    let custom_values = if left.custom_values == right.custom_values {
881        left.custom_values.clone()
882    } else {
883        left.custom_values
884            .iter()
885            .copied()
886            .filter(|value| right.custom_values.contains(value))
887            .collect()
888    };
889
890    Some((
891        left.copy_to_custom_values(custom_values.clone())?
892            .compact()?,
893        right.copy_to_custom_values(custom_values)?.compact()?,
894    ))
895}
896
897impl NativeHistogram {
898    fn copy_to_schema(&self, target_schema: i32) -> Option<Self> {
899        if self.uses_custom_buckets()
900            || !(MIN_EXPONENTIAL_SCHEMA..=MAX_EXPONENTIAL_SCHEMA).contains(&target_schema)
901            || target_schema > self.schema
902        {
903            return None;
904        }
905        if target_schema == self.schema {
906            return Some(self.clone());
907        }
908
909        let mut result = self.clone();
910        result.schema = target_schema;
911        (result.positive_spans, result.positive_buckets) = reduce_side(
912            &self.positive_spans,
913            &self.positive_buckets,
914            self.schema,
915            target_schema,
916        )?;
917        (result.negative_spans, result.negative_buckets) = reduce_side(
918            &self.negative_spans,
919            &self.negative_buckets,
920            self.schema,
921            target_schema,
922        )?;
923        Some(result)
924    }
925
926    fn copy_to_custom_values(&self, custom_values: Vec<f64>) -> Option<Self> {
927        if !self.uses_custom_buckets() {
928            return None;
929        }
930        if self.custom_values == custom_values {
931            return Some(self.clone());
932        }
933
934        let mut result = self.clone();
935        result.custom_values = custom_values.clone();
936        result.negative_spans.clear();
937        result.negative_buckets.clear();
938        (result.positive_spans, result.positive_buckets) = map_custom_side(
939            &self.positive_spans,
940            &self.positive_buckets,
941            &self.custom_values,
942            &custom_values,
943        )?;
944        Some(result)
945    }
946
947    fn grow_zero_threshold(&mut self, zero_threshold: f64) -> Option<()> {
948        if self.uses_custom_buckets() || zero_threshold == self.zero_threshold {
949            self.zero_threshold = zero_threshold;
950            return Some(());
951        }
952
953        let (spans, buckets, zero_count) = fold_zero_side(
954            &self.positive_spans,
955            &self.positive_buckets,
956            self.schema,
957            zero_threshold,
958        )?;
959        self.positive_spans = spans;
960        self.positive_buckets = buckets;
961        self.zero_count += zero_count;
962
963        let (spans, buckets, zero_count) = fold_zero_side(
964            &self.negative_spans,
965            &self.negative_buckets,
966            self.schema,
967            zero_threshold,
968        )?;
969        self.negative_spans = spans;
970        self.negative_buckets = buckets;
971        self.zero_count += zero_count;
972        self.zero_threshold = zero_threshold;
973        Some(())
974    }
975
976    fn expanded_zero_threshold(&self, mut zero_threshold: f64) -> Option<f64> {
977        if self.uses_custom_buckets() {
978            return Some(zero_threshold);
979        }
980        zero_threshold = expand_zero_threshold_side(
981            &self.positive_spans,
982            &self.positive_buckets,
983            self.schema,
984            zero_threshold,
985        )?;
986        expand_zero_threshold_side(
987            &self.negative_spans,
988            &self.negative_buckets,
989            self.schema,
990            zero_threshold,
991        )
992    }
993}
994
995fn add_reset_hint(left: CounterResetHint, right: CounterResetHint) -> CounterResetHint {
996    if left == CounterResetHint::Gauge || right == CounterResetHint::Gauge {
997        CounterResetHint::Gauge
998    } else if left == right {
999        left
1000    } else {
1001        CounterResetHint::Unknown
1002    }
1003}
1004
1005fn side_bucket_indices(spans: &[Span]) -> Option<Vec<i32>> {
1006    let mut indices = Vec::new();
1007    let mut current_index = 0i32;
1008    let mut first = true;
1009    for span in spans {
1010        if first {
1011            current_index = span.offset;
1012            first = false;
1013        } else {
1014            current_index = current_index.checked_add(span.offset)?;
1015        }
1016        for _ in 0..span.length {
1017            indices.push(current_index);
1018            current_index = current_index.checked_add(1)?;
1019        }
1020    }
1021    Some(indices)
1022}
1023
1024fn spans_from_indices_counts(values: Vec<(i32, f64)>) -> Option<(Vec<Span>, Vec<f64>)> {
1025    let mut spans = Vec::<Span>::new();
1026    let mut buckets = Vec::new();
1027    let mut previous_index = None::<i32>;
1028
1029    for (idx, count) in values {
1030        if count == 0.0 {
1031            continue;
1032        }
1033        match (spans.last_mut(), previous_index) {
1034            (Some(span), Some(previous)) if previous.checked_add(1) == Some(idx) => {
1035                span.length = span.length.checked_add(1)?;
1036            }
1037            (_, Some(previous)) => {
1038                spans.push(Span {
1039                    offset: idx.checked_sub(previous)?.checked_sub(1)?,
1040                    length: 1,
1041                });
1042            }
1043            (_, None) => {
1044                spans.push(Span {
1045                    offset: idx,
1046                    length: 1,
1047                });
1048            }
1049        }
1050        buckets.push(count);
1051        previous_index = Some(idx);
1052    }
1053
1054    Some((spans, buckets))
1055}
1056
1057fn side_counts(spans: &[Span], buckets: &[f64]) -> Option<BTreeMap<i32, f64>> {
1058    let mut values = BTreeMap::new();
1059    for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
1060        if *count != 0.0 {
1061            values.insert(idx, *count);
1062        }
1063    }
1064    Some(values)
1065}
1066
1067fn side_counts_equal(
1068    left_spans: &[Span],
1069    left_buckets: &[f64],
1070    right_spans: &[Span],
1071    right_buckets: &[f64],
1072) -> bool {
1073    match (
1074        side_counts(left_spans, left_buckets),
1075        side_counts(right_spans, right_buckets),
1076    ) {
1077        (Some(left), Some(right)) => {
1078            left.len() == right.len()
1079                && left.iter().all(|(idx, count)| {
1080                    right
1081                        .get(idx)
1082                        .is_some_and(|other| count.to_bits() == other.to_bits())
1083                })
1084        }
1085        (None, None) => true,
1086        _ => false,
1087    }
1088}
1089
1090fn merge_side(
1091    left_spans: &[Span],
1092    left_buckets: &[f64],
1093    right_spans: &[Span],
1094    right_buckets: &[f64],
1095    op: impl Fn(f64, f64) -> f64,
1096) -> Option<(Vec<Span>, Vec<f64>)> {
1097    let left = side_counts(left_spans, left_buckets)?;
1098    let right = side_counts(right_spans, right_buckets)?;
1099    let mut values = BTreeMap::new();
1100    for idx in left.keys().chain(right.keys()) {
1101        values.insert(
1102            *idx,
1103            op(
1104                left.get(idx).copied().unwrap_or_default(),
1105                right.get(idx).copied().unwrap_or_default(),
1106            ),
1107        );
1108    }
1109    spans_from_indices_counts(values.into_iter().collect())
1110}
1111
1112fn reduce_side(
1113    spans: &[Span],
1114    buckets: &[f64],
1115    schema: i32,
1116    target_schema: i32,
1117) -> Option<(Vec<Span>, Vec<f64>)> {
1118    let factor = 1_i32.checked_shl((schema - target_schema) as u32)?;
1119    let mut values = std::collections::BTreeMap::<i32, f64>::new();
1120    for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
1121        let target_idx = ceil_div(idx, factor);
1122        *values.entry(target_idx).or_default() += *count;
1123    }
1124    spans_from_indices_counts(values.into_iter().collect())
1125}
1126
1127fn compact_side(spans: &[Span], buckets: &[f64]) -> Option<(Vec<Span>, Vec<f64>)> {
1128    let indices = side_bucket_indices(spans)?;
1129    spans_from_indices_counts(indices.into_iter().zip(buckets.iter().copied()).collect())
1130}
1131
1132fn map_custom_side(
1133    spans: &[Span],
1134    buckets: &[f64],
1135    old_values: &[f64],
1136    new_values: &[f64],
1137) -> Option<(Vec<Span>, Vec<f64>)> {
1138    let mut values = std::collections::BTreeMap::<i32, f64>::new();
1139    for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
1140        let upper = get_bound(idx, CUSTOM_BUCKETS_SCHEMA, old_values)?;
1141        let target_idx = new_values
1142            .iter()
1143            .position(|value| *value >= upper)
1144            .unwrap_or(new_values.len()) as i32;
1145        *values.entry(target_idx).or_default() += *count;
1146    }
1147    spans_from_indices_counts(values.into_iter().collect())
1148}
1149
1150fn fold_zero_side(
1151    spans: &[Span],
1152    buckets: &[f64],
1153    schema: i32,
1154    zero_threshold: f64,
1155) -> Option<(Vec<Span>, Vec<f64>, f64)> {
1156    let mut kept = Vec::new();
1157    let mut zero_count = 0.0;
1158    for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
1159        if get_bound(idx, schema, &[])? <= zero_threshold {
1160            zero_count += *count;
1161        } else {
1162            kept.push((idx, *count));
1163        }
1164    }
1165    let (spans, buckets) = spans_from_indices_counts(kept)?;
1166    Some((spans, buckets, zero_count))
1167}
1168
1169fn expand_zero_threshold_side(
1170    spans: &[Span],
1171    buckets: &[f64],
1172    schema: i32,
1173    mut zero_threshold: f64,
1174) -> Option<f64> {
1175    for (idx, count) in side_bucket_indices(spans)?.into_iter().zip(buckets) {
1176        if *count == 0.0 {
1177            continue;
1178        }
1179        let lower = get_bound(idx.checked_sub(1)?, schema, &[])?;
1180        let upper = get_bound(idx, schema, &[])?;
1181        if lower < zero_threshold && zero_threshold < upper {
1182            zero_threshold = upper;
1183        }
1184    }
1185    Some(zero_threshold)
1186}
1187
1188fn ceil_div(value: i32, divisor: i32) -> i32 {
1189    value.div_euclid(divisor) + i32::from(value.rem_euclid(divisor) != 0)
1190}
1191
1192fn get_bound(idx: i32, schema: i32, custom_values: &[f64]) -> Option<f64> {
1193    if schema == CUSTOM_BUCKETS_SCHEMA {
1194        return match idx {
1195            -1 => Some(f64::NEG_INFINITY),
1196            idx if idx == custom_values.len() as i32 => Some(f64::INFINITY),
1197            idx if idx >= 0 && (idx as usize) < custom_values.len() => {
1198                Some(custom_values[idx as usize])
1199            }
1200            _ => None,
1201        };
1202    }
1203
1204    if !(MIN_EXPONENTIAL_SCHEMA..=MAX_EXPONENTIAL_SCHEMA).contains(&schema) {
1205        return None;
1206    }
1207    if schema < 0 {
1208        return Some(2.0_f64.powi(idx.checked_shl((-schema) as u32)?));
1209    }
1210    Some(2.0_f64.powf(idx as f64 / (1u32 << schema) as f64))
1211}
1212
1213/// Returns the Arrow type for a complete native histogram value.
1214pub fn native_histogram_arrow_type() -> ArrowDataType {
1215    native_histogram_value_type().as_arrow_type()
1216}
1217
1218fn struct_child<'a>(array: &'a StructArray, name: &str) -> DfResult<&'a ArrayRef> {
1219    let index = array
1220        .fields()
1221        .iter()
1222        .position(|field| field.name() == name)
1223        .ok_or_else(|| {
1224            DataFusionError::Execution(format!("native histogram missing field {name}"))
1225        })?;
1226    Ok(array.column(index))
1227}
1228
1229fn primitive_child<'a, T>(array: &'a StructArray, name: &str) -> DfResult<&'a PrimitiveArray<T>>
1230where
1231    T: ArrowPrimitiveType,
1232{
1233    let child = struct_child(array, name)?;
1234    child
1235        .as_any()
1236        .downcast_ref::<PrimitiveArray<T>>()
1237        .ok_or_else(|| {
1238            DataFusionError::Execution(format!(
1239                "native histogram field {name} has invalid type {}",
1240                child.data_type()
1241            ))
1242        })
1243}
1244
1245fn list_child<'a>(array: &'a StructArray, name: &str) -> DfResult<&'a ListArray> {
1246    let child = struct_child(array, name)?;
1247    child.as_any().downcast_ref::<ListArray>().ok_or_else(|| {
1248        DataFusionError::Execution(format!(
1249            "native histogram field {name} has invalid type {}",
1250            child.data_type()
1251        ))
1252    })
1253}
1254
1255fn required_primitive<T>(array: &StructArray, name: &str, row: usize) -> DfResult<T::Native>
1256where
1257    T: ArrowPrimitiveType,
1258{
1259    let values = primitive_child::<T>(array, name)?;
1260    if values.is_null(row) {
1261        return Err(DataFusionError::Execution(format!(
1262            "native histogram field {name} is null"
1263        )));
1264    }
1265    Ok(values.value(row))
1266}
1267
1268fn optional_primitive<T>(array: &StructArray, name: &str, row: usize) -> DfResult<Option<T::Native>>
1269where
1270    T: ArrowPrimitiveType,
1271{
1272    let values = primitive_child::<T>(array, name)?;
1273    Ok((!values.is_null(row)).then(|| values.value(row)))
1274}
1275
1276fn list_values<T>(array: &StructArray, name: &str, row: usize) -> DfResult<Vec<T::Native>>
1277where
1278    T: ArrowPrimitiveType,
1279{
1280    let list = list_child(array, name)?;
1281    if list.is_null(row) {
1282        return Ok(Vec::new());
1283    }
1284
1285    let values = list.value(row);
1286    let values = values
1287        .as_any()
1288        .downcast_ref::<PrimitiveArray<T>>()
1289        .ok_or_else(|| {
1290            DataFusionError::Execution(format!(
1291                "native histogram list field {name} has invalid value type {}",
1292                values.data_type()
1293            ))
1294        })?;
1295
1296    values
1297        .iter()
1298        .map(|value| {
1299            value.ok_or_else(|| {
1300                DataFusionError::Execution(format!(
1301                    "native histogram list field {name} contains null"
1302                ))
1303            })
1304        })
1305        .collect()
1306}
1307
1308fn read_spans(offsets: Vec<i32>, lengths: Vec<u32>, name: &str) -> DfResult<Vec<Span>> {
1309    if offsets.len() != lengths.len() {
1310        return Err(DataFusionError::Execution(format!(
1311            "native histogram {name} span offsets and lengths mismatch: {} vs {}",
1312            offsets.len(),
1313            lengths.len()
1314        )));
1315    }
1316    Ok(offsets
1317        .into_iter()
1318        .zip(lengths)
1319        .map(|(offset, length)| Span { offset, length })
1320        .collect())
1321}
1322
1323fn check_span_bucket_count(spans: &[Span], buckets: usize, name: &str) -> DfResult<()> {
1324    let span_len = spans
1325        .iter()
1326        .try_fold(0usize, |sum, span| sum.checked_add(span.length as usize))
1327        .ok_or_else(|| {
1328            DataFusionError::Execution(format!("native histogram {name} spans overflow"))
1329        })?;
1330    if span_len != buckets {
1331        return Err(DataFusionError::Execution(format!(
1332            "native histogram {name} spans describe {span_len} buckets, found {buckets}"
1333        )));
1334    }
1335    Ok(())
1336}
1337
1338/// Decodes one native histogram row into the query-time [`NativeHistogram`] model.
1339///
1340/// A null struct returns `Ok(None)`. Integer payloads are converted to absolute
1341/// `f64` counts, and malformed child fields or span layouts return an error.
1342pub fn read_histogram(array: &StructArray, row: usize) -> DfResult<Option<NativeHistogram>> {
1343    if array.is_null(row) {
1344        return Ok(None);
1345    }
1346
1347    let schema = required_primitive::<Int32Type>(array, SCHEMA_FIELD, row)?;
1348    let positive_spans = read_spans(
1349        list_values::<Int32Type>(array, POSITIVE_SPAN_OFFSETS_FIELD, row)?,
1350        list_values::<UInt32Type>(array, POSITIVE_SPAN_LENGTHS_FIELD, row)?,
1351        "positive",
1352    )?;
1353    let negative_spans = read_spans(
1354        list_values::<Int32Type>(array, NEGATIVE_SPAN_OFFSETS_FIELD, row)?,
1355        list_values::<UInt32Type>(array, NEGATIVE_SPAN_LENGTHS_FIELD, row)?,
1356        "negative",
1357    )?;
1358
1359    let (count, zero_count, positive_buckets, negative_buckets) =
1360        if let Some(count) = optional_primitive::<Float64Type>(array, COUNT_F64_FIELD, row)? {
1361            (
1362                count,
1363                optional_primitive::<Float64Type>(array, ZERO_COUNT_F64_FIELD, row)?
1364                    .unwrap_or_default(),
1365                list_values::<Float64Type>(array, POSITIVE_BUCKETS_F64_FIELD, row)?,
1366                list_values::<Float64Type>(array, NEGATIVE_BUCKETS_F64_FIELD, row)?,
1367            )
1368        } else {
1369            (
1370                required_primitive::<UInt64Type>(array, COUNT_U64_FIELD, row)? as f64,
1371                optional_primitive::<UInt64Type>(array, ZERO_COUNT_U64_FIELD, row)?
1372                    .unwrap_or_default() as f64,
1373                list_values::<Int64Type>(array, POSITIVE_BUCKETS_I64_FIELD, row)?
1374                    .into_iter()
1375                    .map(|value| value as f64)
1376                    .collect(),
1377                list_values::<Int64Type>(array, NEGATIVE_BUCKETS_I64_FIELD, row)?
1378                    .into_iter()
1379                    .map(|value| value as f64)
1380                    .collect(),
1381            )
1382        };
1383
1384    check_span_bucket_count(&positive_spans, positive_buckets.len(), "positive")?;
1385    check_span_bucket_count(&negative_spans, negative_buckets.len(), "negative")?;
1386
1387    Ok(Some(NativeHistogram {
1388        schema,
1389        zero_threshold: required_primitive::<Float64Type>(array, ZERO_THRESHOLD_FIELD, row)?,
1390        sum: required_primitive::<Float64Type>(array, SUM_FIELD, row)?,
1391        reset_hint: required_primitive::<Int32Type>(array, RESET_HINT_FIELD, row)?.into(),
1392        start_timestamp: optional_primitive::<TimestampMillisecondType>(
1393            array,
1394            START_TIMESTAMP_FIELD,
1395            row,
1396        )?,
1397        custom_values: list_values::<Float64Type>(array, CUSTOM_VALUES_FIELD, row)?,
1398        positive_spans,
1399        negative_spans,
1400        count,
1401        zero_count,
1402        positive_buckets,
1403        negative_buckets,
1404    }))
1405}
1406
1407fn list_opt<T>(values: Vec<T>) -> Option<Vec<Option<T>>> {
1408    Some(values.into_iter().map(Some).collect())
1409}
1410
1411/// Builds a native histogram Struct array from query-time values.
1412///
1413/// The result uses the canonical `f64` payload fields. Integer count and bucket
1414/// fields remain empty because [`NativeHistogram`] no longer tracks the source
1415/// payload family after decoding.
1416pub fn build_histogram_array(values: &[Option<NativeHistogram>]) -> ArrayRef {
1417    let mut schemas = Vec::with_capacity(values.len());
1418    let mut zero_thresholds = Vec::with_capacity(values.len());
1419    let mut sums = Vec::with_capacity(values.len());
1420    let mut reset_hints = Vec::with_capacity(values.len());
1421    let mut start_timestamps = Vec::with_capacity(values.len());
1422    let mut custom_values = Vec::with_capacity(values.len());
1423    let mut positive_span_offsets = Vec::with_capacity(values.len());
1424    let mut positive_span_lengths = Vec::with_capacity(values.len());
1425    let mut negative_span_offsets = Vec::with_capacity(values.len());
1426    let mut negative_span_lengths = Vec::with_capacity(values.len());
1427    let mut count_u64 = Vec::<Option<u64>>::with_capacity(values.len());
1428    let mut zero_count_u64 = Vec::<Option<u64>>::with_capacity(values.len());
1429    let mut positive_buckets_i64 = Vec::with_capacity(values.len());
1430    let mut negative_buckets_i64 = Vec::with_capacity(values.len());
1431    let mut count_f64 = Vec::with_capacity(values.len());
1432    let mut zero_count_f64 = Vec::with_capacity(values.len());
1433    let mut positive_buckets_f64 = Vec::with_capacity(values.len());
1434    let mut negative_buckets_f64 = Vec::with_capacity(values.len());
1435    let mut validity = Vec::with_capacity(values.len());
1436
1437    for value in values {
1438        validity.push(value.is_some());
1439        if let Some(histogram) = value {
1440            schemas.push(Some(histogram.schema));
1441            zero_thresholds.push(Some(histogram.zero_threshold));
1442            sums.push(Some(histogram.sum));
1443            reset_hints.push(Some(i32::from(histogram.reset_hint)));
1444            start_timestamps.push(histogram.start_timestamp);
1445            custom_values.push(list_opt(histogram.custom_values.clone()));
1446            positive_span_offsets.push(list_opt(
1447                histogram
1448                    .positive_spans
1449                    .iter()
1450                    .map(|span| span.offset)
1451                    .collect(),
1452            ));
1453            positive_span_lengths.push(list_opt(
1454                histogram
1455                    .positive_spans
1456                    .iter()
1457                    .map(|span| span.length)
1458                    .collect(),
1459            ));
1460            negative_span_offsets.push(list_opt(
1461                histogram
1462                    .negative_spans
1463                    .iter()
1464                    .map(|span| span.offset)
1465                    .collect(),
1466            ));
1467            negative_span_lengths.push(list_opt(
1468                histogram
1469                    .negative_spans
1470                    .iter()
1471                    .map(|span| span.length)
1472                    .collect(),
1473            ));
1474            count_u64.push(None);
1475            zero_count_u64.push(None);
1476            positive_buckets_i64.push(list_opt(Vec::<i64>::new()));
1477            negative_buckets_i64.push(list_opt(Vec::<i64>::new()));
1478            count_f64.push(Some(histogram.count));
1479            zero_count_f64.push(Some(histogram.zero_count));
1480            positive_buckets_f64.push(list_opt(histogram.positive_buckets.clone()));
1481            negative_buckets_f64.push(list_opt(histogram.negative_buckets.clone()));
1482        } else {
1483            schemas.push(None);
1484            zero_thresholds.push(None);
1485            sums.push(None);
1486            reset_hints.push(None);
1487            start_timestamps.push(None);
1488            custom_values.push(None);
1489            positive_span_offsets.push(None);
1490            positive_span_lengths.push(None);
1491            negative_span_offsets.push(None);
1492            negative_span_lengths.push(None);
1493            count_u64.push(None);
1494            zero_count_u64.push(None);
1495            positive_buckets_i64.push(None);
1496            negative_buckets_i64.push(None);
1497            count_f64.push(None);
1498            zero_count_f64.push(None);
1499            positive_buckets_f64.push(None);
1500            negative_buckets_f64.push(None);
1501        }
1502    }
1503
1504    let named_arrays: Vec<(&str, ArrayRef)> = vec![
1505        (SCHEMA_FIELD, Arc::new(Int32Array::from(schemas))),
1506        (
1507            ZERO_THRESHOLD_FIELD,
1508            Arc::new(Float64Array::from(zero_thresholds)),
1509        ),
1510        (SUM_FIELD, Arc::new(Float64Array::from(sums))),
1511        (RESET_HINT_FIELD, Arc::new(Int32Array::from(reset_hints))),
1512        (
1513            START_TIMESTAMP_FIELD,
1514            Arc::new(TimestampMillisecondArray::from_iter(start_timestamps)),
1515        ),
1516        (
1517            CUSTOM_VALUES_FIELD,
1518            Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
1519                custom_values,
1520            )),
1521        ),
1522        (
1523            POSITIVE_SPAN_OFFSETS_FIELD,
1524            Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
1525                positive_span_offsets,
1526            )),
1527        ),
1528        (
1529            POSITIVE_SPAN_LENGTHS_FIELD,
1530            Arc::new(ListArray::from_iter_primitive::<UInt32Type, _, _>(
1531                positive_span_lengths,
1532            )),
1533        ),
1534        (
1535            NEGATIVE_SPAN_OFFSETS_FIELD,
1536            Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
1537                negative_span_offsets,
1538            )),
1539        ),
1540        (
1541            NEGATIVE_SPAN_LENGTHS_FIELD,
1542            Arc::new(ListArray::from_iter_primitive::<UInt32Type, _, _>(
1543                negative_span_lengths,
1544            )),
1545        ),
1546        (COUNT_U64_FIELD, Arc::new(UInt64Array::from(count_u64))),
1547        (
1548            ZERO_COUNT_U64_FIELD,
1549            Arc::new(UInt64Array::from(zero_count_u64)),
1550        ),
1551        (
1552            POSITIVE_BUCKETS_I64_FIELD,
1553            Arc::new(ListArray::from_iter_primitive::<Int64Type, _, _>(
1554                positive_buckets_i64,
1555            )),
1556        ),
1557        (
1558            NEGATIVE_BUCKETS_I64_FIELD,
1559            Arc::new(ListArray::from_iter_primitive::<Int64Type, _, _>(
1560                negative_buckets_i64,
1561            )),
1562        ),
1563        (COUNT_F64_FIELD, Arc::new(Float64Array::from(count_f64))),
1564        (
1565            ZERO_COUNT_F64_FIELD,
1566            Arc::new(Float64Array::from(zero_count_f64)),
1567        ),
1568        (
1569            POSITIVE_BUCKETS_F64_FIELD,
1570            Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
1571                positive_buckets_f64,
1572            )),
1573        ),
1574        (
1575            NEGATIVE_BUCKETS_F64_FIELD,
1576            Arc::new(ListArray::from_iter_primitive::<Float64Type, _, _>(
1577                negative_buckets_f64,
1578            )),
1579        ),
1580    ];
1581    let (fields, arrays): (Vec<_>, Vec<_>) = named_arrays
1582        .into_iter()
1583        .map(|(name, array)| (Field::new(name, array.data_type().clone(), true), array))
1584        .unzip();
1585
1586    Arc::new(StructArray::new(
1587        fields.into(),
1588        arrays,
1589        Some(NullBuffer::from(validity)),
1590    ))
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596
1597    fn histogram(positive_spans: Vec<Span>, positive_buckets: Vec<f64>) -> NativeHistogram {
1598        let count = positive_buckets.iter().sum();
1599        NativeHistogram {
1600            schema: 0,
1601            zero_threshold: 0.0,
1602            sum: count,
1603            reset_hint: CounterResetHint::CounterReset,
1604            start_timestamp: None,
1605            custom_values: Vec::new(),
1606            positive_spans,
1607            negative_spans: Vec::new(),
1608            count,
1609            zero_count: 0.0,
1610            positive_buckets,
1611            negative_buckets: Vec::new(),
1612        }
1613    }
1614
1615    #[test]
1616    fn span_rebuild_checks_offsets_and_preserves_empty_input() {
1617        assert_eq!(
1618            spans_from_indices_counts(Vec::new()),
1619            Some((Vec::new(), Vec::new()))
1620        );
1621        assert_eq!(
1622            spans_from_indices_counts(vec![(2, 1.0), (3, 2.0), (5, 3.0)]),
1623            Some((
1624                vec![
1625                    Span {
1626                        offset: 2,
1627                        length: 2,
1628                    },
1629                    Span {
1630                        offset: 1,
1631                        length: 1,
1632                    },
1633                ],
1634                vec![1.0, 2.0, 3.0],
1635            ))
1636        );
1637        assert_eq!(
1638            spans_from_indices_counts(vec![(i32::MIN, 1.0), (i32::MAX, 2.0)]),
1639            None
1640        );
1641    }
1642
1643    #[test]
1644    fn arrow_round_trip_preserves_unrecognized_reset_hint() {
1645        let mut expected = histogram(
1646            vec![Span {
1647                offset: 0,
1648                length: 1,
1649            }],
1650            vec![1.0],
1651        );
1652        expected.reset_hint = CounterResetHint::Unrecognized(42);
1653
1654        let array = build_histogram_array(&[Some(expected.clone())]);
1655        assert_eq!(array.data_type(), &native_histogram_arrow_type());
1656        let array = array.as_any().downcast_ref::<StructArray>().unwrap();
1657
1658        assert_eq!(read_histogram(array, 0).unwrap(), Some(expected));
1659        assert!(
1660            primitive_child::<UInt64Type>(array, COUNT_U64_FIELD)
1661                .unwrap()
1662                .is_null(0)
1663        );
1664        assert_eq!(
1665            primitive_child::<Float64Type>(array, COUNT_F64_FIELD)
1666                .unwrap()
1667                .value(0),
1668            1.0
1669        );
1670    }
1671
1672    #[test]
1673    fn add_uses_sparse_bucket_union() {
1674        let left = histogram(
1675            vec![Span {
1676                offset: 0,
1677                length: 1,
1678            }],
1679            vec![1.0],
1680        );
1681        let right = histogram(
1682            vec![Span {
1683                offset: 2,
1684                length: 1,
1685            }],
1686            vec![2.0],
1687        );
1688
1689        let result = left.add(&right).unwrap();
1690        assert_eq!(
1691            result.positive_spans,
1692            vec![
1693                Span {
1694                    offset: 0,
1695                    length: 1,
1696                },
1697                Span {
1698                    offset: 1,
1699                    length: 1,
1700                },
1701            ]
1702        );
1703        assert_eq!(result.positive_buckets, vec![1.0, 2.0]);
1704    }
1705
1706    #[test]
1707    fn add_combines_reset_hints_for_empty_payloads() {
1708        let mut empty = histogram(Vec::new(), Vec::new());
1709        empty.reset_hint = CounterResetHint::Gauge;
1710        let mut populated = histogram(
1711            vec![Span {
1712                offset: 0,
1713                length: 1,
1714            }],
1715            vec![1.0],
1716        );
1717        populated.reset_hint = CounterResetHint::NotCounterReset;
1718
1719        assert_eq!(
1720            empty.add(&populated).unwrap().reset_hint,
1721            CounterResetHint::Gauge
1722        );
1723        assert_eq!(
1724            populated.add(&empty).unwrap().reset_hint,
1725            CounterResetHint::Gauge
1726        );
1727    }
1728
1729    #[test]
1730    fn zero_threshold_expands_to_populated_bucket_boundary() {
1731        let left = NativeHistogram {
1732            zero_threshold: 0.5,
1733            ..histogram(
1734                vec![Span {
1735                    offset: 0,
1736                    length: 1,
1737                }],
1738                vec![1.0],
1739            )
1740        };
1741        let right = NativeHistogram {
1742            zero_threshold: 0.75,
1743            ..histogram(
1744                vec![Span {
1745                    offset: 1,
1746                    length: 1,
1747                }],
1748                vec![1.0],
1749            )
1750        };
1751
1752        let result = left.add(&right).unwrap();
1753        assert_eq!(result.zero_threshold, 1.0);
1754        assert_eq!(result.zero_count, 1.0);
1755        assert_eq!(result.positive_buckets, vec![1.0]);
1756    }
1757
1758    #[test]
1759    fn custom_reconciliation_without_shared_bounds_uses_overflow_bucket() {
1760        let mut left = histogram(
1761            vec![Span {
1762                offset: 0,
1763                length: 1,
1764            }],
1765            vec![1.0],
1766        );
1767        left.schema = CUSTOM_BUCKETS_SCHEMA;
1768        left.custom_values = vec![1.0];
1769
1770        let mut right = histogram(
1771            vec![Span {
1772                offset: 0,
1773                length: 1,
1774            }],
1775            vec![2.0],
1776        );
1777        right.schema = CUSTOM_BUCKETS_SCHEMA;
1778        right.custom_values = vec![2.0];
1779
1780        let result = left.add(&right).unwrap();
1781        assert!(result.custom_values.is_empty());
1782        assert_eq!(
1783            result.positive_spans,
1784            vec![Span {
1785                offset: 0,
1786                length: 1,
1787            }]
1788        );
1789        assert_eq!(result.positive_buckets, vec![3.0]);
1790    }
1791
1792    #[test]
1793    fn promql_eq_ignores_reset_hint_start_timestamp_and_sparse_zeros() {
1794        let mut left = histogram(
1795            vec![Span {
1796                offset: 0,
1797                length: 2,
1798            }],
1799            vec![1.0, 0.0],
1800        );
1801        left.reset_hint = CounterResetHint::CounterReset;
1802        left.start_timestamp = Some(1000);
1803
1804        let mut right = histogram(
1805            vec![Span {
1806                offset: 0,
1807                length: 1,
1808            }],
1809            vec![1.0],
1810        );
1811        right.reset_hint = CounterResetHint::NotCounterReset;
1812        right.start_timestamp = Some(2000);
1813
1814        assert!(left.promql_eq(&right));
1815    }
1816
1817    #[test]
1818    fn promql_eq_compares_payload_floats_by_bits() {
1819        let nan = f64::from_bits(0x7ff8_0000_0000_0001);
1820        let other_nan = f64::from_bits(0x7ff8_0000_0000_0002);
1821        let mut left = histogram(
1822            vec![Span {
1823                offset: 0,
1824                length: 1,
1825            }],
1826            vec![nan],
1827        );
1828        left.count = nan;
1829        left.zero_count = nan;
1830        left.sum = nan;
1831        let right = left.clone();
1832
1833        assert!(left.promql_eq(&right));
1834
1835        for changed in [
1836            NativeHistogram {
1837                count: other_nan,
1838                ..right.clone()
1839            },
1840            NativeHistogram {
1841                zero_count: other_nan,
1842                ..right.clone()
1843            },
1844            NativeHistogram {
1845                sum: other_nan,
1846                ..right.clone()
1847            },
1848            NativeHistogram {
1849                positive_buckets: vec![other_nan],
1850                ..right.clone()
1851            },
1852        ] {
1853            assert!(!left.promql_eq(&changed));
1854        }
1855
1856        let undecodable = histogram(
1857            vec![Span {
1858                offset: i32::MAX,
1859                length: 1,
1860            }],
1861            vec![1.0],
1862        );
1863        assert!(undecodable.promql_eq(&undecodable.clone()));
1864    }
1865
1866    #[test]
1867    fn detect_reset_preserves_layout_direction() {
1868        let mut previous = histogram(
1869            vec![Span {
1870                offset: 1,
1871                length: 1,
1872            }],
1873            vec![1.0],
1874        );
1875        previous.reset_hint = CounterResetHint::Unknown;
1876
1877        let mut higher_resolution = previous.clone();
1878        higher_resolution.schema = 1;
1879        higher_resolution.positive_spans[0].offset = 2;
1880        assert!(higher_resolution.detect_reset(&previous));
1881
1882        let mut lower_resolution = previous.clone();
1883        lower_resolution.schema = 0;
1884        lower_resolution.positive_spans[0].offset = 1;
1885        let mut previous_higher_resolution = higher_resolution;
1886        previous_higher_resolution.reset_hint = CounterResetHint::Unknown;
1887        assert!(!lower_resolution.detect_reset(&previous_higher_resolution));
1888
1889        let mut smaller_zero_threshold = previous.clone();
1890        smaller_zero_threshold.zero_threshold = 0.5;
1891        previous.zero_threshold = 1.0;
1892        assert!(smaller_zero_threshold.detect_reset(&previous));
1893
1894        let mut previous = histogram(
1895            vec![Span {
1896                offset: 0,
1897                length: 1,
1898            }],
1899            vec![1.0],
1900        );
1901        previous.reset_hint = CounterResetHint::Unknown;
1902        let mut split_bucket = histogram(Vec::new(), Vec::new());
1903        split_bucket.reset_hint = CounterResetHint::Unknown;
1904        split_bucket.count = 1.0;
1905        split_bucket.zero_count = 1.0;
1906        split_bucket.zero_threshold = 0.75;
1907        assert!(split_bucket.detect_reset(&previous));
1908
1909        split_bucket.zero_threshold = 1.0;
1910        assert!(!split_bucket.detect_reset(&previous));
1911    }
1912
1913    #[test]
1914    fn extreme_bucket_index_returns_none_instead_of_overflowing() {
1915        assert_eq!(ceil_div(i32::MIN, 2), i32::MIN / 2);
1916
1917        let mut histogram = histogram(
1918            vec![Span {
1919                offset: i32::MIN,
1920                length: 1,
1921            }],
1922            vec![1.0],
1923        );
1924        assert!(histogram.side_buckets(true).is_none());
1925
1926        histogram.schema = 1;
1927        let reduced = histogram.copy_to_schema(0).unwrap();
1928        assert_eq!(reduced.positive_spans[0].offset, i32::MIN / 2);
1929    }
1930
1931    #[test]
1932    fn fraction_excludes_nan_observations() {
1933        let mut histogram = histogram(
1934            vec![Span {
1935                offset: 0,
1936                length: 1,
1937            }],
1938            vec![8.0],
1939        );
1940        histogram.count = 10.0;
1941        histogram.sum = f64::NAN;
1942
1943        assert_eq!(histogram.fraction(f64::NEG_INFINITY, f64::INFINITY), 0.8);
1944    }
1945
1946    #[test]
1947    fn subtraction_returns_gauge_histogram() {
1948        let left = histogram(
1949            vec![Span {
1950                offset: 0,
1951                length: 1,
1952            }],
1953            vec![3.0],
1954        );
1955        let right = histogram(
1956            vec![Span {
1957                offset: 0,
1958                length: 1,
1959            }],
1960            vec![1.0],
1961        );
1962
1963        let result = left.sub(&right).unwrap();
1964        assert_eq!(result.reset_hint, CounterResetHint::Gauge);
1965        assert_eq!(result.positive_buckets, vec![2.0]);
1966    }
1967
1968    #[test]
1969    fn subtraction_treats_empty_left_as_zero() {
1970        let left = histogram(vec![], vec![]);
1971        let mut right = histogram(
1972            vec![Span {
1973                offset: 0,
1974                length: 1,
1975            }],
1976            vec![2.0],
1977        );
1978        right.schema = CUSTOM_BUCKETS_SCHEMA;
1979        right.custom_values = vec![1.0];
1980
1981        let result = left.sub(&right).unwrap();
1982
1983        assert_eq!(result.schema, CUSTOM_BUCKETS_SCHEMA);
1984        assert_eq!(result.reset_hint, CounterResetHint::Gauge);
1985        assert_eq!(result.count, -2.0);
1986        assert_eq!(result.sum, -2.0);
1987        assert_eq!(result.positive_buckets, vec![-2.0]);
1988    }
1989}
1990
1991// ---------------------------------------------------------------------------
1992// Stable Parquet field ids for native-histogram sub-fields.
1993//
1994// External readers resolve nested struct fields by `PARQUET:field_id`, so each
1995// sub-field (and list element) needs a stable positive id. The struct schema
1996// is fixed (always the same 18 fields). Each histogram column owns a block of
1997// ids (offset from a reserved base by the column's id), so several histogram
1998// columns in one table get disjoint sub-field ids. The reserved base is
1999// disjoint from user column ids and mito2 internal ids (`1 << 30`).
2000//
2001// The id is computed with checked arithmetic: the reserved base plus
2002// `column_id * stride` cannot always fit in a positive `i32` (a `ColumnId` is
2003// `u32`), so derivation returns `None` once the representable range is
2004// exceeded. Callers must handle `None` explicitly — the SST parquet writer
2005// surfaces it as an error rather than wrapping, panicking, or silently
2006// dropping the field id.
2007// ---------------------------------------------------------------------------
2008
2009/// Reserved base for native-histogram struct sub-field ids.
2010pub const NATIVE_HISTOGRAM_SUBFIELD_ID_BASE: i32 = 0x5000_0000;
2011
2012/// Number of ids reserved per histogram column (18 sub-fields + headroom for
2013/// list element ids), so multiple histogram columns get disjoint ids.
2014pub const NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE: i32 = 64;
2015
2016/// Offset of list element ids within a column's id block.
2017pub const NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET: i32 = 32;
2018
2019/// Returns the stable field id for a native-histogram struct sub-field,
2020/// namespaced by its parent `column_id`, or `None` if `name` is not a known
2021/// sub-field or the derived id overflows a positive `i32`.
2022pub fn native_histogram_subfield_id(column_id: i32, name: &str) -> Option<i32> {
2023    let idx = subfield_index(name)?;
2024    NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
2025        .checked_add(column_id.checked_mul(NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE)?)
2026        .and_then(|v| v.checked_add(idx))
2027}
2028
2029/// Returns the stable list `element-id` for a list-typed native-histogram
2030/// sub-field, namespaced by its parent `column_id`, or `None` if `name` is not
2031/// a known sub-field or the derived id overflows a positive `i32`.
2032pub fn native_histogram_list_element_id(column_id: i32, name: &str) -> Option<i32> {
2033    let idx = subfield_index(name)?;
2034    NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
2035        .checked_add(column_id.checked_mul(NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE)?)
2036        .and_then(|v| v.checked_add(NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET))
2037        .and_then(|v| v.checked_add(idx))
2038}
2039
2040fn subfield_index(name: &str) -> Option<i32> {
2041    NATIVE_HISTOGRAM_FIELD_NAMES
2042        .iter()
2043        .position(|n| *n == name)
2044        .map(|i| i as i32)
2045}
2046
2047#[cfg(test)]
2048mod subfield_id_tests {
2049    use super::*;
2050
2051    #[test]
2052    fn subfield_ids_are_namespaced_and_disjoint() {
2053        // SCHEMA=0, ZERO_THRESHOLD=1, SUM=2, ..., CUSTOM_VALUES=5.
2054        let sum_idx = 2;
2055        let custom_values_idx = 5;
2056
2057        // Ids are offset from the reserved base by the parent column id and
2058        // the sub-field index.
2059        assert_eq!(
2060            native_histogram_subfield_id(1, SUM_FIELD),
2061            Some(NATIVE_HISTOGRAM_SUBFIELD_ID_BASE + NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE + sum_idx,)
2062        );
2063        // List element ids additionally carry the list offset.
2064        assert_eq!(
2065            native_histogram_list_element_id(1, CUSTOM_VALUES_FIELD),
2066            Some(
2067                NATIVE_HISTOGRAM_SUBFIELD_ID_BASE
2068                    + NATIVE_HISTOGRAM_SUBFIELD_ID_STRIDE
2069                    + NATIVE_HISTOGRAM_LIST_ELEMENT_OFFSET
2070                    + custom_values_idx,
2071            )
2072        );
2073        // Different parent columns get disjoint ids for the same sub-field.
2074        assert_ne!(
2075            native_histogram_subfield_id(1, SUM_FIELD),
2076            native_histogram_subfield_id(7, SUM_FIELD)
2077        );
2078        // Unknown sub-field name -> None.
2079        assert_eq!(native_histogram_subfield_id(1, "not_a_field"), None);
2080    }
2081
2082    #[test]
2083    fn subfield_ids_overflow_returns_none() {
2084        // `column_id` is u32-sized, but the derived id must fit in a positive
2085        // i32. At column_id = 12_582_912, BASE + column_id*64 == i32::MAX + 1,
2086        // which previously overflowed (debug panic / release wrap). Checked
2087        // arithmetic must yield None instead of wrapping or panicking.
2088        assert_eq!(native_histogram_subfield_id(12_582_912, SUM_FIELD), None);
2089        assert_eq!(
2090            native_histogram_list_element_id(12_582_912, CUSTOM_VALUES_FIELD),
2091            None
2092        );
2093        // One below that boundary is still representable.
2094        assert!(native_histogram_subfield_id(12_582_911, SUM_FIELD).is_some());
2095    }
2096}