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