rovenue/placements/
bucketing.rs

1use sha2::{Digest, Sha256};
2
3pub const BUCKET_COUNT: u32 = 10_000;
4
5/// Deterministic bucket assignment: `sha256("{subscriber_id}:{seed}")`, first
6/// 4 bytes read big-endian, mod `BUCKET_COUNT`. Must match the TS
7/// implementation byte-for-byte (same hash input, same byte order, same
8/// modulo) — this is the cross-language bucketing contract, verified against
9/// `packages/shared/src/experiments/bucketing-vectors.json` below.
10pub fn assign_bucket(subscriber_id: &str, seed: &str) -> u32 {
11    let digest = Sha256::digest(format!("{subscriber_id}:{seed}").as_bytes());
12    let hash = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
13    hash % BUCKET_COUNT
14}
15
16/// Weights are fractions summing to 1. Boundary must replicate JS
17/// `Math.round` (half-away-from-zero for positives = f64::round here).
18pub fn select_variant_index(bucket: u32, weights: &[f64]) -> usize {
19    let mut cumulative = 0.0f64;
20    for (i, w) in weights.iter().enumerate() {
21        cumulative += w * BUCKET_COUNT as f64;
22        if (bucket as f64) < cumulative.round() {
23            return i;
24        }
25    }
26    weights.len() - 1
27}
28
29#[cfg(test)]
30mod tests {
31    use super::*;
32    use serde::Deserialize;
33
34    /// Cross-language bucketing contract fixture, shared verbatim with the TS
35    /// implementation. Path is relative to this file:
36    /// `src/placements/bucketing.rs` -> up to `packages/` -> `shared/src/...`.
37    const VECTORS_JSON: &str =
38        include_str!("../../../shared/src/experiments/bucketing-vectors.json");
39
40    #[derive(Debug, Deserialize)]
41    struct VectorFile {
42        #[serde(rename = "bucketCount")]
43        bucket_count: u32,
44        cases: Vec<VectorCase>,
45    }
46
47    #[derive(Debug, Deserialize)]
48    struct VectorCase {
49        #[serde(rename = "subscriberId")]
50        subscriber_id: String,
51        seed: String,
52        #[serde(rename = "expectedBucket")]
53        expected_bucket: u32,
54        variants: Vec<VectorVariant>,
55        #[serde(rename = "expectedVariantId")]
56        expected_variant_id: String,
57    }
58
59    #[derive(Debug, Deserialize)]
60    struct VectorVariant {
61        id: String,
62        weight: f64,
63    }
64
65    #[test]
66    fn matches_cross_language_bucketing_vectors() {
67        let file: VectorFile = serde_json::from_str(VECTORS_JSON).expect("valid fixture json");
68        assert_eq!(file.bucket_count, BUCKET_COUNT);
69        assert!(!file.cases.is_empty());
70
71        for case in &file.cases {
72            let bucket = assign_bucket(&case.subscriber_id, &case.seed);
73            assert_eq!(
74                bucket, case.expected_bucket,
75                "bucket mismatch for subscriber={:?} seed={:?}",
76                case.subscriber_id, case.seed
77            );
78
79            let weights: Vec<f64> = case.variants.iter().map(|v| v.weight).collect();
80            let idx = select_variant_index(bucket, &weights);
81            assert_eq!(
82                case.variants[idx].id, case.expected_variant_id,
83                "variant mismatch for subscriber={:?} seed={:?}",
84                case.subscriber_id, case.seed
85            );
86        }
87    }
88}