From 8ff3f42862e20f3634463b05242b951b1754956e Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Tue, 12 May 2026 14:14:16 -0700 Subject: [PATCH 1/8] feat(payload): impl PartialEq/Eq/PartialOrd/Ord/Hash for Probability Hand-write the comparison and hashing traits, sound under `try_new`'s invariants (no NaN, no `-0.0`). `PartialEq`/`PartialOrd`/`Ord` use `f32` native operators (`==`, `total_cmp`); `Hash` uses the `u32` bit pattern because `f32: !Hash`. The `Eq`/`Hash` contract holds because numerically equal valid values share a bit pattern. Add unit and property tests covering equality, ordering, hashing in a `HashSet`, and agreement between `Ord::cmp` and `f32::partial_cmp` over the full valid range. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 83 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index 3c8a9aa27..6663c2a60 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -2,7 +2,7 @@ use rand::{RngExt, distr::uniform::SampleUniform}; use serde::Deserialize; -use std::{cmp, fmt}; +use std::{cmp, fmt, hash}; /// Range expression for configuration #[derive(Debug, Deserialize, serde::Serialize, Clone, PartialEq, Copy)] @@ -182,6 +182,38 @@ impl fmt::Display for Probability { } } +// `Eq`, `Ord`, and `Hash` are sound because [`Self::try_new`] rejects NaN and +// normalizes `-0.0` to `+0.0`, so every value in the valid range has a unique +// bit pattern and an unambiguous numeric ordering. `Hash` works on the `u32` +// bit pattern because `f32: !Hash`; the `Eq`/`Hash` contract is preserved +// because numerically equal valid values share a bit pattern. + +impl PartialEq for Probability { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl Eq for Probability {} + +impl Ord for Probability { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.value.total_cmp(&other.value) + } +} + +impl PartialOrd for Probability { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl hash::Hash for Probability { + fn hash(&self, state: &mut H) { + self.value.to_bits().hash(state); + } +} + impl Probability { /// The lower bound decoded from `MIN_AS_BITS`. /// @@ -308,6 +340,38 @@ mod probability_tests { } } + // ===== Unit tests: ordering / equality / hashing ===== + + #[test] + fn equality_holds_for_same_bit_pattern() { + let a = AtLeastHalf::try_new(0.75).expect("valid"); + let b = AtLeastHalf::try_new(0.75).expect("valid"); + let c = AtLeastHalf::try_new(0.875).expect("valid"); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn ordering_matches_numeric_ordering() { + let half = AtLeastHalf::try_new(0.5).expect("valid"); + let three_quarters = AtLeastHalf::try_new(0.75).expect("valid"); + let one = AtLeastHalf::try_new(1.0).expect("valid"); + assert!(half < three_quarters); + assert!(three_quarters < one); + assert!(half < one); + } + + #[test] + fn hash_agrees_with_eq() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(AtLeastHalf::try_new(0.75).expect("valid")); + // Re-inserting the equivalent value must hit the existing entry. + assert!(!set.insert(AtLeastHalf::try_new(0.75).expect("valid"))); + assert!(set.insert(AtLeastHalf::try_new(0.875).expect("valid"))); + assert_eq!(set.len(), 2); + } + // ===== Unit tests: wire-format pins ===== #[test] @@ -474,6 +538,23 @@ mod probability_tests { } } + // ===== Property tests: ordering agrees with f32 PartialOrd ===== + + proptest! { + #[test] + fn ord_matches_f32_partial_cmp( + a in valid_value_strategy(ZeroOrMore::MIN), + b in valid_value_strategy(ZeroOrMore::MIN), + ) { + let pa = ZeroOrMore::try_new(a).expect("valid"); + let pb = ZeroOrMore::try_new(b).expect("valid"); + prop_assert_eq!( + pa.cmp(&pb), + a.partial_cmp(&b).expect("no NaN in valid range") + ); + } + } + // ===== Property tests: Display ===== proptest! { From 26e92a9ca80b973be5f38643a565a5043f348c3c Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Tue, 12 May 2026 15:01:42 -0700 Subject: [PATCH 2/8] feat(payload): add Probability aliases Rename the generic struct to `BoundedProbability` and expose two type aliases for the bounds that occur in lading payload config: `Probability` for $[0.0, 1.0]$ and `AtLeastOneTenth` for $[0.1, 1.0]$. Callers adopting the type no longer have to spell `f32::to_bits(...)` at each use site, which is the main blocker for wiring the type into existing `f32` fields (`dogstatsd::Config`, `opentelemetry::trace::Config`, etc.). ProbabilityError keeps its name since Probability is now the alias users typically construct. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 86 ++++++++++++++++++----------- 1 file changed, 55 insertions(+), 31 deletions(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index 6663c2a60..bc04fbc23 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -89,7 +89,7 @@ where /// equal to `+0.0` under IEEE-754 numeric ordering. const NEG_ZERO_AS_BITS: u32 = 0x8000_0000; -/// Error returned when a value cannot be turned into a [`Probability`]. +/// Error returned when a value cannot be turned into a [`BoundedProbability`]. #[derive(Debug, thiserror::Error, Clone, Copy)] pub enum ProbabilityError { /// Value is [`f32::NAN`], [`f32::INFINITY`], or [`f32::NEG_INFINITY`]. @@ -147,22 +147,38 @@ pub enum ProbabilityError { /// before storage. This canonical-bit-pattern guarantee is what makes hashing /// on `value.to_bits()` consistent with numeric equality. /// +/// Two type aliases are provided for the bounds that actually occur in lading +/// payload configuration today; callers should prefer them over spelling the +/// bit pattern at the use site. Define additional aliases as new bounds appear. +/// /// # Example /// /// ``` -/// use lading_payload::common::config::Probability; +/// use lading_payload::common::config::{BoundedProbability, Probability}; /// -/// type AtLeastHalf = Probability<{ f32::to_bits(0.5) }>; -/// let p = AtLeastHalf::try_new(0.75).expect("0.75 is in [0.5, 1.0]"); +/// // For the common `[0.0, 1.0]` case, use the `Probability` alias. +/// let p = Probability::try_new(0.75).expect("0.75 is in [0.0, 1.0]"); /// assert_eq!(p.get(), 0.75); +/// +/// // For other lower bounds, parameterize `BoundedProbability` directly. +/// type AtLeastHalf = BoundedProbability<{ f32::to_bits(0.5) }>; +/// let q = AtLeastHalf::try_new(0.75).expect("0.75 is in [0.5, 1.0]"); +/// assert_eq!(q.get(), 0.75); /// ``` #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] #[serde(into = "f32", try_from = "f32")] -pub struct Probability { +pub struct BoundedProbability { value: f32, } -impl TryFrom for Probability { +/// A probability in the closed unit interval `[0.0, 1.0]`. The most common bound. +pub type Probability = BoundedProbability<{ f32::to_bits(0.0) }>; + +/// A probability or ratio in `[0.1, 1.0]`. Used for fields such as +/// `unique_tag_ratio` that must avoid extreme low values. +pub type AtLeastOneTenth = BoundedProbability<{ f32::to_bits(0.1) }>; + +impl TryFrom for BoundedProbability { type Error = ProbabilityError; fn try_from(value: f32) -> Result { @@ -170,13 +186,13 @@ impl TryFrom for Probability { } } -impl From> for f32 { - fn from(p: Probability) -> Self { +impl From> for f32 { + fn from(p: BoundedProbability) -> Self { p.value } } -impl fmt::Display for Probability { +impl fmt::Display for BoundedProbability { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Display::fmt(&self.value, f) } @@ -188,33 +204,33 @@ impl fmt::Display for Probability { // bit pattern because `f32: !Hash`; the `Eq`/`Hash` contract is preserved // because numerically equal valid values share a bit pattern. -impl PartialEq for Probability { +impl PartialEq for BoundedProbability { fn eq(&self, other: &Self) -> bool { self.value == other.value } } -impl Eq for Probability {} +impl Eq for BoundedProbability {} -impl Ord for Probability { +impl Ord for BoundedProbability { fn cmp(&self, other: &Self) -> cmp::Ordering { self.value.total_cmp(&other.value) } } -impl PartialOrd for Probability { +impl PartialOrd for BoundedProbability { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl hash::Hash for Probability { +impl hash::Hash for BoundedProbability { fn hash(&self, state: &mut H) { self.value.to_bits().hash(state); } } -impl Probability { +impl BoundedProbability { /// The lower bound decoded from `MIN_AS_BITS`. /// /// The `assert!`s here run at const-evaluation time for every @@ -240,7 +256,7 @@ impl Probability { /// `[MIN, +1.0]` and is not [`f32::NAN`], [`f32::INFINITY`], or /// [`f32::NEG_INFINITY`]. A `-0.0` input is normalized to `+0.0`. /// - /// This is a `const fn`, so callers can build a [`Probability`] in a + /// This is a `const fn`, so callers can build a [`BoundedProbability`] in a /// `const` context by matching on the returned [`Result`]; the validation /// then runs at compile time. /// @@ -279,12 +295,12 @@ impl Probability { #[cfg(test)] mod probability_tests { - use super::{NEG_ZERO_AS_BITS, Probability, ProbabilityError}; + use super::{BoundedProbability, NEG_ZERO_AS_BITS, ProbabilityError}; use proptest::prelude::*; - type ZeroOrMore = Probability<{ f32::to_bits(0.0) }>; - type AtLeastHalf = Probability<{ f32::to_bits(0.5) }>; - type AtLeastOne = Probability<{ f32::to_bits(1.0) }>; + type ZeroOrMore = BoundedProbability<{ f32::to_bits(0.0) }>; + type AtLeastHalf = BoundedProbability<{ f32::to_bits(0.5) }>; + type AtLeastOne = BoundedProbability<{ f32::to_bits(1.0) }>; // ===== Unit tests: constants ===== @@ -423,15 +439,19 @@ mod probability_tests { // ===== Property-test helpers (generic over MIN_AS_BITS) ===== fn check_accepts_in_range(v: f32) { - let p = Probability::::try_new(v).expect("v should be valid by construction"); + let p = BoundedProbability::::try_new(v) + .expect("v should be valid by construction"); assert_eq!(p.get().to_bits(), v.to_bits()); } fn check_rejects_below_min(v: f32) { - let err = Probability::::try_new(v).expect_err("v should be below MIN"); + let err = BoundedProbability::::try_new(v).expect_err("v should be below MIN"); match err { ProbabilityError::BelowMin { min, value } => { - assert_eq!(min.to_bits(), Probability::::MIN.to_bits()); + assert_eq!( + min.to_bits(), + BoundedProbability::::MIN.to_bits() + ); assert_eq!(value.to_bits(), v.to_bits()); } other => panic!("expected BelowMin, got {other:?}"), @@ -439,32 +459,35 @@ mod probability_tests { } fn check_display_matches(v: f32) { - let p = Probability::::try_new(v).expect("valid v"); + let p = BoundedProbability::::try_new(v).expect("valid v"); assert_eq!(format!("{p}"), format!("{v}")); } fn check_display_precision(v: f32, n: usize) { - let p = Probability::::try_new(v).expect("valid v"); + let p = BoundedProbability::::try_new(v).expect("valid v"); assert_eq!(format!("{p:.n$}"), format!("{v:.n$}")); } fn check_serde_json_round_trip(v: f32) { - let p = Probability::::try_new(v).expect("valid v"); + let p = BoundedProbability::::try_new(v).expect("valid v"); let json = serde_json::to_string(&p).expect("serialize"); - let back: Probability = serde_json::from_str(&json).expect("deserialize"); + let back: BoundedProbability = + serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.get().to_bits(), v.to_bits()); } fn check_serde_yaml_round_trip(v: f32) { - let p = Probability::::try_new(v).expect("valid v"); + let p = BoundedProbability::::try_new(v).expect("valid v"); let yaml = serde_yaml::to_string(&p).expect("serialize"); - let back: Probability = serde_yaml::from_str(&yaml).expect("deserialize"); + let back: BoundedProbability = + serde_yaml::from_str(&yaml).expect("deserialize"); assert_eq!(back.get().to_bits(), v.to_bits()); } fn check_serde_json_rejects_below_min(v: f32) { let json = serde_json::to_string(&v).expect("serialize raw f32"); - let err = serde_json::from_str::>(&json).expect_err("v < MIN"); + let err = + serde_json::from_str::>(&json).expect_err("v < MIN"); assert!( err.to_string().contains("below lower bound"), "unexpected error: {err}" @@ -473,7 +496,8 @@ mod probability_tests { fn check_serde_yaml_rejects_below_min(v: f32) { let yaml = serde_yaml::to_string(&v).expect("serialize raw f32"); - let err = serde_yaml::from_str::>(&yaml).expect_err("v < MIN"); + let err = + serde_yaml::from_str::>(&yaml).expect_err("v < MIN"); assert!( err.to_string().contains("below lower bound"), "unexpected error: {err}" From 9819cb0c1dfd13fdfc531ef960291226e1940c52 Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Tue, 12 May 2026 15:09:42 -0700 Subject: [PATCH 3/8] feat(payload): impl arbitrary::Arbitrary for BoundedProbability Add an arbitrary::Arbitrary impl gated on the existing `arbitrary` feature so types containing a Probability field can continue to derive Arbitrary once the type is wired into Config structs. A derived impl would call f32::arbitrary and emit NaN, infinity, and out-of-range values, so a manual impl is required. The impl samples a u32 in [MIN_AS_BITS, f32::to_bits(+1.0)] and decodes it with f32::from_bits. The f32 <-> u32 bit-ordering monotonicity that is already documented on the type guarantees the decoded value lies in [MIN, +1.0] and cannot be -0.0 (whose bit pattern 0x8000_0000 is well above 0x3f80_0000). Routing the result through try_new fires the per-monomorphization const-eval bound check and forwards any future invariant added to the constructor; the expect is sound by the argument above. Add three property tests, one per existing bound, gated on the same feature. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index bc04fbc23..78e83b224 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -293,6 +293,26 @@ impl BoundedProbability { } } +/// Generate a uniformly-distributed-over-bit-patterns value in `[MIN, +1.0]` +/// by sampling a `u32` in `[MIN_AS_BITS, f32::to_bits(+1.0)]` and decoding it. +/// +/// This works because the f32 ↔ u32 ordering (documented on the type) is +/// monotonic for non-negative finite values, so every bit pattern in that +/// range decodes to a valid stored value. `-0.0`'s bit pattern is +/// `0x8000_0000`, far above `f32::to_bits(+1.0) = 0x3f80_0000`, so it can +/// never be generated. +#[cfg(feature = "arbitrary")] +impl<'a, const MIN_AS_BITS: u32> arbitrary::Arbitrary<'a> for BoundedProbability { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let bits = u.int_in_range(MIN_AS_BITS..=f32::to_bits(Self::MAX))?; + let value = f32::from_bits(bits); + // Routing through `try_new` fires the per-monomorphization const-eval + // bound check on `Self::MIN` and forwards any future invariant added + // to the constructor. The `expect` is safe by the argument above. + Ok(Self::try_new(value).expect("bits in [MIN_AS_BITS, MAX_AS_BITS] always valid")) + } +} + #[cfg(test)] mod probability_tests { use super::{BoundedProbability, NEG_ZERO_AS_BITS, ProbabilityError}; @@ -739,4 +759,45 @@ mod probability_tests { ); } } + + // ===== Property tests: Arbitrary impl (feature = "arbitrary") ===== + + #[cfg(feature = "arbitrary")] + fn check_arbitrary_produces_valid(bytes: &[u8]) { + use arbitrary::{Arbitrary, Unstructured}; + let mut u = Unstructured::new(bytes); + // `int_in_range` can fail with `NotEnoughData` on short inputs; that's + // fine — we only need to check that any `Ok` value is valid. + if let Ok(p) = BoundedProbability::::arbitrary(&mut u) { + let v = p.get(); + assert!(v.is_finite()); + assert_ne!(v.to_bits(), NEG_ZERO_AS_BITS); + assert!(v >= BoundedProbability::::MIN); + assert!(v <= BoundedProbability::::MAX); + } + } + + #[cfg(feature = "arbitrary")] + proptest! { + #[test] + fn arbitrary_produces_valid_zero_or_more( + bytes in prop::collection::vec(any::(), 4..32), + ) { + check_arbitrary_produces_valid::<{ f32::to_bits(0.0) }>(&bytes); + } + + #[test] + fn arbitrary_produces_valid_at_least_half( + bytes in prop::collection::vec(any::(), 4..32), + ) { + check_arbitrary_produces_valid::<{ f32::to_bits(0.5) }>(&bytes); + } + + #[test] + fn arbitrary_produces_valid_at_least_one( + bytes in prop::collection::vec(any::(), 4..32), + ) { + check_arbitrary_produces_valid::<{ f32::to_bits(1.0) }>(&bytes); + } + } } From 7ee8c43ab437240d9d1df31eaaad8c061dabdd4e Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Tue, 12 May 2026 17:08:45 -0700 Subject: [PATCH 4/8] feat(payload): add BoundedProbability::sample_bernoulli helper Probabilities in lading payload code exist to drive Bernoulli trials ("flip a coin biased by p"). Without a helper, every adopting call site would extract the inner f32 via get() and pass it to rng.random_bool(p as f64) by hand, which both leaks the f32 representation and recomputes the same f32 -> f64 conversion at each use. Add sample_bernoulli(&self, &mut rng) -> bool that delegates to rng.random_bool. The f32 -> f64 conversion is exact for every f32 in [+0.0, +1.0], so the success probability is preserved bit-for-bit, and try_new's range invariant means random_bool never panics on out-of-range input. Add two property tests over u64 seeds, one for p = 0.0 and one for p = 1.0. Together they pin the wiring (P(true) is not inverted) and exercise the boundary values that random_bool is most likely to special- case. A statistical test on intermediate p would only re-test rand::Rng::random_bool itself, so it is omitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index 78e83b224..b17c3b3c3 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -291,6 +291,19 @@ impl BoundedProbability { pub const fn get(&self) -> f32 { self.value } + + /// Sample a Bernoulli trial with success probability `self.get()`. + /// + /// Returns `true` with probability `self.get()` and `false` otherwise. + /// The f32 ↔ f64 conversion is exact for every f32 in `[+0.0, +1.0]`, so + /// the success probability is preserved bit-for-bit. + #[must_use] + pub fn sample_bernoulli(&self, rng: &mut R) -> bool + where + R: rand::Rng + ?Sized, + { + rng.random_bool(f64::from(self.value)) + } } /// Generate a uniformly-distributed-over-bit-patterns value in `[MIN, +1.0]` @@ -800,4 +813,33 @@ mod probability_tests { check_arbitrary_produces_valid::<{ f32::to_bits(1.0) }>(&bytes); } } + + // ===== Property tests: sample_bernoulli ===== + // + // The degenerate `p = 0.0` and `p = 1.0` cases together pin both the + // wiring (no inversion of P(true) vs P(false)) and the absence of a + // panic from `random_bool`. A statistical test on intermediate values + // would only re-test `rand::Rng::random_bool`, so it is omitted. + + proptest! { + #[test] + fn bernoulli_at_zero_never_succeeds(seed: u64) { + use rand::{SeedableRng, rngs::SmallRng}; + let p = ZeroOrMore::try_new(0.0).expect("0.0 in [0, 1]"); + let mut rng = SmallRng::seed_from_u64(seed); + for _ in 0..1024 { + prop_assert!(!p.sample_bernoulli(&mut rng)); + } + } + + #[test] + fn bernoulli_at_one_always_succeeds(seed: u64) { + use rand::{SeedableRng, rngs::SmallRng}; + let p = AtLeastOne::try_new(1.0).expect("1.0 in [1, 1]"); + let mut rng = SmallRng::seed_from_u64(seed); + for _ in 0..1024 { + prop_assert!(p.sample_bernoulli(&mut rng)); + } + } + } } From 21da2305c2cc483a89d9dac3bcce5b68ea5767a3 Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Wed, 20 May 2026 14:45:15 -0700 Subject: [PATCH 5/8] feat(payload): add AtLeastOneHundredth alias Introduces `AtLeastOneHundredth = BoundedProbability<{ f32::to_bits(0.01) }>` for fields such as `unique_tag_ratio` whose existing 0.01 floor must admit in-the-wild values below 0.1. Also updates the `AtLeastOneTenth` rustdoc to drop the `unique_tag_ratio` reference, since that field will be wired to `AtLeastOneHundredth` rather than tightened to a 0.1 floor. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index b17c3b3c3..e6563ced0 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -174,10 +174,15 @@ pub struct BoundedProbability { /// A probability in the closed unit interval `[0.0, 1.0]`. The most common bound. pub type Probability = BoundedProbability<{ f32::to_bits(0.0) }>; -/// A probability or ratio in `[0.1, 1.0]`. Used for fields such as -/// `unique_tag_ratio` that must avoid extreme low values. +/// A probability or ratio in `[0.1, 1.0]`. Use for fields that must avoid +/// extreme low values. pub type AtLeastOneTenth = BoundedProbability<{ f32::to_bits(0.1) }>; +/// A probability or ratio in `[0.01, 1.0]`. Use for fields such as +/// `unique_tag_ratio` that must avoid extreme low values but admit +/// in-the-wild values below `0.1`. +pub type AtLeastOneHundredth = BoundedProbability<{ f32::to_bits(0.01) }>; + impl TryFrom for BoundedProbability { type Error = ProbabilityError; From 2690207e86684e370d34cd83a73b3f819ef82ba3 Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Wed, 20 May 2026 22:42:49 -0700 Subject: [PATCH 6/8] docs(payload): replace non-ASCII characters in BoundedProbability docs AGENTS.md requires US-ASCII only in code and documentation. Replace two left-right arrows with `<->` and an em dash with `--`. --- lading_payload/src/common/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index e6563ced0..d23012a60 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -300,7 +300,7 @@ impl BoundedProbability { /// Sample a Bernoulli trial with success probability `self.get()`. /// /// Returns `true` with probability `self.get()` and `false` otherwise. - /// The f32 ↔ f64 conversion is exact for every f32 in `[+0.0, +1.0]`, so + /// The f32 <-> f64 conversion is exact for every f32 in `[+0.0, +1.0]`, so /// the success probability is preserved bit-for-bit. #[must_use] pub fn sample_bernoulli(&self, rng: &mut R) -> bool @@ -314,7 +314,7 @@ impl BoundedProbability { /// Generate a uniformly-distributed-over-bit-patterns value in `[MIN, +1.0]` /// by sampling a `u32` in `[MIN_AS_BITS, f32::to_bits(+1.0)]` and decoding it. /// -/// This works because the f32 ↔ u32 ordering (documented on the type) is +/// This works because the f32 <-> u32 ordering (documented on the type) is /// monotonic for non-negative finite values, so every bit pattern in that /// range decodes to a valid stored value. `-0.0`'s bit pattern is /// `0x8000_0000`, far above `f32::to_bits(+1.0) = 0x3f80_0000`, so it can @@ -785,7 +785,7 @@ mod probability_tests { use arbitrary::{Arbitrary, Unstructured}; let mut u = Unstructured::new(bytes); // `int_in_range` can fail with `NotEnoughData` on short inputs; that's - // fine — we only need to check that any `Ok` value is valid. + // fine -- we only need to check that any `Ok` value is valid. if let Ok(p) = BoundedProbability::::arbitrary(&mut u) { let v = p.get(); assert!(v.is_finite()); From bce8ac61e83184c56775f64a191cccff92a3bbeb Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Wed, 20 May 2026 23:31:09 -0700 Subject: [PATCH 7/8] refactor(payload): remove unused BoundedProbability surface Drop `impl Display`, `impl Eq`, `impl Ord`, `impl PartialOrd`, `impl Hash`, `sample_bernoulli`, and the `AtLeastOneTenth` alias from `BoundedProbability`. None of these are reached by production code at the tip of the adoption stack: callers only need `PartialEq` (transitively via derives on `dogstatsd::Config`, `ValueConf`, `TimestampConfig`), `Arbitrary` (transitively via fuzz target derives), `TryFrom`/`From` (required by serde), `try_new`/`get`, and the `Probability` and `AtLeastOneHundredth` aliases. Drop the now-dead tests covering the removed items. `cargo test -p lading-payload` passes 239 tests; clippy and the `arbitrary` feature build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 178 +--------------------------- 1 file changed, 2 insertions(+), 176 deletions(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index d23012a60..9e5607f0b 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -2,7 +2,7 @@ use rand::{RngExt, distr::uniform::SampleUniform}; use serde::Deserialize; -use std::{cmp, fmt, hash}; +use std::{cmp, fmt}; /// Range expression for configuration #[derive(Debug, Deserialize, serde::Serialize, Clone, PartialEq, Copy)] @@ -174,10 +174,6 @@ pub struct BoundedProbability { /// A probability in the closed unit interval `[0.0, 1.0]`. The most common bound. pub type Probability = BoundedProbability<{ f32::to_bits(0.0) }>; -/// A probability or ratio in `[0.1, 1.0]`. Use for fields that must avoid -/// extreme low values. -pub type AtLeastOneTenth = BoundedProbability<{ f32::to_bits(0.1) }>; - /// A probability or ratio in `[0.01, 1.0]`. Use for fields such as /// `unique_tag_ratio` that must avoid extreme low values but admit /// in-the-wild values below `0.1`. @@ -197,44 +193,12 @@ impl From> for f32 { } } -impl fmt::Display for BoundedProbability { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(&self.value, f) - } -} - -// `Eq`, `Ord`, and `Hash` are sound because [`Self::try_new`] rejects NaN and -// normalizes `-0.0` to `+0.0`, so every value in the valid range has a unique -// bit pattern and an unambiguous numeric ordering. `Hash` works on the `u32` -// bit pattern because `f32: !Hash`; the `Eq`/`Hash` contract is preserved -// because numerically equal valid values share a bit pattern. - impl PartialEq for BoundedProbability { fn eq(&self, other: &Self) -> bool { self.value == other.value } } -impl Eq for BoundedProbability {} - -impl Ord for BoundedProbability { - fn cmp(&self, other: &Self) -> cmp::Ordering { - self.value.total_cmp(&other.value) - } -} - -impl PartialOrd for BoundedProbability { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl hash::Hash for BoundedProbability { - fn hash(&self, state: &mut H) { - self.value.to_bits().hash(state); - } -} - impl BoundedProbability { /// The lower bound decoded from `MIN_AS_BITS`. /// @@ -296,19 +260,6 @@ impl BoundedProbability { pub const fn get(&self) -> f32 { self.value } - - /// Sample a Bernoulli trial with success probability `self.get()`. - /// - /// Returns `true` with probability `self.get()` and `false` otherwise. - /// The f32 <-> f64 conversion is exact for every f32 in `[+0.0, +1.0]`, so - /// the success probability is preserved bit-for-bit. - #[must_use] - pub fn sample_bernoulli(&self, rng: &mut R) -> bool - where - R: rand::Rng + ?Sized, - { - rng.random_bool(f64::from(self.value)) - } } /// Generate a uniformly-distributed-over-bit-patterns value in `[MIN, +1.0]` @@ -394,7 +345,7 @@ mod probability_tests { } } - // ===== Unit tests: ordering / equality / hashing ===== + // ===== Unit tests: equality ===== #[test] fn equality_holds_for_same_bit_pattern() { @@ -405,27 +356,6 @@ mod probability_tests { assert_ne!(a, c); } - #[test] - fn ordering_matches_numeric_ordering() { - let half = AtLeastHalf::try_new(0.5).expect("valid"); - let three_quarters = AtLeastHalf::try_new(0.75).expect("valid"); - let one = AtLeastHalf::try_new(1.0).expect("valid"); - assert!(half < three_quarters); - assert!(three_quarters < one); - assert!(half < one); - } - - #[test] - fn hash_agrees_with_eq() { - use std::collections::HashSet; - let mut set = HashSet::new(); - set.insert(AtLeastHalf::try_new(0.75).expect("valid")); - // Re-inserting the equivalent value must hit the existing entry. - assert!(!set.insert(AtLeastHalf::try_new(0.75).expect("valid"))); - assert!(set.insert(AtLeastHalf::try_new(0.875).expect("valid"))); - assert_eq!(set.len(), 2); - } - // ===== Unit tests: wire-format pins ===== #[test] @@ -496,16 +426,6 @@ mod probability_tests { } } - fn check_display_matches(v: f32) { - let p = BoundedProbability::::try_new(v).expect("valid v"); - assert_eq!(format!("{p}"), format!("{v}")); - } - - fn check_display_precision(v: f32, n: usize) { - let p = BoundedProbability::::try_new(v).expect("valid v"); - assert_eq!(format!("{p:.n$}"), format!("{v:.n$}")); - } - fn check_serde_json_round_trip(v: f32) { let p = BoundedProbability::::try_new(v).expect("valid v"); let json = serde_json::to_string(&p).expect("serialize"); @@ -600,72 +520,6 @@ mod probability_tests { } } - // ===== Property tests: ordering agrees with f32 PartialOrd ===== - - proptest! { - #[test] - fn ord_matches_f32_partial_cmp( - a in valid_value_strategy(ZeroOrMore::MIN), - b in valid_value_strategy(ZeroOrMore::MIN), - ) { - let pa = ZeroOrMore::try_new(a).expect("valid"); - let pb = ZeroOrMore::try_new(b).expect("valid"); - prop_assert_eq!( - pa.cmp(&pb), - a.partial_cmp(&b).expect("no NaN in valid range") - ); - } - } - - // ===== Property tests: Display ===== - - proptest! { - #[test] - fn display_matches_inner_f32_for_valid_values_zero_or_more( - v in valid_value_strategy(ZeroOrMore::MIN), - ) { - check_display_matches::<{ f32::to_bits(0.0) }>(v); - } - - #[test] - fn display_matches_inner_f32_for_valid_values_at_least_half( - v in valid_value_strategy(AtLeastHalf::MIN), - ) { - check_display_matches::<{ f32::to_bits(0.5) }>(v); - } - - #[test] - fn display_matches_inner_f32_for_valid_values_at_least_one( - v in valid_value_strategy(AtLeastOne::MIN), - ) { - check_display_matches::<{ f32::to_bits(1.0) }>(v); - } - - #[test] - fn display_propagates_precision_zero_or_more( - v in valid_value_strategy(ZeroOrMore::MIN), - n in 0_usize..=10, - ) { - check_display_precision::<{ f32::to_bits(0.0) }>(v, n); - } - - #[test] - fn display_propagates_precision_at_least_half( - v in valid_value_strategy(AtLeastHalf::MIN), - n in 0_usize..=10, - ) { - check_display_precision::<{ f32::to_bits(0.5) }>(v, n); - } - - #[test] - fn display_propagates_precision_at_least_one( - v in valid_value_strategy(AtLeastOne::MIN), - n in 0_usize..=10, - ) { - check_display_precision::<{ f32::to_bits(1.0) }>(v, n); - } - } - // ===== Property tests: serde round-trips ===== proptest! { @@ -819,32 +673,4 @@ mod probability_tests { } } - // ===== Property tests: sample_bernoulli ===== - // - // The degenerate `p = 0.0` and `p = 1.0` cases together pin both the - // wiring (no inversion of P(true) vs P(false)) and the absence of a - // panic from `random_bool`. A statistical test on intermediate values - // would only re-test `rand::Rng::random_bool`, so it is omitted. - - proptest! { - #[test] - fn bernoulli_at_zero_never_succeeds(seed: u64) { - use rand::{SeedableRng, rngs::SmallRng}; - let p = ZeroOrMore::try_new(0.0).expect("0.0 in [0, 1]"); - let mut rng = SmallRng::seed_from_u64(seed); - for _ in 0..1024 { - prop_assert!(!p.sample_bernoulli(&mut rng)); - } - } - - #[test] - fn bernoulli_at_one_always_succeeds(seed: u64) { - use rand::{SeedableRng, rngs::SmallRng}; - let p = AtLeastOne::try_new(1.0).expect("1.0 in [1, 1]"); - let mut rng = SmallRng::seed_from_u64(seed); - for _ in 0..1024 { - prop_assert!(p.sample_bernoulli(&mut rng)); - } - } - } } From 52539fb3c46e0d0e81a96bf574a25c8d0c92af4e Mon Sep 17 00:00:00 2001 From: "Geoffrey M. Oxberry" Date: Wed, 20 May 2026 23:47:29 -0700 Subject: [PATCH 8/8] style(payload): remove trailing blank line in probability_tests `cargo fmt --check` flagged a stray blank line before the closing brace of `mod probability_tests` introduced by the prior cleanup commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- lading_payload/src/common/config.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/lading_payload/src/common/config.rs b/lading_payload/src/common/config.rs index 9e5607f0b..b3ddac7da 100644 --- a/lading_payload/src/common/config.rs +++ b/lading_payload/src/common/config.rs @@ -672,5 +672,4 @@ mod probability_tests { check_arbitrary_produces_valid::<{ f32::to_bits(1.0) }>(&bytes); } } - }