Skip to main content

common_query/
native_histogram.rs

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