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::{DecodeWithMemTracking, 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// For τ = 0.5%: ln(1/τ) = ln(200) ≈ 5.298317366548036
38// Represent in FixedU128 by multiplying by 1e18 and rounding.
39pub const LN_1_OVER_TAU_0_5_PERCENT: FixedU128 = FixedU128::from_inner(5_298_317_366_548_036_608);
40
41/// Threshold for number of bundles operator can produce based on their stake share.
42/// Operators whose expected bundles from their threshold < E_BASE will not be checked
43/// since they are not throughput relevant.
44pub const E_BASE: u64 = 3;
45
46/// Minimum shortfall fraction relative to expected bundles to consider an operator offline.
47/// (numerator, denominator): operator is flagged only if submitted < expected * (den - num) / den.
48/// (1, 3) means 1/3 = 33% shortfall required (must submit < 67% of expected).
49/// Constraints: denominator > 0, numerator <= denominator.
50pub const OFFLINE_SHORTFALL_FRACTION: (u64, u64) = (1, 3);
51
52const _: () = assert!(OFFLINE_SHORTFALL_FRACTION.1 > 0);
53const _: () = assert!(OFFLINE_SHORTFALL_FRACTION.0 < OFFLINE_SHORTFALL_FRACTION.1);
54
55/// Extension trait for FixedU128 providing rounding to u64.
56trait FixedU128Ext {
57    /// Floor FixedU128 to u64.
58    fn to_u64_floor(self) -> u64;
59    /// Ceil FixedU128 to u64.
60    fn to_u64_ceil(self) -> u64;
61}
62
63impl FixedU128Ext for FixedU128 {
64    fn to_u64_floor(self) -> u64 {
65        (self.into_inner() / FixedU128::DIV).saturated_into()
66    }
67
68    fn to_u64_ceil(self) -> u64 {
69        self.into_inner().div_ceil(FixedU128::DIV).saturated_into()
70    }
71}
72
73// Exact per-slot win probability is p = threshold / 2^128.
74// Convert that to FixedU128 (1e18 scaling) using U256 intermediates.
75fn p_from_threshold(threshold: u128) -> FixedU128 {
76    // inner = floor(threshold * 1e18 / 2^128)
77    let num = U256::from(threshold).saturating_mul(U256::from(FixedU128::DIV));
78    let den = U256::from(1u128) << 128; // 2^128
79    FixedU128::from_inner((num / den).unique_saturated_into())
80}
81
82/// Chernoff lower-tail threshold r for Binomial(S, p), with false-positive at most τ.
83///
84/// Inputs:
85/// - slots_in_epoch: S (number of slots in the epoch)
86/// - p_slot: per-slot success probability p in FixedU128 (1e18 scaling)
87/// - ln_one_over_tau: ln(1/τ) in FixedU128 (1e18 scaling), precomputed off-chain
88///
89/// Returns:
90/// - r in [0, S], as u64
91///
92/// Formula:
93///   μ = S * p
94///   r = floor( μ - sqrt( 2 * μ * ln(1/τ) ) ), clamped to [0, S]
95///
96/// Guarantees:
97///   P_honest(X < r) <= τ for X ~ Binomial(S, p). Conservative (safe) bound.
98///
99/// Usage:
100///   let r = chernoff_threshold_fp(S, p, ln_1_over_tau);
101///   if observed_bundles < r { exclude } else { keep }
102fn chernoff_threshold_fp(
103    slots_in_epoch: u64,
104    p_slot: FixedU128,
105    ln_one_over_tau: FixedU128,
106) -> Option<u64> {
107    if slots_in_epoch == 0 || p_slot.is_zero() {
108        return Some(0);
109    }
110
111    if p_slot >= FixedU128::one() {
112        return Some(slots_in_epoch);
113    }
114
115    // μ = S * p
116    let mu = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
117
118    // t = sqrt( 2 * μ * ln(1/τ) )
119    let t = FixedU128::from(2u128)
120        .saturating_mul(mu)
121        .saturating_mul(ln_one_over_tau)
122        .try_sqrt()?;
123
124    // r = floor( μ - t ), clamped to [0, S]
125    Some(
126        if mu > t {
127            mu.saturating_sub(t).to_u64_floor()
128        } else {
129            0
130        }
131        .clamp(0, slots_in_epoch),
132    )
133}
134
135/// Check if an operator is "throughput-relevant" this epoch:
136/// μ_floor = floor(S * p) >= E_relevance
137///
138/// Recommendation for τ = 1%:
139///   E_relevance >= ceil( 2 * ln(1/τ) ) = ceil(9.21) = 10
140fn is_throughput_relevant_fp(slots_in_epoch: u64, p_slot: FixedU128, e_relevance: u64) -> bool {
141    let mu = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
142    let mu_floor = mu.to_u64_floor();
143    mu_floor >= e_relevance
144}
145
146// Compute E_relevance = ceil( max( E_BASE, 2 * ln(1/τ) ) ) in integer bundles.
147fn compute_e_relevance(ln_one_over_tau: FixedU128, e_base: u64) -> u64 {
148    let two_ln = FixedU128::saturating_from_integer(2)
149        .saturating_mul(ln_one_over_tau)
150        .to_u64_ceil();
151    max(e_base, two_ln)
152}
153
154/// Expectations for one operator at epoch end (slots-based, Chernoff-calibrated).
155#[derive(
156    TypeInfo,
157    Debug,
158    Encode,
159    Decode,
160    Clone,
161    PartialEq,
162    Eq,
163    Serialize,
164    Deserialize,
165    DecodeWithMemTracking,
166)]
167pub struct OperatorEpochExpectations {
168    /// floor(μ) = floor(S * p_slot_exact): integer expected bundles this epoch.
169    pub expected_bundles: u64,
170    /// Chernoff lower-bound r: minimum bundles to pass with false-positive ≤ τ.
171    pub min_required_bundles: u64,
172    /// Shortfall policy threshold: expected * (den - num) / den, using floor rounding.
173    /// Operator must submit fewer than this AND fewer than min_required_bundles to be flagged.
174    pub shortfall_threshold: u64,
175}
176
177/// Compute epoch-end expectations for an operator using the exact VRF threshold.
178/// Returns None if the operator is not throughput-relevant this epoch.
179///
180/// Flow is:
181/// - calculate_threshold(...) -> u128 threshold
182/// - p_from_threshold(threshold) -> FixedU128 per-slot probability
183/// - compute_e_relevance(ln_one_over_tau, e_base) -> u64 E_relevance
184/// - is_throughput_relevant_fp(S, p, E_relevance) -> bool
185/// - chernoff_threshold_fp(S, p, ln_one_over_tau) -> u64 r
186///
187/// Inputs:
188/// - slots_in_epoch: S
189/// - operator_stake, total_domain_stake: epoch-start stake snapshot
190/// - bundle_slot_probability: (theta_num, theta_den)
191/// - ln_one_over_tau: ln(1/τ) in FixedU128 (e.g., LN_1_OVER_TAU_1_PERCENT)
192/// - e_base: soft relevance floor (e.g., E_BASE)
193pub fn operator_expected_bundles_in_epoch(
194    slots_in_epoch: u64,
195    operator_stake: StakeWeight,
196    total_domain_stake: StakeWeight,
197    bundle_slot_probability: (u64, u64), // (theta_num, theta_den)
198    ln_one_over_tau: FixedU128,
199    e_base: u64,
200    shortfall_fraction: (u64, u64),
201) -> Option<OperatorEpochExpectations> {
202    if slots_in_epoch.is_zero() || shortfall_fraction.1 == 0 {
203        return None;
204    }
205
206    // Exact per-slot probability from runtime VRF threshold
207    let threshold =
208        calculate_threshold(operator_stake, total_domain_stake, bundle_slot_probability)?;
209    let p_slot = p_from_threshold(threshold);
210    if p_slot.is_zero() {
211        return None;
212    }
213
214    // Throughput relevance: μ_floor >= E_relevance
215    let e_rel = compute_e_relevance(ln_one_over_tau, e_base);
216    if !is_throughput_relevant_fp(slots_in_epoch, p_slot, e_rel) {
217        return None;
218    }
219
220    // Expected bundles (floor μ) and Chernoff lower-bound r
221    let mu_fp = p_slot.saturating_mul(FixedU128::saturating_from_integer(slots_in_epoch));
222    let expected_bundles = mu_fp.to_u64_floor();
223    let min_required_bundles = chernoff_threshold_fp(slots_in_epoch, p_slot, ln_one_over_tau)?;
224
225    // Shortfall policy gate: expected * (den - num) / den, floor rounding
226    let (sf_num, sf_den) = shortfall_fraction;
227    let shortfall_threshold =
228        expected_bundles.saturating_mul(sf_den.saturating_sub(sf_num)) / sf_den;
229
230    Some(OperatorEpochExpectations {
231        expected_bundles,
232        min_required_bundles,
233        shortfall_threshold,
234    })
235}