sp_domains/offline_operators.rs
1//! Chernoff lower-tail threshold for Binomial(S, p).
2//!
3//! Threshold (additive Chernoff, lower tail):
4//!   Let μ = S * p, where S is slots_in_epoch and p is per-slot win probability.
5//!   t = sqrt( 2 * μ * ln(1/τ) )
6//!   r = floor( μ - t ), clamped to [0, S]
7//!
8//! Property:
9//!   For X ~ Binomial(S, p), `P[ X < r ] <= τ`  (conservative bound)
10//!   This holds for all p in `[0,1]` and any S.
11//!
12//! Notes:
13//! - Provide ln(1/τ) as a FixedU128 constant (precomputed off-chain).
14//! - Examples:
15//!   τ = 1%   => ln(100)   ≈ 4.605170185988092  -> inner ≈ 4_605_170_185_988_091_904
16//!   τ = 0.5% => ln(200)   ≈ 5.298317366548036  -> inner ≈ 5_298_317_366_548_036_000
17//!   τ = 0.1% => ln(1000)  ≈ 6.907755278982137  -> inner ≈ 6_907_755_278_982_137_000
18//! - To decide throughput relevance before calling this (cheap filter), compute
19//!   μ_floor = floor(S * p) and require μ_floor >= E_relevance (e.g., >= 10 for τ=1%).
20
21#[cfg(test)]
22mod tests;
23
24use crate::StakeWeight;
25use crate::bundle_producer_election::calculate_threshold;
26use core::cmp::max;
27use frame_support::pallet_prelude::{Decode, Encode, TypeInfo};
28use frame_support::{Deserialize, Serialize};
29use sp_arithmetic::traits::{One, SaturatedConversion, Saturating, UniqueSaturatedInto, Zero};
30use sp_arithmetic::{FixedPointNumber, FixedU128};
31use sp_core::U256;
32
33/// For τ = 1%: ln(1/τ) = ln(100) ≈ 4.605170185988092
34/// Represent in FixedU128 by multiplying by 1e18 and rounding.
35pub const LN_1_OVER_TAU_1_PERCENT: FixedU128 = FixedU128::from_inner(4_605_170_185_988_091_904);
36
37/// Threshold for number of bundles operator can produce based on their stake share.
38/// Operators whose expected bundles from their threshold < E_BASE will not be checked
39/// since they are not throughput relevant.
40pub const E_BASE: u64 = 3;
41
42/// Extension trait for FixedU128 providing rounding to u64.
43trait FixedU128Ext {
44    /// Floor FixedU128 to u64.
45    fn to_u64_floor(self) -> u64;
46    /// Ceil FixedU128 to u64.
47    fn to_u64_ceil(self) -> u64;
48}
49
50impl FixedU128Ext for FixedU128 {
51    fn to_u64_floor(self) -> u64 {
52        (self.into_inner() / FixedU128::DIV).saturated_into()
53    }
54
55    fn to_u64_ceil(self) -> u64 {
56        self.into_inner().div_ceil(FixedU128::DIV).saturated_into()
57    }
58}
59
60// Exact per-slot win probability is p = threshold / 2^128.
61// Convert that to FixedU128 (1e18 scaling) using U256 intermediates.
62fn p_from_threshold(threshold: u128) -> FixedU128 {
63    // inner = floor(threshold * 1e18 / 2^128)
64    let num = U256::from(threshold).saturating_mul(U256::from(FixedU128::DIV));
65    let den = U256::from(1u128) << 128; // 2^128
66    FixedU128::from_inner((num / den).unique_saturated_into())
67}
68
69/// Chernoff lower-tail threshold r for Binomial(S, p), with false-positive at most τ.
70///
71/// Inputs:
72/// - slots_in_epoch: S (number of slots in the epoch)
73/// - p_slot: per-slot success probability p in FixedU128 (1e18 scaling)
74/// - ln_one_over_tau: ln(1/τ) in FixedU128 (1e18 scaling), precomputed off-chain
75///
76/// Returns:
77/// - r in [0, S], as u64
78///
79/// Formula:
80///   μ = S * p
81///   r = floor( μ - sqrt( 2 * μ * ln(1/τ) ) ), clamped to [0, S]
82///
83/// Guarantees:
84///   P_honest(X < r) <= τ for X ~ Binomial(S, p). Conservative (safe) bound.
85///
86/// Usage:
87///   let r = chernoff_threshold_fp(S, p, ln_1_over_tau);
88///   if observed_bundles < r { exclude } else { keep }
89fn chernoff_threshold_fp(
90    slots_in_epoch: u64,
91    p_slot: FixedU128,
92    ln_one_over_tau: FixedU128,
93) -> Option<u64> {
94    if slots_in_epoch == 0 || p_slot.is_zero() {
95        return Some(0);
96    }
97
98    if p_slot >= FixedU128::one() {
99        return Some(slots_in_epoch);
100    }
101
102    // μ = S * p
103    let mu = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
104
105    // t = sqrt( 2 * μ * ln(1/τ) )
106    let t = FixedU128::from(2u128)
107        .saturating_mul(mu)
108        .saturating_mul(ln_one_over_tau)
109        .try_sqrt()?;
110
111    // r = floor( μ - t ), clamped to [0, S]
112    Some(
113        if mu > t {
114            mu.saturating_sub(t).to_u64_floor()
115        } else {
116            0
117        }
118        .clamp(0, slots_in_epoch),
119    )
120}
121
122/// Check if an operator is "throughput-relevant" this epoch:
123/// μ_floor = floor(S * p) >= E_relevance
124///
125/// Recommendation for τ = 1%:
126///   E_relevance >= ceil( 2 * ln(1/τ) ) = ceil(9.21) = 10
127fn is_throughput_relevant_fp(slots_in_epoch: u64, p_slot: FixedU128, e_relevance: u64) -> bool {
128    let mu = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
129    let mu_floor = mu.to_u64_floor();
130    mu_floor >= e_relevance
131}
132
133// Compute E_relevance = ceil( max( E_BASE, 2 * ln(1/τ) ) ) in integer bundles.
134fn compute_e_relevance(ln_one_over_tau: FixedU128, e_base: u64) -> u64 {
135    let two_ln = FixedU128::saturating_from_integer(2)
136        .saturating_mul(ln_one_over_tau)
137        .to_u64_ceil();
138    max(e_base, two_ln)
139}
140
141/// Expectations for one operator at epoch end (slots-based, Chernoff-calibrated).
142#[derive(TypeInfo, Debug, Encode, Decode, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct OperatorEpochExpectations {
144    /// floor(μ) = floor(S * p_slot_exact): integer expected bundles this epoch.
145    pub expected_bundles: u64,
146    /// Chernoff lower-bound r: minimum bundles to pass with false-positive ≤ τ.
147    pub min_required_bundles: u64,
148}
149
150/// Compute epoch-end expectations for an operator using the exact VRF threshold.
151/// Returns None if the operator is not throughput-relevant this epoch.
152///
153/// Flow is:
154/// - calculate_threshold(...) -> u128 threshold
155/// - p_from_threshold(threshold) -> FixedU128 per-slot probability
156/// - compute_e_relevance(ln_one_over_tau, e_base) -> u64 E_relevance
157/// - is_throughput_relevant_fp(S, p, E_relevance) -> bool
158/// - chernoff_threshold_fp(S, p, ln_one_over_tau) -> u64 r
159///
160/// Inputs:
161/// - slots_in_epoch: S
162/// - operator_stake, total_domain_stake: epoch-start stake snapshot
163/// - bundle_slot_probability: (theta_num, theta_den)
164/// - ln_one_over_tau: ln(1/τ) in FixedU128 (e.g., LN_1_OVER_TAU_1_PERCENT)
165/// - e_base: soft relevance floor (e.g., E_BASE)
166pub fn operator_expected_bundles_in_epoch(
167    slots_in_epoch: u64,
168    operator_stake: StakeWeight,
169    total_domain_stake: StakeWeight,
170    bundle_slot_probability: (u64, u64), // (theta_num, theta_den)
171    ln_one_over_tau: FixedU128,
172    e_base: u64,
173) -> Option<OperatorEpochExpectations> {
174    if slots_in_epoch.is_zero() {
175        return None;
176    }
177
178    // Exact per-slot probability from runtime VRF threshold
179    let threshold =
180        calculate_threshold(operator_stake, total_domain_stake, bundle_slot_probability)?;
181    let p_slot = p_from_threshold(threshold);
182    if p_slot.is_zero() {
183        return None;
184    }
185
186    // Throughput relevance: μ_floor >= E_relevance
187    let e_rel = compute_e_relevance(ln_one_over_tau, e_base);
188    if !is_throughput_relevant_fp(slots_in_epoch, p_slot, e_rel) {
189        return None;
190    }
191
192    // Expected bundles (floor μ) and Chernoff lower-bound r
193    let mu_fp = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
194    let expected_bundles = mu_fp.to_u64_floor();
195    let min_required_bundles = chernoff_threshold_fp(slots_in_epoch, p_slot, ln_one_over_tau)?;
196
197    Some(OperatorEpochExpectations {
198        expected_bundles,
199        min_required_bundles,
200    })
201}