Skip to main content

optirs_core/privacy/federated_privacy/
components.rs

1// Component implementations for federated privacy algorithms
2//
3// # 0.3.2 changes
4//
5// * Public field names were corrected to snake_case: `clientid` -> `client_id`,
6//   `epsilonconsumed` -> `epsilon_consumed`, `amplificationfactor` ->
7//   `amplification_factor`, `compressionratio` -> `compression_ratio`,
8//   `significancelevel` -> `significance_level`. These are breaking renames on a
9//   public API; the project's naming policy mandates snake_case and the 0.3.x
10//   series is the place to fix them.
11// * `RoundComposition` gained `total_clients` and `noise_multiplier`, without
12//   which `FederatedCompositionAnalyzer` cannot compose through the moments
13//   accountant.
14// * `FederatedMetaLearner::new` honours its argument (it was `_parametersize`).
15// * The real method bodies for `PrivacyAmplificationAnalyzer`,
16//   `FederatedCompositionAnalyzer`, `FederatedMetaLearner` and `TaskDetector`
17//   live in the `composition` and `adaptation` modules; this file keeps the type
18//   definitions and constructors.
19
20use super::config::*;
21use crate::error::Result;
22use scirs2_core::ndarray::Array1;
23use scirs2_core::numeric::Float;
24use std::collections::{HashMap, VecDeque};
25use std::fmt::Debug;
26
27/// Byzantine-robust aggregation, re-exported from the audited implementation in
28/// [`crate::privacy::federated::byzantine_aggregation`].
29///
30/// Until 0.3.2 this module declared a second, field-identical
31/// `ByzantineRobustAggregator` whose `client_reputations` and
32/// `robust_estimators` were written once and never read, with its methods
33/// supplied by an `impl` block in `coordinator.rs` that returned `Ok(0.9)` for
34/// the robustness factor and an empty map for the reputations. The real engine
35/// implements trimmed mean, coordinate-wise median, Krum, Multi-Krum, Bulyan,
36/// centered clipping, reputation weighting and the outlier tests, and returns an
37/// error rather than a plain mean when a method cannot be applied.
38pub use super::super::federated::byzantine_aggregation::{
39    AdaptivePrivacyAllocation, ByzantineRobustAggregator, OutlierDetectionResult, RobustEstimators,
40    StatisticalAnalyzer, TestStatistic,
41};
42
43/// Cross-device privacy management, re-exported from
44/// [`crate::privacy::federated::cross_device_manager`].
45///
46/// The copy this replaces stored `device_profiles` and `temporal_correlations`
47/// that nothing read, so no user-level or temporal-correlation accounting
48/// happened at all. The real manager tracks per-subject epsilon budgets across a
49/// participation window.
50pub use super::super::federated::cross_device_manager::{
51    CrossDevicePrivacyManager, DeviceProfile, DeviceType, TemporalEvent, TemporalEventType,
52};
53
54// Advanced federated learning implementation structures
55
56/// Personalized federated learning manager
57pub struct PersonalizationManager<T: Float + Debug + Send + Sync + 'static> {
58    config: PersonalizationConfig,
59    client_models: HashMap<String, PersonalizedModel<T>>,
60    global_model: Option<Array1<T>>,
61    meta_learner: FederatedMetaLearner<T>,
62}
63
64/// Adaptive privacy budget manager
65pub struct AdaptiveBudgetManager<T: Float + Debug + Send + Sync + 'static> {
66    config: AdaptiveBudgetConfig,
67    client_budgets: HashMap<String, AdaptiveBudget>,
68    fairness_monitor: FairnessMonitor,
69    _phantom: std::marker::PhantomData<T>,
70}
71
72/// Continual learning coordinator
73pub struct ContinualLearningCoordinator<T: Float + Debug + Send + Sync + 'static> {
74    config: ContinualLearningConfig,
75    task_detector: TaskDetector<T>,
76    task_history: VecDeque<TaskInfo>,
77}
78
79// Supporting implementation structures
80
81/// Personalized model for each client
82#[derive(Debug, Clone)]
83pub struct PersonalizedModel<T: Float + Debug + Send + Sync + 'static> {
84    pub model_parameters: Array1<T>,
85    pub personal_layers: HashMap<usize, Array1<T>>,
86    pub adaptation_state: AdaptationState<T>,
87    pub performance_history: Vec<f64>,
88    pub last_update_round: usize,
89}
90
91/// Adaptation state for personalized models
92#[derive(Debug, Clone)]
93pub struct AdaptationState<T: Float + Debug + Send + Sync + 'static> {
94    pub learning_rate: f64,
95    pub momentum: Array1<T>,
96    pub adaptation_count: usize,
97    pub gradient_history: VecDeque<Array1<T>>,
98}
99
100/// Federated meta-learner
101pub struct FederatedMetaLearner<T: Float + Debug + Send + Sync + 'static> {
102    pub(super) meta_parameters: Array1<T>,
103    pub(super) client_adaptations: HashMap<String, Array1<T>>,
104    pub(super) meta_gradient_buffer: Array1<T>,
105    pub(super) task_distributions: HashMap<String, TaskDistribution<T>>,
106}
107
108/// Task distribution for meta-learning
109#[derive(Debug, Clone)]
110pub struct TaskDistribution<T: Float + Debug + Send + Sync + 'static> {
111    pub support_gradient: Array1<T>,
112    pub query_gradient: Array1<T>,
113    pub task_similarity: f64,
114    pub adaptation_steps: usize,
115}
116
117/// Adaptive budget for each client
118#[derive(Debug, Clone)]
119pub struct AdaptiveBudget {
120    pub current_epsilon: f64,
121    pub current_delta: f64,
122    pub allocated_epsilon: f64,
123    pub allocated_delta: f64,
124    pub consumption_rate: f64,
125    pub importance_weight: f64,
126    pub context_factors: HashMap<String, f64>,
127}
128
129/// Fairness monitor
130pub struct FairnessMonitor {
131    fairness_metrics: FairnessMetrics,
132    client_fairness_scores: HashMap<String, f64>,
133}
134
135/// Fairness metrics
136#[derive(Debug, Clone)]
137pub struct FairnessMetrics {
138    pub demographic_parity: f64,
139    pub equalized_opportunity: f64,
140    pub individual_fairness: f64,
141    pub group_fairness: f64,
142}
143
144/// Task detector for continual learning
145pub struct TaskDetector<T: Float + Debug + Send + Sync + 'static> {
146    pub(super) detection_method: TaskDetectionMethod,
147    pub(super) gradient_buffer: VecDeque<Array1<T>>,
148    pub(super) change_points: Vec<ChangePoint>,
149    pub(super) detection_threshold: f64,
150}
151
152/// Change point for task detection
153#[derive(Debug, Clone)]
154pub struct ChangePoint {
155    pub round: usize,
156    pub confidence: f64,
157    pub change_magnitude: f64,
158}
159
160/// Task information
161#[derive(Debug, Clone)]
162pub struct TaskInfo {
163    pub task_id: usize,
164    pub start_round: usize,
165    pub end_round: Option<usize>,
166    pub task_description: String,
167    pub performance_metrics: HashMap<String, f64>,
168}
169
170/// Secure aggregation protocol implementation.
171///
172/// Re-exported from [`crate::privacy::federated::secure_aggregation`]. Until
173/// 0.3.2 this module declared a second `SecureAggregator<T>` whose entire state
174/// -- `client_masks`, a mutex-guarded `shared_randomness` counter and
175/// `round_keys` -- was written once at construction and never read, and whose
176/// `aggregate_with_masks` was a plaintext mean. Two types with the same name,
177/// one of which only pretended to mask, is exactly how a caller ends up
178/// believing an unmasked mean is confidential. The pretender is gone.
179pub use super::super::federated::secure_aggregation::SecureAggregator;
180
181/// Privacy amplification analyzer
182pub struct PrivacyAmplificationAnalyzer {
183    pub(super) config: AmplificationConfig,
184    pub(super) subsampling_history: VecDeque<SubsamplingEvent>,
185    pub(super) amplification_factors: HashMap<String, f64>,
186}
187
188/// Federated composition analyzer
189pub struct FederatedCompositionAnalyzer {
190    pub(super) method: FederatedCompositionMethod,
191    pub(super) round_compositions: Vec<RoundComposition>,
192    pub(super) client_compositions: HashMap<String, Vec<ClientComposition>>,
193}
194
195/// Client participation in a round
196#[derive(Debug, Clone)]
197pub struct ParticipationRound {
198    pub round: usize,
199    pub participating_clients: Vec<String>,
200    pub sampling_probability: f64,
201    pub privacy_cost: PrivacyCost,
202    pub aggregation_noise: f64,
203}
204
205/// Privacy cost breakdown
206#[derive(Debug, Clone)]
207pub struct PrivacyCost {
208    pub epsilon: f64,
209    pub delta: f64,
210    pub client_contribution: f64,
211    pub amplification_factor: f64,
212    pub composition_cost: f64,
213}
214
215/// Subsampling event for amplification analysis
216#[derive(Debug, Clone)]
217pub struct SubsamplingEvent {
218    pub round: usize,
219    pub sampling_rate: f64,
220    pub clients_sampled: usize,
221    pub total_clients: usize,
222    pub amplification_factor: f64,
223}
224
225/// Round composition for privacy accounting
226#[derive(Debug, Clone)]
227pub struct RoundComposition {
228    /// Round number.
229    pub round: usize,
230    /// Clients that participated in this round.
231    pub participating_clients: usize,
232    /// Size of the federation the round sampled from.
233    ///
234    /// Added in 0.3.2: the moments-accountant composition needs the sampling
235    /// rate, and the previous shape could only report the numerator.
236    pub total_clients: usize,
237    /// Epsilon consumed by the round.
238    pub epsilon_consumed: f64,
239    /// Delta the round's epsilon is reported at.
240    pub delta_consumed: f64,
241    /// Whether the subsampling amplification bound was applied.
242    pub amplification_applied: bool,
243    /// Composition method the round was accounted under.
244    pub composition_method: FederatedCompositionMethod,
245    /// Noise multiplier used by the round, when the round was a Gaussian
246    /// mechanism. Required by
247    /// `FederatedCompositionMethod::FederatedMomentsAccountant`.
248    pub noise_multiplier: Option<f64>,
249}
250
251/// Client-specific composition tracking
252#[derive(Debug, Clone)]
253pub struct ClientComposition {
254    pub client_id: String,
255    pub round: usize,
256    pub local_epsilon: f64,
257    pub local_delta: f64,
258    pub contribution_weight: f64,
259}
260
261// Implementation blocks for components
262
263impl FairnessMonitor {
264    /// Create a new fairness monitor
265    pub fn new() -> Self {
266        Self {
267            fairness_metrics: FairnessMetrics {
268                demographic_parity: 0.0,
269                equalized_opportunity: 0.0,
270                individual_fairness: 0.0,
271                group_fairness: 0.0,
272            },
273            client_fairness_scores: HashMap::new(),
274        }
275    }
276
277    /// Record a client's fairness score, which
278    /// [`Self::compute_fairness_weights`] turns into a selection weight.
279    ///
280    /// Without this the score map could never be populated and every client's
281    /// weight was unconditionally `1.0`.
282    pub fn set_client_score(&mut self, client_id: String, score: f64) -> Result<()> {
283        if !score.is_finite() || score < 0.0 {
284            return Err(crate::error::OptimError::InvalidParameter(format!(
285                "a fairness score must be non-negative and finite, got {score}"
286            )));
287        }
288        self.client_fairness_scores.insert(client_id, score);
289        Ok(())
290    }
291
292    /// Get current fairness metrics
293    pub fn get_metrics(&self) -> &FairnessMetrics {
294        &self.fairness_metrics
295    }
296
297    /// Compute fairness weights for clients
298    pub fn compute_fairness_weights(&self, client_ids: &[String]) -> HashMap<String, f64> {
299        let mut weights = HashMap::new();
300        for client_id in client_ids {
301            // Use existing fairness score or default to 1.0
302            let weight = self
303                .client_fairness_scores
304                .get(client_id)
305                .copied()
306                .unwrap_or(1.0);
307            weights.insert(client_id.clone(), weight);
308        }
309        weights
310    }
311}
312
313impl Default for FairnessMonitor {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319impl<
320        T: Float
321            + Debug
322            + Send
323            + Sync
324            + 'static
325            + Default
326            + Clone
327            + scirs2_core::ndarray::ScalarOperand,
328    > FederatedMetaLearner<T>
329{
330    /// Create a new federated meta-learner sized for `parameter_size`
331    /// parameters.
332    ///
333    /// The argument used to be `_parametersize` and was discarded, so both
334    /// buffers were allocated as `Array1::default(0)`: a caller constructing for
335    /// a one-million-parameter model got empty buffers and every later
336    /// elementwise operation was a length mismatch. `0` remains legal and means
337    /// "not yet sized"; `compute_client_meta_gradients` (in
338    /// [`super::adaptation`]) reports that rather than returning a length-0
339    /// array.
340    pub fn new(parameter_size: usize) -> Self {
341        Self {
342            meta_parameters: Array1::zeros(parameter_size),
343            client_adaptations: HashMap::new(),
344            meta_gradient_buffer: Array1::zeros(parameter_size),
345            task_distributions: HashMap::new(),
346        }
347    }
348
349    /// Number of parameters this learner is sized for.
350    pub fn parameter_size(&self) -> usize {
351        self.meta_parameters.len()
352    }
353
354    /// The current meta-parameters.
355    pub fn meta_parameters(&self) -> &Array1<T> {
356        &self.meta_parameters
357    }
358
359    /// The most recently computed meta-gradient.
360    pub fn meta_gradient_buffer(&self) -> &Array1<T> {
361        &self.meta_gradient_buffer
362    }
363
364    /// The per-client adaptation recorded for `client_id`, if any.
365    pub fn client_adaptation(&self, client_id: &str) -> Option<&Array1<T>> {
366        self.client_adaptations.get(client_id)
367    }
368
369    /// The task distribution recorded for `client_id`, if any.
370    pub fn task_distribution(&self, client_id: &str) -> Option<&TaskDistribution<T>> {
371        self.task_distributions.get(client_id)
372    }
373}
374
375impl<T: Float + Debug + Send + Sync + 'static> TaskDetector<T> {
376    /// Create a new task detector
377    pub fn new() -> Self {
378        Self {
379            detection_method: TaskDetectionMethod::GradientBased,
380            gradient_buffer: VecDeque::with_capacity(100),
381            change_points: Vec::new(),
382            detection_threshold: 0.1,
383        }
384    }
385
386    /// Detection threshold on the normalised gradient shift.
387    pub fn detection_threshold(&self) -> f64 {
388        self.detection_threshold
389    }
390
391    /// Replace the detection threshold.
392    pub fn set_detection_threshold(&mut self, threshold: f64) -> Result<()> {
393        if !threshold.is_finite() || threshold <= 0.0 {
394            return Err(crate::error::OptimError::InvalidParameter(format!(
395                "the task-detection threshold must be positive and finite, got {threshold}"
396            )));
397        }
398        self.detection_threshold = threshold;
399        Ok(())
400    }
401
402    /// The configured detection method.
403    pub fn detection_method(&self) -> TaskDetectionMethod {
404        self.detection_method
405    }
406
407    /// Change points detected so far.
408    pub fn change_points(&self) -> &[ChangePoint] {
409        &self.change_points
410    }
411}
412
413impl<T: Float + Debug + Send + Sync + 'static> Default for TaskDetector<T> {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419// Default implementations for component creation
420
421impl<
422        T: Float
423            + Debug
424            + Send
425            + Sync
426            + 'static
427            + Default
428            + Clone
429            + scirs2_core::ndarray::ScalarOperand,
430    > PersonalizationManager<T>
431{
432    /// Create a manager with personalization disabled.
433    ///
434    /// Retained for compatibility; prefer
435    /// [`PersonalizationManager::with_config`], which is the only way a
436    /// configured strategy can reach runtime.
437    pub fn new() -> Result<Self> {
438        Self::with_config(
439            PersonalizationConfig {
440                strategy: PersonalizationStrategy::None,
441                local_adaptation: LocalAdaptationConfig::default(),
442                clustering: ClusteringConfig::default(),
443                meta_learning: MetaLearningConfig::default(),
444                privacy_preserving: false,
445            },
446            0,
447        )
448    }
449
450    /// Create a manager from a configuration, sized for `parameter_size`
451    /// parameters.
452    pub fn with_config(config: PersonalizationConfig, parameter_size: usize) -> Result<Self> {
453        Ok(Self {
454            config,
455            client_models: HashMap::new(),
456            global_model: None,
457            meta_learner: FederatedMetaLearner::new(parameter_size),
458        })
459    }
460
461    /// The configuration this manager is running under.
462    pub fn config(&self) -> &PersonalizationConfig {
463        &self.config
464    }
465
466    /// The meta-learner backing the personalization strategy.
467    pub fn meta_learner(&self) -> &FederatedMetaLearner<T> {
468        &self.meta_learner
469    }
470
471    /// Mutable access to the meta-learner.
472    pub fn meta_learner_mut(&mut self) -> &mut FederatedMetaLearner<T> {
473        &mut self.meta_learner
474    }
475
476    /// Apply an aggregated client update to the global model and return it.
477    ///
478    /// # Semantics
479    ///
480    /// `aggregated_update` is the cohort's *delta* (the FedAvg convention: each
481    /// client uploads `local_params - global_params`, the server averages them).
482    /// The global model therefore advances by `global += aggregated_update`. On
483    /// the first call the manager holds no global model yet, so the update *is*
484    /// the model.
485    ///
486    /// Before 0.3.2 this function was `Ok(aggregate.clone())` -- it returned the
487    /// caller's own input, never touched `global_model`, and so the field was
488    /// written once at construction and never read. A federation driving its
489    /// global model through this function stayed at round one forever while the
490    /// return value made it look as though every round had been applied.
491    ///
492    /// # Errors
493    ///
494    /// [`crate::error::OptimError::DimensionMismatch`] if the update's length differs from the
495    /// stored global model's, since silently zero-extending or truncating would
496    /// corrupt the model. [`crate::error::OptimError::InvalidParameter`] for an empty update.
497    pub fn update_global_model(&mut self, aggregated_update: &Array1<T>) -> Result<Array1<T>> {
498        if aggregated_update.is_empty() {
499            return Err(crate::error::OptimError::InvalidParameter(
500                "the aggregated update is empty; there is nothing to apply".to_string(),
501            ));
502        }
503        match self.global_model.as_mut() {
504            Some(model) => {
505                if model.len() != aggregated_update.len() {
506                    return Err(crate::error::OptimError::DimensionMismatch(format!(
507                        "the aggregated update has {} coordinates but the global model has {}",
508                        aggregated_update.len(),
509                        model.len()
510                    )));
511                }
512                for (slot, &delta) in model.iter_mut().zip(aggregated_update.iter()) {
513                    *slot = *slot + delta;
514                }
515                Ok(model.clone())
516            }
517            None => {
518                self.global_model = Some(aggregated_update.clone());
519                Ok(aggregated_update.clone())
520            }
521        }
522    }
523
524    /// The current global model, or `None` before the first
525    /// [`Self::update_global_model`].
526    pub fn global_model(&self) -> Option<&Array1<T>> {
527        self.global_model.as_ref()
528    }
529
530    /// Record a client's personalized model.
531    ///
532    /// Personalization strategies keep a per-client model alongside the global
533    /// one; without this the `client_models` map could never be populated.
534    pub fn set_client_model(&mut self, client_id: String, model: PersonalizedModel<T>) {
535        self.client_models.insert(client_id, model);
536    }
537
538    /// The personalized model recorded for `client_id`, if any.
539    pub fn client_model(&self, client_id: &str) -> Option<&PersonalizedModel<T>> {
540        self.client_models.get(client_id)
541    }
542}
543
544impl<T: Float + Debug + Send + Sync + 'static> AdaptiveBudgetManager<T> {
545    /// Create a manager with the default (disabled) adaptive budget config.
546    ///
547    /// Retained for compatibility; prefer
548    /// [`AdaptiveBudgetManager::with_config`].
549    pub fn new() -> Result<Self> {
550        Self::with_config(AdaptiveBudgetConfig::default())
551    }
552
553    /// Create a manager from a configuration.
554    pub fn with_config(config: AdaptiveBudgetConfig) -> Result<Self> {
555        Ok(Self {
556            config,
557            client_budgets: HashMap::new(),
558            fairness_monitor: FairnessMonitor::new(),
559            _phantom: std::marker::PhantomData,
560        })
561    }
562
563    /// The configuration this manager is running under.
564    pub fn config(&self) -> &AdaptiveBudgetConfig {
565        &self.config
566    }
567
568    /// The fairness monitor.
569    pub fn fairness_monitor(&self) -> &FairnessMonitor {
570        &self.fairness_monitor
571    }
572
573    /// The adaptive budget recorded for a client, if any.
574    pub fn client_budget(&self, client_id: &str) -> Option<&AdaptiveBudget> {
575        self.client_budgets.get(client_id)
576    }
577}
578
579impl<T: Float + Debug + Send + Sync + 'static + Default> ContinualLearningCoordinator<T> {
580    /// Create a coordinator with the task-agnostic strategy.
581    ///
582    /// Retained for compatibility; prefer
583    /// [`ContinualLearningCoordinator::with_config`].
584    pub fn new() -> Result<Self> {
585        Self::with_config(ContinualLearningConfig {
586            strategy: ContinualLearningStrategy::TaskAgnostic,
587            memory_management: MemoryManagementConfig::default(),
588            task_detection: TaskDetectionConfig::default(),
589            knowledge_transfer: KnowledgeTransferConfig::default(),
590            forgetting_prevention: ForgettingPreventionConfig::default(),
591        })
592    }
593
594    /// Create a coordinator from a configuration.
595    ///
596    /// The task detector is configured from `config.task_detection`, so the
597    /// configured detection method and threshold reach runtime instead of the
598    /// hardcoded `GradientBased` / `0.1` pair.
599    pub fn with_config(config: ContinualLearningConfig) -> Result<Self> {
600        let mut task_detector = TaskDetector::new();
601        task_detector.detection_method = config.task_detection.detection_method;
602        task_detector.set_detection_threshold(config.task_detection.sensitivity_threshold)?;
603        Ok(Self {
604            config,
605            task_detector,
606            task_history: VecDeque::new(),
607        })
608    }
609
610    /// The configuration this coordinator is running under.
611    pub fn config(&self) -> &ContinualLearningConfig {
612        &self.config
613    }
614
615    /// The task detector.
616    pub fn task_detector(&self) -> &TaskDetector<T> {
617        &self.task_detector
618    }
619
620    /// Mutable access to the task detector.
621    pub fn task_detector_mut(&mut self) -> &mut TaskDetector<T> {
622        &mut self.task_detector
623    }
624
625    /// Recorded task history, oldest first.
626    pub fn task_history(&self) -> impl Iterator<Item = &TaskInfo> {
627        self.task_history.iter()
628    }
629}
630
631impl PrivacyAmplificationAnalyzer {
632    /// Create a new privacy amplification analyzer.
633    ///
634    /// The analysis itself lives in [`super::composition`]:
635    /// `compute_amplification_factor` now evaluates the published subsampling
636    /// bound instead of the discarded `(1/q).sqrt()` placeholder, and records the
637    /// real client counts instead of a fabricated `total_clients: 1000`.
638    pub fn new(config: AmplificationConfig) -> Self {
639        Self {
640            config,
641            subsampling_history: VecDeque::new(),
642            amplification_factors: HashMap::new(),
643        }
644    }
645}
646
647impl FederatedCompositionAnalyzer {
648    /// Create a new federated composition analyzer
649    pub fn new(method: FederatedCompositionMethod) -> Self {
650        Self {
651            method,
652            round_compositions: Vec::new(),
653            client_compositions: HashMap::new(),
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::privacy::federated_privacy::config::{
662        AdaptiveBudgetConfig, ByzantineRobustConfig, ByzantineRobustMethod, ClusteringConfig,
663        ContinualLearningConfig, ContinualLearningStrategy, CrossDeviceConfig,
664        ForgettingPreventionConfig, KnowledgeTransferConfig, LocalAdaptationConfig,
665        MemoryManagementConfig, MetaLearningConfig, PersonalizationConfig, PersonalizationStrategy,
666        ReputationSystemConfig, SecureAggregationConfig, StatisticalTestConfig,
667        TaskDetectionConfig, TaskDetectionMethod,
668    };
669
670    fn byzantine_config(trim_ratio: f64, byzantine_ratio: f64) -> ByzantineRobustConfig {
671        ByzantineRobustConfig {
672            method: ByzantineRobustMethod::TrimmedMean { trim_ratio },
673            expected_byzantine_ratio: byzantine_ratio,
674            dynamic_detection: true,
675            reputation_system: ReputationSystemConfig::default(),
676            statistical_tests: StatisticalTestConfig::default(),
677        }
678    }
679
680    #[test]
681    fn the_byzantine_aggregator_takes_its_configuration() {
682        // Regression for F104: `new()` took no argument and hardcoded
683        // TrimmedMean{0.2} with expected_byzantine_ratio 0.2, so whatever the
684        // user configured could never reach the aggregator.
685        let aggregator =
686            match ByzantineRobustAggregator::<f64>::with_config(byzantine_config(0.35, 0.4)) {
687                Ok(aggregator) => aggregator,
688                Err(err) => panic!("construction failed: {err}"),
689            };
690        assert_eq!(aggregator.config().expected_byzantine_ratio, 0.4);
691        match aggregator.config().method {
692            ByzantineRobustMethod::TrimmedMean { trim_ratio } => {
693                assert!((trim_ratio - 0.35).abs() < 1e-12)
694            }
695            other => panic!("unexpected method {other:?}"),
696        }
697        assert!((aggregator.statistical_analyzer().significance_level() - 0.05).abs() < 1e-12);
698    }
699
700    #[test]
701    fn an_unusable_byzantine_configuration_is_refused() {
702        // `trim_ratio` is the *total* fraction removed, split evenly between the
703        // two tails, so 0.5 (25% per tail) is legal; 1.0 and above would remove
704        // everything.
705        for (trim, byzantine) in [
706            (1.0f64, 0.2f64),
707            (1.5, 0.2),
708            (-0.1, 0.2),
709            (0.2, 0.5),
710            (0.2, 1.0),
711        ] {
712            assert!(
713                ByzantineRobustAggregator::<f64>::with_config(byzantine_config(trim, byzantine))
714                    .is_err(),
715                "trim={trim}, byzantine={byzantine} must be refused"
716            );
717        }
718        let mut config = byzantine_config(0.2, 0.2);
719        config.statistical_tests.significancelevel = 1.0;
720        assert!(ByzantineRobustAggregator::<f64>::with_config(config).is_err());
721    }
722
723    /// The `SecureAggregator` name reachable from this module must resolve to
724    /// the audited Bonawitz implementation, not to a re-introduced shell: the
725    /// shell stored a `client_masks` map on the *server*, which is the opposite
726    /// of secure aggregation.
727    #[test]
728    fn the_secure_aggregator_is_the_audited_implementation() {
729        let config = SecureAggregationConfig {
730            min_clients: 25,
731            max_dropouts: 4,
732            ..SecureAggregationConfig::default()
733        };
734        let aggregator = match SecureAggregator::<f64>::new(config) {
735            Ok(aggregator) => aggregator,
736            Err(err) => panic!("construction failed: {err}"),
737        };
738        assert_eq!(aggregator.aggregation_threshold(), 25);
739        assert_eq!(aggregator.config().min_clients, 25);
740        // Only the real implementation exposes the round/key-registration state
741        // machine; the shell had no notion of a plan or of client keys.
742        assert_eq!(aggregator.rounds_prepared(), 0);
743        assert!(aggregator.current_plan().is_none());
744        assert!(aggregator.modulus() > 0);
745
746        for min_clients in [0usize, 1] {
747            let config = SecureAggregationConfig {
748                min_clients,
749                max_dropouts: 0,
750                ..SecureAggregationConfig::default()
751            };
752            assert!(
753                SecureAggregator::<f64>::new(config).is_err(),
754                "min_clients={min_clients} must be refused"
755            );
756        }
757    }
758
759    #[test]
760    fn the_personalization_manager_takes_its_strategy_and_size() {
761        let config = PersonalizationConfig {
762            strategy: PersonalizationStrategy::MetaLearning {
763                inner_lr: 0.01,
764                outer_lr: 0.001,
765            },
766            local_adaptation: LocalAdaptationConfig::default(),
767            clustering: ClusteringConfig::default(),
768            meta_learning: MetaLearningConfig::default(),
769            privacy_preserving: true,
770        };
771        let manager = match PersonalizationManager::<f64>::with_config(config, 128) {
772            Ok(manager) => manager,
773            Err(err) => panic!("construction failed: {err}"),
774        };
775        assert!(matches!(
776            manager.config().strategy,
777            PersonalizationStrategy::MetaLearning { .. }
778        ));
779        assert!(manager.config().privacy_preserving);
780        assert_eq!(
781            manager.meta_learner().parameter_size(),
782            128,
783            "the meta-learner must be sized for the model, not left at 0"
784        );
785
786        // The compatibility constructor keeps the historical behaviour.
787        let default_manager = match PersonalizationManager::<f64>::new() {
788            Ok(manager) => manager,
789            Err(err) => panic!("construction failed: {err}"),
790        };
791        assert!(matches!(
792            default_manager.config().strategy,
793            PersonalizationStrategy::None
794        ));
795    }
796
797    #[test]
798    fn the_continual_learning_coordinator_configures_its_detector() {
799        let config = ContinualLearningConfig {
800            strategy: ContinualLearningStrategy::EWC { lambda: 0.5 },
801            memory_management: MemoryManagementConfig::default(),
802            task_detection: TaskDetectionConfig {
803                enabled: true,
804                detection_method: TaskDetectionMethod::GradientBased,
805                sensitivity_threshold: 0.42,
806                adaptation_delay: 3,
807            },
808            knowledge_transfer: KnowledgeTransferConfig::default(),
809            forgetting_prevention: ForgettingPreventionConfig::default(),
810        };
811        let coordinator = match ContinualLearningCoordinator::<f64>::with_config(config) {
812            Ok(coordinator) => coordinator,
813            Err(err) => panic!("construction failed: {err}"),
814        };
815        assert!(matches!(
816            coordinator.config().strategy,
817            ContinualLearningStrategy::EWC { .. }
818        ));
819        assert!(
820            (coordinator.task_detector().detection_threshold() - 0.42).abs() < 1e-12,
821            "the configured sensitivity must reach the detector, got {}",
822            coordinator.task_detector().detection_threshold()
823        );
824        assert!(coordinator.task_history().next().is_none());
825    }
826
827    #[test]
828    fn an_unusable_task_detection_threshold_is_refused() {
829        let config = ContinualLearningConfig {
830            strategy: ContinualLearningStrategy::TaskAgnostic,
831            memory_management: MemoryManagementConfig::default(),
832            task_detection: TaskDetectionConfig {
833                enabled: true,
834                detection_method: TaskDetectionMethod::GradientBased,
835                sensitivity_threshold: 0.0,
836                adaptation_delay: 1,
837            },
838            knowledge_transfer: KnowledgeTransferConfig::default(),
839            forgetting_prevention: ForgettingPreventionConfig::default(),
840        };
841        assert!(ContinualLearningCoordinator::<f64>::with_config(config).is_err());
842    }
843
844    #[test]
845    fn the_adaptive_budget_manager_takes_its_configuration() {
846        let config = AdaptiveBudgetConfig {
847            enabled: true,
848            ..AdaptiveBudgetConfig::default()
849        };
850        let manager = match AdaptiveBudgetManager::<f64>::with_config(config) {
851            Ok(manager) => manager,
852            Err(err) => panic!("construction failed: {err}"),
853        };
854        assert!(
855            manager.config().enabled,
856            "the configured `enabled` flag must reach the manager"
857        );
858        assert!(manager.client_budget("nobody").is_none());
859
860        let default_manager = match AdaptiveBudgetManager::<f64>::new() {
861            Ok(manager) => manager,
862            Err(err) => panic!("construction failed: {err}"),
863        };
864        assert!(!default_manager.config().enabled);
865    }
866
867    #[test]
868    fn the_cross_device_manager_exposes_its_configuration() {
869        let manager = CrossDevicePrivacyManager::<f64>::new(CrossDeviceConfig::default());
870        assert!(!manager.config().user_level_privacy);
871        assert!(manager.get_user_cluster("nobody").is_none());
872        assert_eq!(manager.device_count(), 0);
873    }
874
875    #[test]
876    fn fairness_weights_default_to_one_for_unknown_clients() {
877        let monitor = FairnessMonitor::new();
878        let weights = monitor.compute_fairness_weights(&["a".to_string(), "b".to_string()]);
879        assert_eq!(weights.len(), 2);
880        assert!(weights.values().all(|weight| (*weight - 1.0).abs() < 1e-12));
881        assert_eq!(monitor.get_metrics().demographic_parity, 0.0);
882    }
883}