ddsketch/canonical/mapping/
logarithmic.rs1use datadog_protos::sketches::{index_mapping::Interpolation, IndexMapping as ProtoIndexMapping};
2
3use super::IndexMapping;
4use crate::canonical::error::ProtoConversionError;
5use crate::common::float_eq;
6
7#[derive(Clone, Debug, PartialEq)]
12pub struct LogarithmicMapping {
13 gamma: f64,
15
16 index_offset: f64,
18
19 multiplier: f64,
21}
22
23impl LogarithmicMapping {
24 pub fn new(relative_accuracy: f64) -> Result<Self, &'static str> {
41 if relative_accuracy <= 0.0 || relative_accuracy >= 1.0 {
42 return Err("relative accuracy must be between 0 and 1 (exclusive)");
43 }
44
45 let gamma = (1.0 + relative_accuracy) / (1.0 - relative_accuracy);
46 if gamma <= 1.0 {
47 return Err("gamma must be greater than 1");
48 }
49
50 let multiplier = 1.0 / gamma.ln();
51
52 Ok(Self {
53 gamma,
54 index_offset: 0.0,
55 multiplier,
56 })
57 }
58
59 pub fn new_with_gamma(gamma: f64) -> Result<Self, &'static str> {
72 Self::new_with_gamma_and_offset(gamma, 0.0)
73 }
74
75 pub fn new_with_gamma_and_offset(gamma: f64, index_offset: f64) -> Result<Self, &'static str> {
84 if gamma <= 1.0 {
85 return Err("gamma must be greater than 1");
86 }
87 let multiplier = 1.0 / gamma.ln();
88 Ok(Self {
89 gamma,
90 index_offset,
91 multiplier,
92 })
93 }
94}
95
96impl IndexMapping for LogarithmicMapping {
97 fn index(&self, value: f64) -> i32 {
98 let index = value.ln() * self.multiplier + self.index_offset;
99 if index >= 0.0 {
100 index as i32
101 } else {
102 (index as i32) - 1
103 }
104 }
105
106 fn value(&self, index: i32) -> f64 {
107 self.lower_bound(index) * (1.0 + self.relative_accuracy())
108 }
109
110 fn lower_bound(&self, index: i32) -> f64 {
111 ((index as f64 - self.index_offset) / self.multiplier).exp()
112 }
113
114 fn relative_accuracy(&self) -> f64 {
115 (self.gamma - 1.0) / (self.gamma + 1.0)
116 }
117
118 fn min_indexable_value(&self) -> f64 {
119 f64::MIN_POSITIVE.max((((i32::MIN as f64) - self.index_offset) / self.multiplier + 1.0).exp())
120 }
121
122 fn max_indexable_value(&self) -> f64 {
123 ((((i32::MAX as f64) - self.index_offset) / self.multiplier - 1.0).exp()).min(f64::MAX / self.gamma)
124 }
125
126 fn gamma(&self) -> f64 {
127 self.gamma
128 }
129
130 fn index_offset(&self) -> f64 {
131 self.index_offset
132 }
133
134 fn interpolation(&self) -> Interpolation {
135 Interpolation::NONE
136 }
137
138 fn validate_proto_mapping(&self, proto: &ProtoIndexMapping) -> Result<(), ProtoConversionError> {
139 if !float_eq(proto.gamma, self.gamma) {
141 return Err(ProtoConversionError::GammaMismatch {
142 expected: self.gamma,
143 actual: proto.gamma,
144 });
145 }
146
147 if !float_eq(proto.indexOffset, self.index_offset) {
149 return Err(ProtoConversionError::NonZeroIndexOffset {
150 actual: proto.indexOffset,
151 });
152 }
153
154 let interpolation = proto.interpolation.enum_value_or_default();
156 if interpolation != Interpolation::NONE {
157 return Err(ProtoConversionError::UnsupportedInterpolation {
158 actual: interpolation as i32,
159 });
160 }
161
162 Ok(())
163 }
164
165 fn to_proto(&self) -> ProtoIndexMapping {
166 let mut proto = ProtoIndexMapping::new();
167 proto.gamma = self.gamma;
168 proto.indexOffset = self.index_offset;
169 proto.interpolation = protobuf::EnumOrUnknown::new(Interpolation::NONE);
170 proto
171 }
172}
173
174impl Default for LogarithmicMapping {
175 fn default() -> Self {
177 Self::new(0.01).expect("0.01 is a valid relative accuracy")
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn test_new_valid_accuracy() {
187 let mapping = LogarithmicMapping::new(0.01).unwrap();
188 assert!((mapping.relative_accuracy() - 0.01).abs() < 1e-10);
189 }
190
191 #[test]
192 fn test_new_invalid_accuracy_zero() {
193 assert!(LogarithmicMapping::new(0.0).is_err());
194 }
195
196 #[test]
197 fn test_new_invalid_accuracy_one() {
198 assert!(LogarithmicMapping::new(1.0).is_err());
199 }
200
201 #[test]
202 fn test_new_invalid_accuracy_negative() {
203 assert!(LogarithmicMapping::new(-0.1).is_err());
204 }
205
206 #[test]
207 fn test_index_value_roundtrip() {
208 let mapping = LogarithmicMapping::new(0.01).unwrap();
209
210 for i in -100..100 {
212 let value = mapping.value(i);
213 let recovered_index = mapping.index(value);
214 assert!(
216 (recovered_index - i).abs() <= 1,
217 "index {} -> value {} -> index {}",
218 i,
219 value,
220 recovered_index
221 );
222 }
223 }
224
225 #[test]
226 fn test_bounds_ordering() {
227 let mapping = LogarithmicMapping::new(0.01).unwrap();
228
229 for i in -100..100 {
230 let lower = mapping.lower_bound(i);
231 let value = mapping.value(i);
232
233 assert!(
234 lower < value,
235 "lower {} should be < value {} for index {}",
236 lower,
237 value,
238 i
239 );
240 }
241 }
242
243 #[test]
244 fn test_gamma_calculation() {
245 let mapping = LogarithmicMapping::new(0.01).unwrap();
246 let expected_gamma = 1.01 / 0.99;
248 assert!((mapping.gamma() - expected_gamma).abs() < 1e-10);
249 }
250}