vitaminc_normalize/scalar.rs
1//! One normalized equality term per scalar.
2//!
3//! [`normalize`] turns a physical value and its optional logical type into
4//! the bytes an equality index derives under, and the frozen domain label
5//! those bytes belong to. The encoders are public, one per kind, so an index
6//! layer that already knows a value's kind can call them directly.
7//!
8//! Every integer width shares one 17-byte encoding and domain, and both
9//! float widths share one 8-byte encoding and domain, so equal numbers
10//! match whichever width wrote them. Text is Unicode 16 NFC. These bytes
11//! and their [`domain`] labels are protocol data: changing either requires
12//! a new domain. Text processing is variable-time; this module makes no
13//! constant-time claim for Unicode normalization.
14//!
15//! Logical types opt in. A date, a timestamp, a decimal and a uuid each
16//! have a domain of their own, separate from the physical value they are
17//! stored as, so a date's day count never matches the integer it equals and
18//! a uuid never matches the same sixteen bytes written as bytes. Every other
19//! logical type, [`Logical::Unknown`] included, has no term and is refused
20//! with [`NormalizeError::NoDomain`].
21
22use unicode_general_category::{get_general_category, GeneralCategory};
23use unicode_normalization::UnicodeNormalization;
24use vitaminc_aead::plaintext::kinds::{timestamp_nanos, MAX_DATE_DAYS, MIN_DATE_DAYS};
25use vitaminc_aead::{Float, Int, Logical, Physical, TimeUnit};
26use vitaminc_protected::{Controlled, OpaqueDebug, Protected};
27
28/// Frozen domain labels for equality terms, one per kind that has one.
29pub mod domain {
30 /// Every integer width, `i8` through `u128`.
31 pub const INTEGER: &str = "vitaminc/prf/value/integer-orderable/v1";
32 /// `f32` and `f64`, as `f64`.
33 pub const FLOAT: &str = "vitaminc/prf/value/float-orderable/v1";
34 /// A date, over its day count.
35 pub const DATE: &str = "vitaminc/prf/value/date-orderable/v1";
36 /// A timestamp, truncated to microseconds.
37 pub const TIMESTAMP: &str = "vitaminc/prf/value/timestamp-micros-orderable/v1";
38 /// A decimal, with scale and signed zero normalized.
39 pub const DECIMAL: &str = "vitaminc/prf/value/decimal-orderable/v1";
40 /// Text, as Unicode 16 NFC.
41 pub const TEXT: &str = "vitaminc/prf/value/text-nfc/unicode-16/v1";
42 /// Bytes.
43 pub const BYTES: &str = "vitaminc/prf/value/bytes/v1";
44 /// A uuid, over its sixteen raw bytes.
45 pub const UUID: &str = "vitaminc/prf/value/uuid/v1";
46 /// Every domain, for a test or an index layer that enumerates them. A
47 /// slice, so a domain added later does not change its type.
48 pub const ALL: &[&str] = &[INTEGER, FLOAT, DATE, TIMESTAMP, DECIMAL, TEXT, BYTES, UUID];
49}
50
51/// The normalized bytes for one equality term and the domain they belong
52/// to.
53///
54/// The fields are public so an index layer can build a term of its own,
55/// and whatever consumes a `Normalized` trusts them: a PRF derives under
56/// the `domain` it is handed. The only way to get the frozen encodings is
57/// through [`normalize`] or the per-kind encoders here.
58///
59/// `Debug` shows the domain and masks the bytes.
60#[derive(OpaqueDebug)]
61pub struct Normalized {
62 /// The frozen domain label, one of the [`domain`] constants.
63 #[non_sensitive]
64 pub domain: &'static str,
65 /// The normalized plaintext, protected.
66 pub bytes: Protected<Vec<u8>>,
67}
68
69/// Why a scalar has no term. Never includes plaintext.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
71#[non_exhaustive]
72pub enum NormalizeError {
73 /// The scalar has no domain under the normalizer: a null, an
74 /// `undefined`, a boolean, or a logical type outside its opt-in table.
75 /// The message does not name an index, because the equality and order
76 /// normalizers both report it; whatever wraps it says which.
77 #[error("no domain for this kind of value")]
78 NoDomain,
79 /// The value is not valid for its logical type.
80 #[error("the value is not valid for its logical type")]
81 Invalid,
82 /// Text contains a code point unassigned in Unicode 16.
83 #[error("text contains a code point unassigned in Unicode 16")]
84 UnassignedCodePoint,
85 /// The value is of another kind than the type the index declares: text
86 /// for an integer column, say. Not reported by the equality
87 /// normalizer, which declares no type.
88 #[error("the value is not of the kind the index declares")]
89 Mismatch,
90 /// The value is of the kind the index declares but does not fit it: an
91 /// integer outside the declared width. Not reported by the equality
92 /// normalizer, which declares no type.
93 #[error("the value does not fit the type the index declares")]
94 OutOfRange,
95}
96
97/// The opt-in table: the domain for a scalar, or `None` for one that has
98/// no term. A logical type decides on its own; a value without one goes by
99/// its kind. Neither looks at the value: [`normalize`] checks that.
100pub fn domain_of(ty: Option<Logical>, v: &Physical<'_>) -> Option<&'static str> {
101 match (ty, v) {
102 (None, Physical::Int(_)) => Some(domain::INTEGER),
103 (None, Physical::Float(_)) => Some(domain::FLOAT),
104 (None, Physical::Text(_)) => Some(domain::TEXT),
105 (None, Physical::Bytes(_)) => Some(domain::BYTES),
106 (None, Physical::Null | Physical::Bool(_)) => None,
107 (Some(Logical::Date), _) => Some(domain::DATE),
108 (Some(Logical::Timestamp(_)), _) => Some(domain::TIMESTAMP),
109 (Some(Logical::Decimal { .. }), _) => Some(domain::DECIMAL),
110 (Some(Logical::Uuid), _) => Some(domain::UUID),
111 // `Logical` is non-exhaustive: a type this build does not know, and
112 // any added later, has no term until it is given a domain here.
113 (Some(_), _) => None,
114 }
115}
116
117/// Encode one scalar for equality, with the domain its term belongs to.
118///
119/// Integers of every width share [`domain::INTEGER`]: a sign byte (`0x00`
120/// negative, `0x01` otherwise) then the value as a big-endian 128-bit
121/// two's-complement word, which orders correctly across `i128::MIN` to
122/// `u128::MAX`. Both float widths share [`domain::FLOAT`]: an `f32` widens
123/// exactly to `f64`, so it matches an `f64` only when the numbers are equal
124/// (`f32` 1.5 matches, `f32` 0.1 does not match `f64` 0.1). Float zero
125/// signs and NaN payloads are folded. A timestamp in any unit is truncated
126/// to microseconds. A decimal's scale is normalized, so `1.50` and `1.5`
127/// are one term, as are `-0.00` and `0`.
128pub fn normalize(ty: Option<Logical>, v: Physical<'_>) -> Result<Normalized, NormalizeError> {
129 // A value that does not fit its logical type is refused as invalid
130 // before the type is looked up, so `undefined` over an int is `Invalid`
131 // rather than "nothing to index".
132 if ty.is_some_and(|ty| !ty.admits(&v)) {
133 return Err(NormalizeError::Invalid);
134 }
135 let domain = domain_of(ty, &v).ok_or(NormalizeError::NoDomain)?;
136 let bytes = match (ty, v) {
137 (None, Physical::Int(i)) => Some(integer(i)),
138 (None, Physical::Float(f)) => Some(float(f)),
139 (None, Physical::Text(s)) => Some(text(s)?),
140 (None, Physical::Bytes(b)) => Some(bytes(b)),
141 (Some(Logical::Date), Physical::Int(days)) => date(days),
142 (Some(Logical::Timestamp(unit)), Physical::Int(count)) => timestamp(unit, count),
143 (Some(Logical::Decimal { scale }), Physical::Int(mantissa)) => decimal(scale, mantissa),
144 (Some(Logical::Uuid), Physical::Bytes(b)) => uuid(b),
145 // `admits` has already refused a logical type over the wrong kind,
146 // and `domain_of` everything without a term. Each encoder checks
147 // its own range as well, for a caller that goes to it directly.
148 _ => None,
149 }
150 .ok_or(NormalizeError::Invalid)?;
151 Ok(Normalized { domain, bytes })
152}
153
154/// An integer of any width: a sign byte, then the big-endian 128-bit word.
155pub fn integer(value: Int) -> Protected<Vec<u8>> {
156 let (negative, word) = value.sign_and_word();
157 let word = Protected::new(word.to_be_bytes());
158 let mut bytes = Protected::new(Vec::with_capacity(17));
159 bytes.risky_inner_mut().push(u8::from(!negative));
160 bytes.risky_inner_mut().extend_from_slice(word.risky_ref());
161 bytes
162}
163
164/// A float of either width, as the orderable bytes of its `f64` value with
165/// zero signs and NaN payloads folded.
166pub fn float(value: Float) -> Protected<Vec<u8>> {
167 let value = match value {
168 Float::F32(f) => widen_f32(f),
169 Float::F64(f) => f,
170 };
171 Protected::new(orderable_f64(canonical_f64(value)).to_vec())
172}
173
174/// A date's day count since 1970-01-01, as the orderable bytes of its
175/// count of days from 0001-01-01 (the same bytes chrono's `NaiveDate`
176/// orders by). `None` outside the date range.
177pub fn date(days: Int) -> Option<Protected<Vec<u8>>> {
178 let days = i32::try_from(days.to_i128()?).ok()?;
179 if !(MIN_DATE_DAYS..=MAX_DATE_DAYS).contains(&days) {
180 return None;
181 }
182 let from_ce = days.checked_add(EPOCH_DAYS_FROM_CE)?;
183 Some(Protected::new(
184 ((from_ce as u32) ^ (1u32 << 31)).to_be_bytes().to_vec(),
185 ))
186}
187
188/// A count of `unit`s since the Unix epoch, as orderable seconds then
189/// big-endian nanoseconds truncated to microseconds (the same bytes
190/// chrono's `DateTime<Utc>` orders by, truncated). `None` outside the
191/// timestamp range.
192pub fn timestamp(unit: TimeUnit, count: Int) -> Option<Protected<Vec<u8>>> {
193 let nanos = timestamp_nanos(unit, count)?;
194 let seconds = i64::try_from(nanos.div_euclid(NANOS_PER_SECOND)).ok()?;
195 let sub = (nanos.rem_euclid(NANOS_PER_SECOND) as u32) / 1_000 * 1_000;
196 let mut bytes = Protected::new(Vec::with_capacity(12));
197 bytes
198 .risky_inner_mut()
199 .extend_from_slice(&((seconds as u64) ^ (1u64 << 63)).to_be_bytes());
200 bytes
201 .risky_inner_mut()
202 .extend_from_slice(&sub.to_be_bytes());
203 Some(bytes)
204}
205
206/// A decimal's mantissa and scale, as the 14-byte orderable encoding of
207/// its value, ported from the frozen `orderable-bytes` release and checked
208/// against it, which normalizes the scale and a signed zero. `None`
209/// if the mantissa does not fit 96 bits or the scale is above 28, the
210/// range `rust_decimal` represents.
211///
212/// # Encoding (scientific form, base 10)
213///
214/// Each non-zero value is re-expressed in signed scientific form:
215///
216/// ```text
217/// value = ±significand × 10^(leading_exp − digits(significand) + 1)
218/// ```
219///
220/// where `significand` is the mantissa with trailing zeros stripped and
221/// `leading_exp` is the decimal exponent of the leading significant digit,
222/// in `[-28, 28]`. The 14 bytes are:
223///
224/// ```text
225/// byte 0 : [sign:1][biased_exp:7]
226/// bytes 1..=13 : padded_significand (104 bits, big-endian)
227/// ```
228///
229/// - The sign bit is `1` for positive and zero, `0` for negative, so every
230/// negative sorts below zero and zero below every positive.
231/// - The biased exponent is `leading_exp + 64`, in `[36, 92]` for a
232/// non-zero value. Zero uses `0`, which no non-zero value reaches.
233/// - The padded significand is the significand left-justified to 29
234/// digits by appending zeros, so values at one exponent compare
235/// byte-wise however many digits they have. A 96-bit mantissa has at
236/// most 29 digits, so the padded value fits 104 bits.
237///
238/// For a negative, the exponent bits and the significand are inverted, so
239/// a larger magnitude sorts lower. `1`, `1.0` and `1.00` reduce to the
240/// same significand and exponent, and `+0` and `-0` to the same zero, so
241/// equal values are equal bytes, as `rust_decimal` compares them.
242pub fn decimal(scale: u8, mantissa: Int) -> Option<Protected<Vec<u8>>> {
243 let bytes = Protected::new(crate::decimal::orderable(mantissa.to_i128()?, scale)?);
244 Some(Protected::new(bytes.risky_ref().to_vec()))
245}
246
247/// NFC in the pinned Unicode version, without case or accent folding.
248///
249/// The result is written into one protected buffer sized exactly up front,
250/// so it never reallocates and frees an unwiped partial copy. The
251/// `unicode-normalization` iterator keeps its own small buffers of pending
252/// code points (inline for four, spilling to the heap for longer combining
253/// sequences); those are not wiped and are out of this crate's control.
254pub fn text(text: &str) -> Result<Protected<Vec<u8>>, NormalizeError> {
255 if text
256 .chars()
257 .any(|c| get_general_category(c) == GeneralCategory::Unassigned)
258 {
259 return Err(NormalizeError::UnassignedCodePoint);
260 }
261 // NFC can grow UTF-8 (U+0344 is two bytes, its NFC form four), so measure
262 // the output first rather than sizing from the input.
263 let len = text.nfc().map(char::len_utf8).sum();
264 let mut result = Protected::new(Vec::with_capacity(len));
265 for c in text.nfc() {
266 let mut encoded = Protected::new([0u8; 4]);
267 result
268 .risky_inner_mut()
269 .extend_from_slice(c.encode_utf8(encoded.risky_inner_mut()).as_bytes());
270 }
271 Ok(result)
272}
273
274/// Bytes, as they are.
275pub fn bytes(value: &[u8]) -> Protected<Vec<u8>> {
276 Protected::new(value.to_vec())
277}
278
279/// A uuid's sixteen raw bytes. `None` for any other length.
280pub fn uuid(value: &[u8]) -> Option<Protected<Vec<u8>>> {
281 (value.len() == 16).then(|| Protected::new(value.to_vec()))
282}
283
284/// Days from 0001-01-01 (day 1, chrono's "days from CE") to 1970-01-01.
285const EPOCH_DAYS_FROM_CE: i32 = 719_163;
286const NANOS_PER_SECOND: i128 = 1_000_000_000;
287
288/// The orderable bytes of an `f64`: negatives flip every bit, positives
289/// the sign bit, so the bytes sort as the numbers do. Expects a value
290/// [`canonical_f64`] has already folded.
291fn orderable_f64(value: f64) -> [u8; 8] {
292 let bits = value.to_bits();
293 let sign_extension = ((bits as i64) >> 63) as u64;
294 (bits ^ (sign_extension | (1u64 << 63))).to_be_bytes()
295}
296
297/// `f64::from(f32)` in integer operations only. A hardware conversion can
298/// take a slow path on subnormal inputs, which would leak timing; this keeps
299/// the float path free of floating-point arithmetic.
300fn widen_f32(value: f32) -> f64 {
301 let bits = value.to_bits();
302 let sign = u64::from(bits >> 31) << 63;
303 let exponent = (bits >> 23) & 0xff;
304 let mantissa = u64::from(bits & 0x7f_ffff);
305 let zero_exponent = 0u64.wrapping_sub((exponent == 0) as u64);
306 let max_exponent = 0u64.wrapping_sub((exponent == 0xff) as u64);
307 let nonzero = 0u64.wrapping_sub((mantissa != 0) as u64);
308 // Normal, infinite and NaN: rebias the exponent (127 to 1023, or all
309 // ones stays all ones) and left-align the mantissa.
310 let rebiased = ((u64::from(exponent) + 896) & !max_exponent) | (0x7ff & max_exponent);
311 let normal = (rebiased << 52) | (mantissa << 29);
312 // Subnormal: the highest set bit becomes the implicit leading one.
313 let top = highest_set_bit(mantissa);
314 let subnormal = ((top + 874) << 52) | ((mantissa << (52 - top)) & ((1 << 52) - 1));
315 f64::from_bits(sign | (normal & !zero_exponent) | (subnormal & zero_exponent & nonzero))
316}
317
318/// Index of the highest set bit of a subnormal f32 mantissa. `| 1` keeps the
319/// result in range for a zero mantissa, which `widen_f32` masks out.
320///
321/// Kept out of `widen_f32` so the `.cargo/mutants.toml` exclusion of that
322/// function's equivalent `|`-to-`^` mutants cannot reach this `|`, whose
323/// operands overlap and whose mutant a test does catch.
324fn highest_set_bit(mantissa: u64) -> u64 {
325 u64::from(63 - (mantissa | 1).leading_zeros())
326}
327
328/// Folds every NaN to one quiet NaN and `-0.0` to `0.0`, in integer
329/// operations only.
330fn canonical_f64(value: f64) -> f64 {
331 let bits = value.to_bits();
332 let magnitude = bits & 0x7fff_ffff_ffff_ffff;
333 let nan = 0u64.wrapping_sub((magnitude > 0x7ff0_0000_0000_0000) as u64);
334 let nonzero = 0u64.wrapping_sub((magnitude != 0) as u64);
335 f64::from_bits((bits & !nan).wrapping_add(0x7ff8_0000_0000_0000 & nan) & nonzero)
336}
337
338#[cfg(test)]
339#[path = "scalar_tests.rs"]
340mod tests;