Skip to content

Commit 30ee642

Browse files
committed
fix integer/floating point promotion logic in v3 value compaction
1 parent 4ad619f commit 30ee642

2 files changed

Lines changed: 176 additions & 45 deletions

File tree

lib/saluki-components/src/encoders/datadog/metrics/v3/types.rs

Lines changed: 129 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -80,27 +80,51 @@ impl V3ValueType {
8080
pub fn as_u64(self) -> u64 {
8181
self as u64
8282
}
83+
}
8384

84-
/// Determines the best value type for a given f64 value.
85-
///
86-
/// Prefers smaller representations when lossless:
87-
/// - Zero for 0.0
88-
/// - Sint64 for integers that fit in 49 bits
89-
/// - Float32 for values representable as f32
90-
/// - Float64 otherwise
91-
pub fn for_value(v: f64) -> Self {
85+
/// Intermediate point classification for value type compaction.
86+
///
87+
/// This provides finer-grained classification than [`V3ValueType`] to avoid
88+
/// precision loss when combining different value types. In particular, it
89+
/// distinguishes small integers (that fit losslessly in f32) from large integers
90+
/// (that don't), so that mixing a large integer with a Float32 value correctly
91+
/// escalates to Float64 rather than silently truncating the integer.
92+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
93+
#[repr(u8)]
94+
enum PointKind {
95+
/// Value is zero.
96+
Zero = 0,
97+
/// Integer with |v| <= 2^24, fits losslessly in both sint64 and f32.
98+
Int24 = 1,
99+
/// Integer with |v| > 2^24, fits in sint64 varint but NOT losslessly in f32.
100+
Int48 = 2,
101+
/// Fractional value exactly representable as f32.
102+
Float32 = 3,
103+
/// Everything else — requires full f64 precision.
104+
Float64 = 4,
105+
}
106+
107+
/// Maximum integer magnitude that fits losslessly in f32 (2^24).
108+
const F32_INT_MAX: i64 = 1 << 24;
109+
110+
impl PointKind {
111+
/// Classifies a single f64 value.
112+
fn for_value(v: f64) -> Self {
92113
if v == 0.0 {
93114
return Self::Zero;
94115
}
95116

96-
// Varint range that fits in 7 bytes or less (49 bits)
117+
// Varint range that fits in 7 bytes or less (49 bits).
97118
const VARINT_WIDTH: i32 = 7 * 7 - 1;
98119
const MAX_INT: i64 = 1 << VARINT_WIDTH;
99120
const MIN_INT: i64 = -MAX_INT;
100121

101122
let i = v as i64;
102123
if (MIN_INT..MAX_INT).contains(&i) && (i as f64) == v {
103-
return Self::Sint64;
124+
if (-F32_INT_MAX..=F32_INT_MAX).contains(&i) {
125+
return Self::Int24;
126+
}
127+
return Self::Int48;
104128
}
105129

106130
if (v as f32 as f64) == v {
@@ -110,46 +134,114 @@ impl V3ValueType {
110134
Self::Float64
111135
}
112136

113-
/// Returns the maximum (largest encoding) of two value types.
114-
pub fn max(self, other: Self) -> Self {
115-
if (other as u8) > (self as u8) {
116-
other
117-
} else {
118-
self
137+
/// Combines two point kinds into the smallest kind that can represent both.
138+
///
139+
/// This is `max(self, other)` in all cases **except**:
140+
/// - `Int48 + Float32 = Float64` (and vice versa), because large integers
141+
/// lose precision in f32, and fractional values can't be stored as sint64.
142+
fn union(self, other: Self) -> Self {
143+
match (self, other) {
144+
(Self::Int48, Self::Float32) | (Self::Float32, Self::Int48) => Self::Float64,
145+
_ => self.max(other),
119146
}
120147
}
148+
149+
/// Converts to the wire-format value type.
150+
fn to_value_type(self) -> V3ValueType {
151+
match self {
152+
Self::Zero => V3ValueType::Zero,
153+
Self::Int24 | Self::Int48 => V3ValueType::Sint64,
154+
Self::Float32 => V3ValueType::Float32,
155+
Self::Float64 => V3ValueType::Float64,
156+
}
157+
}
158+
}
159+
160+
/// Determines the best [`V3ValueType`] for a set of f64 values.
161+
///
162+
/// Uses [`PointKind`] internally to avoid precision loss when mixing
163+
/// large integers with fractional float32 values.
164+
pub(super) fn value_type_for_values(values: impl Iterator<Item = f64>) -> V3ValueType {
165+
let mut kind = PointKind::Zero;
166+
for v in values {
167+
kind = kind.union(PointKind::for_value(v));
168+
}
169+
kind.to_value_type()
121170
}
122171

123172
#[cfg(test)]
124173
mod tests {
125174
use super::*;
126175

127176
#[test]
128-
fn test_value_type_for_value() {
129-
assert_eq!(V3ValueType::for_value(0.0), V3ValueType::Zero);
130-
assert_eq!(V3ValueType::for_value(100.0), V3ValueType::Sint64);
131-
assert_eq!(V3ValueType::for_value(-100.0), V3ValueType::Sint64);
132-
assert_eq!(V3ValueType::for_value(1.5), V3ValueType::Float32);
133-
assert_eq!(V3ValueType::for_value(2.75), V3ValueType::Float32);
134-
135-
// Large integers that don't fit in 49 bits AND can't be exactly represented in f32
136-
// Powers of 2 like (1 << 50) can be exactly represented in f32, so we add 1
137-
// to make it an odd number that requires more precision than f32 provides
177+
fn test_point_kind_classification() {
178+
// Zero
179+
assert_eq!(PointKind::for_value(0.0), PointKind::Zero);
180+
181+
// Small integers (fit in f32)
182+
assert_eq!(PointKind::for_value(100.0), PointKind::Int24);
183+
assert_eq!(PointKind::for_value(-100.0), PointKind::Int24);
184+
assert_eq!(PointKind::for_value((1 << 24) as f64), PointKind::Int24);
185+
assert_eq!(PointKind::for_value(-((1 << 24) as f64)), PointKind::Int24);
186+
187+
// Large integers (don't fit losslessly in f32)
188+
assert_eq!(PointKind::for_value(((1 << 24) + 1) as f64), PointKind::Int48);
189+
assert_eq!(PointKind::for_value((1i64 << 30) as f64), PointKind::Int48);
190+
191+
// Float32
192+
assert_eq!(PointKind::for_value(1.5), PointKind::Float32);
193+
assert_eq!(PointKind::for_value(2.75), PointKind::Float32);
194+
195+
// Float64
196+
assert_eq!(PointKind::for_value(std::f64::consts::PI), PointKind::Float64);
138197
let large = ((1i64 << 50) + 1) as f64;
139-
assert_eq!(V3ValueType::for_value(large), V3ValueType::Float64);
198+
assert_eq!(PointKind::for_value(large), PointKind::Float64);
199+
}
140200

141-
// Values that require f64 precision - use PI which has more precision than f32 can hold
142-
// and isn't an integer, so it won't be stored as Sint64
143-
let pi = std::f64::consts::PI;
144-
// PI requires full f64 precision to store exactly
145-
assert_eq!(V3ValueType::for_value(pi), V3ValueType::Float64);
201+
#[test]
202+
fn test_point_kind_union() {
203+
// Standard widening (max)
204+
assert_eq!(PointKind::Zero.union(PointKind::Int24), PointKind::Int24);
205+
assert_eq!(PointKind::Int24.union(PointKind::Int48), PointKind::Int48);
206+
assert_eq!(PointKind::Int24.union(PointKind::Float32), PointKind::Float32);
207+
assert_eq!(PointKind::Float32.union(PointKind::Float64), PointKind::Float64);
208+
assert_eq!(PointKind::Float64.union(PointKind::Zero), PointKind::Float64);
209+
210+
// The critical case: large integer + float32 must escalate to float64
211+
assert_eq!(PointKind::Int48.union(PointKind::Float32), PointKind::Float64);
212+
assert_eq!(PointKind::Float32.union(PointKind::Int48), PointKind::Float64);
146213
}
147214

148215
#[test]
149-
fn test_value_type_max() {
150-
assert_eq!(V3ValueType::Zero.max(V3ValueType::Sint64), V3ValueType::Sint64);
151-
assert_eq!(V3ValueType::Sint64.max(V3ValueType::Float32), V3ValueType::Float32);
152-
assert_eq!(V3ValueType::Float32.max(V3ValueType::Float64), V3ValueType::Float64);
153-
assert_eq!(V3ValueType::Float64.max(V3ValueType::Zero), V3ValueType::Float64);
216+
fn test_value_type_for_values() {
217+
// All zeros
218+
assert_eq!(value_type_for_values([0.0, 0.0].into_iter()), V3ValueType::Zero);
219+
220+
// Small integers
221+
assert_eq!(value_type_for_values([100.0, 200.0].into_iter()), V3ValueType::Sint64);
222+
223+
// Large integers
224+
assert_eq!(
225+
value_type_for_values([(1i64 << 30) as f64, 200.0].into_iter()),
226+
V3ValueType::Sint64
227+
);
228+
229+
// Small integer + float32 → Float32 (safe, small int fits in f32)
230+
assert_eq!(value_type_for_values([100.0, 1.5].into_iter()), V3ValueType::Float32);
231+
232+
// Large integer + float32 → Float64 (the bug fix!)
233+
assert_eq!(
234+
value_type_for_values([(1i64 << 30) as f64, 1.5].into_iter()),
235+
V3ValueType::Float64
236+
);
237+
238+
// Float64 value forces Float64
239+
assert_eq!(
240+
value_type_for_values([100.0, std::f64::consts::PI].into_iter()),
241+
V3ValueType::Float64
242+
);
243+
244+
// Empty iterator
245+
assert_eq!(value_type_for_values(std::iter::empty()), V3ValueType::Zero);
154246
}
155247
}

lib/saluki-components/src/encoders/datadog/metrics/v3/writer.rs

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use protobuf::CodedOutputStream;
77
use saluki_error::GenericError;
88

99
use super::interner::Interner;
10-
use super::types::{field_numbers, V3MetricType, V3ValueType};
10+
use super::types::{field_numbers, value_type_for_values, V3MetricType, V3ValueType};
1111

1212
const METRIC_TYPE_DEFAULT: i32 = 0;
1313
const METRIC_TYPE_AGENT_HIDDEN: i32 = 9;
@@ -474,13 +474,8 @@ impl<'a> V3MetricBuilder<'a> {
474474
let start = self.point_start_idx;
475475
let end = self.writer.vals_float64.len();
476476

477-
// Determine the maximum value type needed
478-
let mut val_ty = V3ValueType::Zero;
479-
for i in start..end {
480-
let val = self.writer.vals_float64[i];
481-
let pnt_val_ty = V3ValueType::for_value(val);
482-
val_ty = val_ty.max(pnt_val_ty);
483-
}
477+
// Determine the best value type for all points in this metric.
478+
let val_ty = value_type_for_values(self.writer.vals_float64[start..end].iter().copied());
484479

485480
// Update the type field
486481
self.writer.types[self.metric_idx] |= val_ty.as_u64();
@@ -676,6 +671,50 @@ mod tests {
676671
assert!(output.is_empty());
677672
}
678673

674+
#[test]
675+
fn test_value_compaction_large_int_plus_float32() {
676+
// Regression test: a large integer (> 2^24) mixed with a fractional
677+
// float32 value must use Float64, not Float32, to avoid precision loss.
678+
let mut writer = V3Writer::new();
679+
680+
{
681+
let mut metric = writer.write(V3MetricType::Gauge, "mixed.metric");
682+
metric.add_point(1000, (1i64 << 30) as f64); // large int, doesn't fit in f32
683+
metric.add_point(2000, 1.5); // fractional, fits in f32
684+
metric.close();
685+
}
686+
687+
let data = writer.finalize_inner();
688+
689+
// Must be stored in float64, not float32
690+
assert!(
691+
data.vals_float32.is_empty(),
692+
"large int should not be stored as float32"
693+
);
694+
assert_eq!(data.vals_float64, vec![(1i64 << 30) as f64, 1.5]);
695+
assert!(data.vals_sint64.is_empty());
696+
}
697+
698+
#[test]
699+
fn test_value_compaction_small_int_plus_float32() {
700+
// Small integers (|v| <= 2^24) mixed with float32 values should
701+
// compact to Float32, since small ints fit losslessly in f32.
702+
let mut writer = V3Writer::new();
703+
704+
{
705+
let mut metric = writer.write(V3MetricType::Gauge, "small.mixed");
706+
metric.add_point(1000, 100.0);
707+
metric.add_point(2000, 1.5);
708+
metric.close();
709+
}
710+
711+
let data = writer.finalize_inner();
712+
713+
assert!(data.vals_float64.is_empty());
714+
assert_eq!(data.vals_float32, vec![100.0, 1.5]);
715+
assert!(data.vals_sint64.is_empty());
716+
}
717+
679718
#[test]
680719
fn test_serialize_basic_metric() {
681720
let mut writer = V3Writer::new();

0 commit comments

Comments
 (0)