Skip to main content

ant_node/replication/
config.rs

1//! Tunable parameters for the replication subsystem.
2//!
3//! All values below are a reference profile used for logic validation.
4//! Parameter safety constraints (Section 4):
5//! 1. `1 <= QUORUM_THRESHOLD <= CLOSE_GROUP_SIZE`
6//! 2. Effective paid-list threshold is per-key dynamic:
7//!    `ConfirmNeeded(K) = floor(PaidGroupSize(K)/2)+1`
8//! 3. If constraints are violated at runtime reconfiguration, node MUST reject
9//!    the config.
10
11#![allow(clippy::module_name_repetitions)]
12
13use std::time::Duration;
14
15use rand::Rng;
16
17use crate::ant_protocol::CLOSE_GROUP_SIZE;
18
19// ---------------------------------------------------------------------------
20// Static constants (compile-time reference profile)
21// ---------------------------------------------------------------------------
22
23/// Maximum number of peers per k-bucket in the Kademlia routing table.
24pub const K_BUCKET_SIZE: usize = 20;
25
26/// Full-network target for required positive presence votes.
27///
28/// Effective per-key threshold is
29/// `QuorumNeeded(K) = min(QUORUM_THRESHOLD, floor(|QuorumTargets|/2)+1)`.
30pub const QUORUM_THRESHOLD: usize = 4; // floor(CLOSE_GROUP_SIZE / 2) + 1
31
32/// Maximum number of closest nodes tracking paid status for a key.
33pub const PAID_LIST_CLOSE_GROUP_SIZE: usize = 20;
34
35/// Number of closest peers to self eligible for neighbor sync.
36pub const NEIGHBOR_SYNC_SCOPE: usize = 20;
37
38/// Number of close-neighbor peers synced concurrently per round-robin repair
39/// round.
40pub const NEIGHBOR_SYNC_PEER_COUNT: usize = 4;
41
42/// Minimum neighbor-sync cadence. Actual interval is randomized within
43/// `[min, max]`.
44const NEIGHBOR_SYNC_INTERVAL_MIN_SECS: u64 = 10 * 60;
45/// Maximum neighbor-sync cadence.
46const NEIGHBOR_SYNC_INTERVAL_MAX_SECS: u64 = 20 * 60;
47
48/// Neighbor sync cadence range (min).
49pub const NEIGHBOR_SYNC_INTERVAL_MIN: Duration =
50    Duration::from_secs(NEIGHBOR_SYNC_INTERVAL_MIN_SECS);
51
52/// Neighbor sync cadence range (max).
53pub const NEIGHBOR_SYNC_INTERVAL_MAX: Duration =
54    Duration::from_secs(NEIGHBOR_SYNC_INTERVAL_MAX_SECS);
55
56/// Per-peer minimum spacing between successive syncs with the same peer.
57const NEIGHBOR_SYNC_COOLDOWN_SECS: u64 = 60 * 60; // 1 hour
58/// Per-peer minimum spacing between successive syncs with the same peer.
59pub const NEIGHBOR_SYNC_COOLDOWN: Duration = Duration::from_secs(NEIGHBOR_SYNC_COOLDOWN_SECS);
60
61/// Minimum self-lookup cadence.
62const SELF_LOOKUP_INTERVAL_MIN_SECS: u64 = 5 * 60;
63/// Maximum self-lookup cadence.
64const SELF_LOOKUP_INTERVAL_MAX_SECS: u64 = 10 * 60;
65
66/// Periodic self-lookup cadence range (min) to keep close neighborhood
67/// current.
68pub const SELF_LOOKUP_INTERVAL_MIN: Duration = Duration::from_secs(SELF_LOOKUP_INTERVAL_MIN_SECS);
69
70/// Periodic self-lookup cadence range (max).
71pub const SELF_LOOKUP_INTERVAL_MAX: Duration = Duration::from_secs(SELF_LOOKUP_INTERVAL_MAX_SECS);
72
73/// Maximum number of concurrent outbound replication sends.
74///
75/// Caps how many fresh-replication chunk transfers can be in-flight at once
76/// across the entire replication engine. Prevents bandwidth saturation on
77/// home broadband connections when multiple chunks arrive simultaneously.
78/// Each send transfers up to 4 MB (`MAX_CHUNK_SIZE`), so a limit of 3 means
79/// at most ~12 MB queued for the upload link at any instant.
80pub const MAX_CONCURRENT_REPLICATION_SENDS: usize = 3;
81
82/// Concurrent fetches cap, derived from hardware thread count.
83///
84/// Uses `std::thread::available_parallelism()` so the node scales to the
85/// machine it runs on.  Falls back to 4 if the OS query fails.
86const AVAILABLE_PARALLELISM_FALLBACK: usize = 4;
87
88/// Returns the number of hardware threads available, used as the fetch
89/// concurrency limit.
90#[allow(clippy::incompatible_msrv)] // NonZero::get is stable since 1.79; MSRV lint conflicts with redundant_closure
91pub fn max_parallel_fetch() -> usize {
92    std::thread::available_parallelism()
93        .map_or(AVAILABLE_PARALLELISM_FALLBACK, std::num::NonZero::get)
94}
95
96/// Minimum audit-scheduler cadence.
97const AUDIT_TICK_INTERVAL_MIN_SECS: u64 = 10 * 60;
98/// Maximum audit-scheduler cadence.
99const AUDIT_TICK_INTERVAL_MAX_SECS: u64 = 20 * 60;
100
101/// Audit scheduler cadence range (min).
102pub const AUDIT_TICK_INTERVAL_MIN: Duration = Duration::from_secs(AUDIT_TICK_INTERVAL_MIN_SECS);
103
104/// Audit scheduler cadence range (max).
105pub const AUDIT_TICK_INTERVAL_MAX: Duration = Duration::from_secs(AUDIT_TICK_INTERVAL_MAX_SECS);
106
107/// Base audit response deadline (independent of challenge size).
108const AUDIT_RESPONSE_BASE_SECS: u64 = 10;
109/// Per-key allowance added to the base audit response deadline.
110const AUDIT_RESPONSE_PER_KEY_MS: u64 = 20;
111
112/// Maximum duration a peer may claim bootstrap status before penalties apply.
113const BOOTSTRAP_CLAIM_GRACE_PERIOD_SECS: u64 = 24 * 60 * 60; // 24 h
114/// Maximum duration a peer may claim bootstrap status before penalties apply.
115pub const BOOTSTRAP_CLAIM_GRACE_PERIOD: Duration =
116    Duration::from_secs(BOOTSTRAP_CLAIM_GRACE_PERIOD_SECS);
117
118/// Minimum continuous out-of-range duration before pruning a key.
119const PRUNE_HYSTERESIS_DURATION_SECS: u64 = 3 * 24 * 60 * 60; // 3 days
120/// Minimum continuous out-of-range duration before pruning a key.
121pub const PRUNE_HYSTERESIS_DURATION: Duration = Duration::from_secs(PRUNE_HYSTERESIS_DURATION_SECS);
122
123/// Protocol identifier for replication operations.
124pub const REPLICATION_PROTOCOL_ID: &str = "autonomi.ant.replication.v1";
125
126/// 10 MiB — maximum replication wire message size (accommodates hint batches).
127const REPLICATION_MESSAGE_SIZE_MIB: usize = 10;
128/// Maximum replication wire message size.
129pub const MAX_REPLICATION_MESSAGE_SIZE: usize = REPLICATION_MESSAGE_SIZE_MIB * 1024 * 1024;
130
131/// Verification request timeout (per-batch).
132const VERIFICATION_REQUEST_TIMEOUT_SECS: u64 = 15;
133/// Verification request timeout (per-batch).
134pub const VERIFICATION_REQUEST_TIMEOUT: Duration =
135    Duration::from_secs(VERIFICATION_REQUEST_TIMEOUT_SECS);
136
137/// Fetch request timeout.
138const FETCH_REQUEST_TIMEOUT_SECS: u64 = 30;
139/// Fetch request timeout.
140pub const FETCH_REQUEST_TIMEOUT: Duration = Duration::from_secs(FETCH_REQUEST_TIMEOUT_SECS);
141
142/// Maximum age for pending-verification entries before stale eviction.
143const PENDING_VERIFY_MAX_AGE_SECS: u64 = 30 * 60;
144/// Maximum age for pending-verification entries before stale eviction.
145pub const PENDING_VERIFY_MAX_AGE: Duration = Duration::from_secs(PENDING_VERIFY_MAX_AGE_SECS);
146
147/// Trust event weight for confirmed audit failures.
148pub const AUDIT_FAILURE_TRUST_WEIGHT: f64 = 5.0;
149
150/// Maximum number of prune-confirmation audit challenges sent per prune pass.
151pub const MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS: usize = 64;
152
153/// Seconds to wait for `DhtNetworkEvent::BootstrapComplete` before proceeding
154/// with bootstrap sync. Covers bootstrap nodes with no peers to connect to.
155const BOOTSTRAP_COMPLETE_TIMEOUT_SECS: u64 = 60;
156
157// ---------------------------------------------------------------------------
158// Runtime-configurable wrapper
159// ---------------------------------------------------------------------------
160
161/// Runtime-configurable replication parameters.
162///
163/// Validated on construction — node rejects invalid configs.
164#[derive(Debug, Clone)]
165pub struct ReplicationConfig {
166    /// Close-group width and target holder count per key.
167    pub close_group_size: usize,
168    /// Required positive presence votes for quorum.
169    pub quorum_threshold: usize,
170    /// Maximum closest nodes tracking paid status for a key.
171    pub paid_list_close_group_size: usize,
172    /// Number of closest peers to self eligible for neighbor sync.
173    pub neighbor_sync_scope: usize,
174    /// Peers synced concurrently per round-robin repair round.
175    pub neighbor_sync_peer_count: usize,
176    /// Neighbor sync cadence range (min).
177    pub neighbor_sync_interval_min: Duration,
178    /// Neighbor sync cadence range (max).
179    pub neighbor_sync_interval_max: Duration,
180    /// Minimum spacing between successive syncs with the same peer.
181    pub neighbor_sync_cooldown: Duration,
182    /// Self-lookup cadence range (min).
183    pub self_lookup_interval_min: Duration,
184    /// Self-lookup cadence range (max).
185    pub self_lookup_interval_max: Duration,
186    /// Audit scheduler cadence range (min).
187    pub audit_tick_interval_min: Duration,
188    /// Audit scheduler cadence range (max).
189    pub audit_tick_interval_max: Duration,
190    /// Base audit response deadline (key-independent component).
191    pub audit_response_base: Duration,
192    /// Per-key allowance added to the base audit response deadline.
193    pub audit_response_per_key: Duration,
194    /// Maximum duration a peer may claim bootstrap status.
195    pub bootstrap_claim_grace_period: Duration,
196    /// Minimum continuous out-of-range duration before pruning a key.
197    pub prune_hysteresis_duration: Duration,
198    /// Verification request timeout (per-batch).
199    pub verification_request_timeout: Duration,
200    /// Fetch request timeout.
201    pub fetch_request_timeout: Duration,
202    /// Seconds to wait for `DhtNetworkEvent::BootstrapComplete` before
203    /// proceeding with bootstrap sync (covers bootstrap nodes with no peers).
204    pub bootstrap_complete_timeout_secs: u64,
205}
206
207impl Default for ReplicationConfig {
208    fn default() -> Self {
209        Self {
210            close_group_size: CLOSE_GROUP_SIZE,
211            quorum_threshold: QUORUM_THRESHOLD,
212            paid_list_close_group_size: PAID_LIST_CLOSE_GROUP_SIZE,
213            neighbor_sync_scope: NEIGHBOR_SYNC_SCOPE,
214            neighbor_sync_peer_count: NEIGHBOR_SYNC_PEER_COUNT,
215            neighbor_sync_interval_min: NEIGHBOR_SYNC_INTERVAL_MIN,
216            neighbor_sync_interval_max: NEIGHBOR_SYNC_INTERVAL_MAX,
217            neighbor_sync_cooldown: NEIGHBOR_SYNC_COOLDOWN,
218            self_lookup_interval_min: SELF_LOOKUP_INTERVAL_MIN,
219            self_lookup_interval_max: SELF_LOOKUP_INTERVAL_MAX,
220            audit_tick_interval_min: AUDIT_TICK_INTERVAL_MIN,
221            audit_tick_interval_max: AUDIT_TICK_INTERVAL_MAX,
222            audit_response_base: Duration::from_secs(AUDIT_RESPONSE_BASE_SECS),
223            audit_response_per_key: Duration::from_millis(AUDIT_RESPONSE_PER_KEY_MS),
224            bootstrap_claim_grace_period: BOOTSTRAP_CLAIM_GRACE_PERIOD,
225            prune_hysteresis_duration: PRUNE_HYSTERESIS_DURATION,
226            verification_request_timeout: VERIFICATION_REQUEST_TIMEOUT,
227            fetch_request_timeout: FETCH_REQUEST_TIMEOUT,
228            bootstrap_complete_timeout_secs: BOOTSTRAP_COMPLETE_TIMEOUT_SECS,
229        }
230    }
231}
232
233impl ReplicationConfig {
234    /// Validate safety constraints. Returns `Err` with a description if any
235    /// constraint is violated.
236    ///
237    /// # Errors
238    ///
239    /// Returns a human-readable message describing the first violated
240    /// constraint.
241    pub fn validate(&self) -> Result<(), String> {
242        if self.close_group_size == 0 {
243            return Err("close_group_size must be >= 1".to_string());
244        }
245        if self.quorum_threshold == 0 || self.quorum_threshold > self.close_group_size {
246            return Err(format!(
247                "quorum_threshold ({}) must satisfy 1 <= quorum_threshold <= close_group_size ({})",
248                self.quorum_threshold, self.close_group_size,
249            ));
250        }
251        if self.close_group_size > MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS {
252            return Err(format!(
253                "close_group_size ({}) must be <= MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS ({})",
254                self.close_group_size, MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS,
255            ));
256        }
257        if self.paid_list_close_group_size == 0 {
258            return Err("paid_list_close_group_size must be >= 1".to_string());
259        }
260        if self.neighbor_sync_interval_min > self.neighbor_sync_interval_max {
261            return Err(format!(
262                "neighbor_sync_interval_min ({:?}) must be <= neighbor_sync_interval_max ({:?})",
263                self.neighbor_sync_interval_min, self.neighbor_sync_interval_max,
264            ));
265        }
266        if self.audit_tick_interval_min > self.audit_tick_interval_max {
267            return Err(format!(
268                "audit_tick_interval_min ({:?}) must be <= audit_tick_interval_max ({:?})",
269                self.audit_tick_interval_min, self.audit_tick_interval_max,
270            ));
271        }
272        if self.self_lookup_interval_min > self.self_lookup_interval_max {
273            return Err(format!(
274                "self_lookup_interval_min ({:?}) must be <= self_lookup_interval_max ({:?})",
275                self.self_lookup_interval_min, self.self_lookup_interval_max,
276            ));
277        }
278        if self.neighbor_sync_peer_count == 0 {
279            return Err("neighbor_sync_peer_count must be >= 1".to_string());
280        }
281        if self.neighbor_sync_scope == 0 {
282            return Err("neighbor_sync_scope must be >= 1".to_string());
283        }
284        Ok(())
285    }
286
287    /// Effective quorum votes required for a key given the number of
288    /// reachable quorum targets.
289    ///
290    /// `min(self.quorum_threshold, floor(quorum_targets_count / 2) + 1)`
291    #[must_use]
292    pub fn quorum_needed(&self, quorum_targets_count: usize) -> usize {
293        if quorum_targets_count == 0 {
294            return 0;
295        }
296        let majority = quorum_targets_count / 2 + 1;
297        self.quorum_threshold.min(majority)
298    }
299
300    /// Confirmations required for paid-list consensus given the number of
301    /// peers in the paid-list close group for a key.
302    ///
303    /// `floor(paid_group_size / 2) + 1`
304    #[must_use]
305    pub fn confirm_needed(paid_group_size: usize) -> usize {
306        paid_group_size / 2 + 1
307    }
308
309    /// Returns a random duration in `[neighbor_sync_interval_min,
310    /// neighbor_sync_interval_max]`.
311    #[must_use]
312    pub fn random_neighbor_sync_interval(&self) -> Duration {
313        random_duration_in_range(
314            self.neighbor_sync_interval_min,
315            self.neighbor_sync_interval_max,
316        )
317    }
318
319    /// Compute the number of keys to sample for an audit round, scaled
320    /// dynamically by the total number of locally stored keys.
321    ///
322    /// Formula: `max(floor(sqrt(total_keys)), 1)`, capped at `total_keys`.
323    #[must_use]
324    pub fn audit_sample_count(total_keys: usize) -> usize {
325        #[allow(
326            clippy::cast_possible_truncation,
327            clippy::cast_sign_loss,
328            clippy::cast_precision_loss
329        )]
330        let sqrt = (total_keys as f64).sqrt() as usize;
331        sqrt.max(1).min(total_keys)
332    }
333
334    /// Maximum number of keys to accept in an incoming audit challenge.
335    ///
336    /// Scales dynamically: `2 * audit_sample_count(stored_chunks)`. The 2x
337    /// margin accounts for the challenger having a larger store than us and
338    /// therefore sampling more keys.
339    #[must_use]
340    pub fn max_incoming_audit_keys(stored_chunks: usize) -> usize {
341        // Allow at least 1 key so a newly-joined node can still be audited.
342        (2 * Self::audit_sample_count(stored_chunks)).max(1)
343    }
344
345    /// Compute the audit response timeout for a challenge with
346    /// `challenged_key_count` keys: `base + per_key * challenged_key_count`.
347    #[must_use]
348    pub fn audit_response_timeout(&self, challenged_key_count: usize) -> Duration {
349        let keys = u32::try_from(challenged_key_count).unwrap_or(u32::MAX);
350        self.audit_response_base + self.audit_response_per_key * keys
351    }
352
353    /// Returns a random duration in `[audit_tick_interval_min,
354    /// audit_tick_interval_max]`.
355    #[must_use]
356    pub fn random_audit_tick_interval(&self) -> Duration {
357        random_duration_in_range(self.audit_tick_interval_min, self.audit_tick_interval_max)
358    }
359
360    /// Returns a random duration in `[self_lookup_interval_min,
361    /// self_lookup_interval_max]`.
362    #[must_use]
363    pub fn random_self_lookup_interval(&self) -> Duration {
364        random_duration_in_range(self.self_lookup_interval_min, self.self_lookup_interval_max)
365    }
366}
367
368/// Pick a random `Duration` uniformly in `[min, max]` at millisecond
369/// granularity.
370///
371/// When `min == max` the result is deterministic.
372fn random_duration_in_range(min: Duration, max: Duration) -> Duration {
373    if min == max {
374        return min;
375    }
376    // Our intervals are minutes/hours, well within u64 range. Saturate to
377    // u64::MAX on the impossible overflow path to avoid a lossy cast.
378    let to_u64_millis = |d: Duration| -> u64 { u64::try_from(d.as_millis()).unwrap_or(u64::MAX) };
379    let chosen = rand::thread_rng().gen_range(to_u64_millis(min)..=to_u64_millis(max));
380    Duration::from_millis(chosen)
381}
382
383// ---------------------------------------------------------------------------
384// Tests
385// ---------------------------------------------------------------------------
386
387#[cfg(test)]
388#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn defaults_pass_validation() {
394        let config = ReplicationConfig::default();
395        assert!(config.validate().is_ok(), "default config must be valid");
396    }
397
398    #[test]
399    fn default_prune_hysteresis_is_three_days() {
400        let config = ReplicationConfig::default();
401        assert_eq!(
402            config.prune_hysteresis_duration,
403            Duration::from_secs(3 * 24 * 60 * 60)
404        );
405    }
406
407    #[test]
408    fn audit_failure_weight_is_five() {
409        assert!((AUDIT_FAILURE_TRUST_WEIGHT - 5.0).abs() <= f64::EPSILON);
410    }
411
412    #[test]
413    fn quorum_threshold_zero_rejected() {
414        let config = ReplicationConfig {
415            quorum_threshold: 0,
416            ..ReplicationConfig::default()
417        };
418        assert!(config.validate().is_err());
419    }
420
421    #[test]
422    fn quorum_threshold_exceeds_close_group_rejected() {
423        let defaults = ReplicationConfig::default();
424        let config = ReplicationConfig {
425            quorum_threshold: defaults.close_group_size + 1,
426            ..defaults
427        };
428        assert!(config.validate().is_err());
429    }
430
431    #[test]
432    fn close_group_size_zero_rejected() {
433        let config = ReplicationConfig {
434            close_group_size: 0,
435            ..ReplicationConfig::default()
436        };
437        assert!(config.validate().is_err());
438    }
439
440    #[test]
441    fn close_group_size_exceeding_prune_audit_budget_rejected() {
442        let config = ReplicationConfig {
443            close_group_size: MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS + 1,
444            quorum_threshold: QUORUM_THRESHOLD,
445            ..ReplicationConfig::default()
446        };
447
448        let err = config.validate().unwrap_err();
449
450        assert!(
451            err.contains("MAX_PRUNE_AUDIT_CHALLENGES_PER_PASS"),
452            "error should mention prune audit budget: {err}"
453        );
454    }
455
456    #[test]
457    fn paid_list_close_group_size_zero_rejected() {
458        let config = ReplicationConfig {
459            paid_list_close_group_size: 0,
460            ..ReplicationConfig::default()
461        };
462        assert!(config.validate().is_err());
463    }
464
465    #[test]
466    fn neighbor_sync_interval_inverted_rejected() {
467        let config = ReplicationConfig {
468            neighbor_sync_interval_min: Duration::from_secs(100),
469            neighbor_sync_interval_max: Duration::from_secs(50),
470            ..ReplicationConfig::default()
471        };
472        assert!(config.validate().is_err());
473    }
474
475    #[test]
476    fn audit_tick_interval_inverted_rejected() {
477        let config = ReplicationConfig {
478            audit_tick_interval_min: Duration::from_secs(100),
479            audit_tick_interval_max: Duration::from_secs(50),
480            ..ReplicationConfig::default()
481        };
482        assert!(config.validate().is_err());
483    }
484
485    #[test]
486    fn self_lookup_interval_inverted_rejected() {
487        let config = ReplicationConfig {
488            self_lookup_interval_min: Duration::from_secs(100),
489            self_lookup_interval_max: Duration::from_secs(50),
490            ..ReplicationConfig::default()
491        };
492        assert!(config.validate().is_err());
493    }
494
495    #[test]
496    fn neighbor_sync_peer_count_zero_rejected() {
497        let config = ReplicationConfig {
498            neighbor_sync_peer_count: 0,
499            ..ReplicationConfig::default()
500        };
501        assert!(config.validate().is_err());
502    }
503
504    #[test]
505    fn audit_sample_count_scales_with_sqrt() {
506        // Empty store
507        assert_eq!(ReplicationConfig::audit_sample_count(0), 0);
508
509        // Single key
510        assert_eq!(ReplicationConfig::audit_sample_count(1), 1);
511
512        // Small stores: sqrt(3)=1
513        assert_eq!(ReplicationConfig::audit_sample_count(3), 1);
514
515        // sqrt scaling
516        assert_eq!(ReplicationConfig::audit_sample_count(4), 2);
517        assert_eq!(ReplicationConfig::audit_sample_count(25), 5);
518        assert_eq!(ReplicationConfig::audit_sample_count(100), 10);
519        assert_eq!(ReplicationConfig::audit_sample_count(1_000), 31);
520        assert_eq!(ReplicationConfig::audit_sample_count(10_000), 100);
521        assert_eq!(ReplicationConfig::audit_sample_count(1_000_000), 1_000);
522    }
523
524    #[test]
525    fn max_incoming_audit_keys_scales_dynamically() {
526        // Empty store: at least 1 key accepted.
527        assert_eq!(ReplicationConfig::max_incoming_audit_keys(0), 1);
528
529        // 1 chunk: 2 * sqrt(1) = 2.
530        assert_eq!(ReplicationConfig::max_incoming_audit_keys(1), 2);
531
532        // 100 chunks: 2 * sqrt(100) = 20.
533        assert_eq!(ReplicationConfig::max_incoming_audit_keys(100), 20);
534
535        // 1M chunks: 2 * sqrt(1_000_000) = 2_000.
536        assert_eq!(ReplicationConfig::max_incoming_audit_keys(1_000_000), 2_000);
537
538        // 5M chunks: 2 * sqrt(5_000_000) = 4_472.
539        assert_eq!(ReplicationConfig::max_incoming_audit_keys(5_000_000), 4_472);
540    }
541
542    #[test]
543    fn quorum_needed_uses_smaller_of_threshold_and_majority() {
544        let config = ReplicationConfig::default();
545
546        // With 7 targets: majority = 7/2+1 = 4, threshold = 4 → min = 4
547        assert_eq!(config.quorum_needed(7), 4);
548
549        // With 3 targets: majority = 3/2+1 = 2, threshold = 4 → min = 2
550        assert_eq!(config.quorum_needed(3), 2);
551
552        // With 0 targets: quorum is impossible — returns 0
553        assert_eq!(config.quorum_needed(0), 0);
554
555        // With 100 targets: majority = 51, threshold = 4 → min = 4
556        assert_eq!(config.quorum_needed(100), 4);
557    }
558
559    #[test]
560    fn confirm_needed_is_strict_majority() {
561        assert_eq!(ReplicationConfig::confirm_needed(1), 1);
562        assert_eq!(ReplicationConfig::confirm_needed(2), 2);
563        assert_eq!(ReplicationConfig::confirm_needed(3), 2);
564        assert_eq!(ReplicationConfig::confirm_needed(4), 3);
565        assert_eq!(ReplicationConfig::confirm_needed(20), 11);
566    }
567
568    #[test]
569    fn random_intervals_within_bounds() {
570        let config = ReplicationConfig::default();
571
572        // Run several iterations to exercise randomness.
573        let iterations = 50;
574        for _ in 0..iterations {
575            let ns = config.random_neighbor_sync_interval();
576            assert!(ns >= config.neighbor_sync_interval_min);
577            assert!(ns <= config.neighbor_sync_interval_max);
578
579            let at = config.random_audit_tick_interval();
580            assert!(at >= config.audit_tick_interval_min);
581            assert!(at <= config.audit_tick_interval_max);
582
583            let sl = config.random_self_lookup_interval();
584            assert!(sl >= config.self_lookup_interval_min);
585            assert!(sl <= config.self_lookup_interval_max);
586        }
587    }
588
589    #[test]
590    fn random_interval_equal_bounds_is_deterministic() {
591        let fixed = Duration::from_secs(42);
592        let config = ReplicationConfig {
593            neighbor_sync_interval_min: fixed,
594            neighbor_sync_interval_max: fixed,
595            ..ReplicationConfig::default()
596        };
597        assert_eq!(config.random_neighbor_sync_interval(), fixed);
598    }
599
600    // -----------------------------------------------------------------------
601    // Section 18 scenarios
602    // -----------------------------------------------------------------------
603
604    /// Scenario 18: Invalid runtime config is rejected by `validate()`.
605    #[test]
606    fn scenario_18_invalid_config_rejected() {
607        // quorum_threshold > close_group_size -> validation fails.
608        let config = ReplicationConfig {
609            quorum_threshold: 10,
610            close_group_size: 7,
611            ..ReplicationConfig::default()
612        };
613        let err = config.validate().unwrap_err();
614        assert!(
615            err.contains("quorum_threshold"),
616            "error should mention quorum_threshold: {err}"
617        );
618
619        // close_group_size = 0 -> validation fails.
620        let config = ReplicationConfig {
621            close_group_size: 0,
622            ..ReplicationConfig::default()
623        };
624        let err = config.validate().unwrap_err();
625        assert!(
626            err.contains("close_group_size"),
627            "error should mention close_group_size: {err}"
628        );
629
630        // neighbor_sync interval min > max -> validation fails.
631        let config = ReplicationConfig {
632            neighbor_sync_interval_min: Duration::from_secs(200),
633            neighbor_sync_interval_max: Duration::from_secs(100),
634            ..ReplicationConfig::default()
635        };
636        let err = config.validate().unwrap_err();
637        assert!(
638            err.contains("neighbor_sync_interval"),
639            "error should mention neighbor_sync_interval: {err}"
640        );
641
642        // self_lookup interval min > max -> validation fails.
643        let config = ReplicationConfig {
644            self_lookup_interval_min: Duration::from_secs(999),
645            self_lookup_interval_max: Duration::from_secs(1),
646            ..ReplicationConfig::default()
647        };
648        let err = config.validate().unwrap_err();
649        assert!(
650            err.contains("self_lookup_interval"),
651            "error should mention self_lookup_interval: {err}"
652        );
653
654        // audit_tick interval min > max -> validation fails.
655        let config = ReplicationConfig {
656            audit_tick_interval_min: Duration::from_secs(500),
657            audit_tick_interval_max: Duration::from_secs(10),
658            ..ReplicationConfig::default()
659        };
660        let err = config.validate().unwrap_err();
661        assert!(
662            err.contains("audit_tick_interval"),
663            "error should mention audit_tick_interval: {err}"
664        );
665    }
666
667    /// Scenario 26: Dynamic paid-list threshold for undersized set.
668    /// With PaidGroupSize=8, `ConfirmNeeded` = floor(8/2)+1 = 5.
669    #[test]
670    fn scenario_26_dynamic_paid_threshold_undersized() {
671        assert_eq!(ReplicationConfig::confirm_needed(8), 5, "floor(8/2)+1 = 5");
672
673        // Additional boundary checks for small paid groups.
674        assert_eq!(
675            ReplicationConfig::confirm_needed(1),
676            1,
677            "single peer requires 1 confirmation"
678        );
679        assert_eq!(
680            ReplicationConfig::confirm_needed(2),
681            2,
682            "2 peers require 2 confirmations"
683        );
684        assert_eq!(
685            ReplicationConfig::confirm_needed(3),
686            2,
687            "3 peers require 2 confirmations"
688        );
689        assert_eq!(
690            ReplicationConfig::confirm_needed(0),
691            1,
692            "0 peers yields floor(0/2)+1 = 1 (degenerate case)"
693        );
694    }
695
696    /// Scenario 31: Consecutive audit ticks occur on randomized intervals
697    /// bounded by the configured `[audit_tick_interval_min, audit_tick_interval_max]`
698    /// window.
699    #[test]
700    fn scenario_31_audit_cadence_within_jitter_bounds() {
701        let config = ReplicationConfig {
702            audit_tick_interval_min: Duration::from_secs(600),
703            audit_tick_interval_max: Duration::from_secs(1200),
704            ..ReplicationConfig::default()
705        };
706
707        // Sample many intervals and verify each is within bounds.
708        let iterations = 100;
709        let mut saw_different = false;
710        let mut prev = Duration::ZERO;
711
712        for _ in 0..iterations {
713            let interval = config.random_audit_tick_interval();
714            assert!(
715                interval >= config.audit_tick_interval_min,
716                "interval {interval:?} below min {:?}",
717                config.audit_tick_interval_min,
718            );
719            assert!(
720                interval <= config.audit_tick_interval_max,
721                "interval {interval:?} above max {:?}",
722                config.audit_tick_interval_max,
723            );
724            if interval != prev && prev != Duration::ZERO {
725                saw_different = true;
726            }
727            prev = interval;
728        }
729
730        // With 100 samples from a 10-minute range, at least two should differ
731        // (probabilistically near-certain).
732        assert!(
733            saw_different,
734            "audit intervals should exhibit randomized jitter across samples"
735        );
736    }
737}