use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TerrainFeature {
Slope,
Roughness,
DustDepth,
BoulderDensity,
CraterProximity,
SolarExposure,
ThermalGradient,
RegolithCompaction,
}
impl TerrainFeature {
pub const ALL: [TerrainFeature; 8] = [
TerrainFeature::Slope,
TerrainFeature::Roughness,
TerrainFeature::DustDepth,
TerrainFeature::BoulderDensity,
TerrainFeature::CraterProximity,
TerrainFeature::SolarExposure,
TerrainFeature::ThermalGradient,
TerrainFeature::RegolithCompaction,
];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainObservation {
pub location: (f64, f64),
pub features: Vec<(TerrainFeature, f64)>,
pub traversability_score: f64,
pub observer_id: String,
pub confidence: f64,
pub timestamp_us: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraversabilityModel {
pub model_id: String,
pub version: u32,
pub feature_weights: Vec<(TerrainFeature, f64)>,
pub bias: f64,
pub training_samples: u64,
pub accuracy: f64,
pub last_updated_us: u64,
}
impl TraversabilityModel {
pub fn new(model_id: String) -> Self {
Self {
model_id,
version: 0,
feature_weights: TerrainFeature::ALL.iter().map(|&f| (f, 0.0)).collect(),
bias: 0.0,
training_samples: 0,
accuracy: 0.0,
last_updated_us: 0,
}
}
pub fn predict(&self, features: &[(TerrainFeature, f64)]) -> f64 {
let weight_map: HashMap<TerrainFeature, f64> =
self.feature_weights.iter().copied().collect();
let sum: f64 = features
.iter()
.map(|(feat, val)| weight_map.get(feat).copied().unwrap_or(0.0) * val)
.sum();
(sum + self.bias).clamp(0.0, 1.0)
}
pub fn update_weights(
&mut self,
new_weights: &[(TerrainFeature, f64)],
new_bias: f64,
new_samples: u64,
) {
self.feature_weights = new_weights.to_vec();
self.bias = new_bias;
self.training_samples += new_samples;
self.version += 1;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradientUpdate {
pub model_id: String,
pub source_agent: String,
pub feature_gradients: Vec<(TerrainFeature, f64)>,
pub bias_gradient: f64,
pub n_samples: u64,
pub timestamp_us: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregatedGradient {
pub feature_gradients: Vec<(TerrainFeature, f64)>,
pub bias_gradient: f64,
pub n_contributors: usize,
pub n_rejected: usize,
}
#[derive(Debug, Clone)]
pub struct FederatedAggregator {
pending_gradients: Vec<GradientUpdate>,
pub min_updates_for_round: usize,
pub byzantine_threshold: f64,
}
impl FederatedAggregator {
pub fn new() -> Self {
Self {
pending_gradients: Vec::new(),
min_updates_for_round: 2,
byzantine_threshold: 3.0,
}
}
pub fn submit_gradient(&mut self, update: GradientUpdate) {
self.pending_gradients.push(update);
}
pub fn aggregate(&mut self) -> Option<AggregatedGradient> {
if self.pending_gradients.len() < self.min_updates_for_round {
return None;
}
let all_features: Vec<TerrainFeature> = TerrainFeature::ALL.to_vec();
let n = self.pending_gradients.len();
let mut feature_vecs: HashMap<TerrainFeature, Vec<f64>> = HashMap::new();
let mut bias_vec: Vec<f64> = Vec::with_capacity(n);
for feat in &all_features {
feature_vecs.insert(*feat, Vec::with_capacity(n));
}
for update in &self.pending_gradients {
let grad_map: HashMap<TerrainFeature, f64> =
update.feature_gradients.iter().copied().collect();
for feat in &all_features {
feature_vecs
.get_mut(feat)
.unwrap()
.push(grad_map.get(feat).copied().unwrap_or(0.0));
}
bias_vec.push(update.bias_gradient);
}
let medians: HashMap<TerrainFeature, f64> = feature_vecs
.iter()
.map(|(&feat, vals)| (feat, median_of(vals)))
.collect();
let bias_median = median_of(&bias_vec);
let std_devs: HashMap<TerrainFeature, f64> = feature_vecs
.iter()
.map(|(&feat, vals)| {
let med = medians[&feat];
let mut abs_devs: Vec<f64> = vals.iter().map(|v| (v - med).abs()).collect();
abs_devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let mad = median_of(&abs_devs);
(feat, mad * 1.4826) })
.collect();
let bias_std = {
let mut abs_devs: Vec<f64> = bias_vec.iter().map(|v| (v - bias_median).abs()).collect();
abs_devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
median_of(&abs_devs) * 1.4826
};
let mut rejected = vec![false; n];
for (i, update) in self.pending_gradients.iter().enumerate() {
let grad_map: HashMap<TerrainFeature, f64> =
update.feature_gradients.iter().copied().collect();
for feat in &all_features {
let val = grad_map.get(feat).copied().unwrap_or(0.0);
let med = medians[&feat];
let sd = std_devs[&feat];
if sd > 1e-12 && (val - med).abs() > self.byzantine_threshold * sd {
rejected[i] = true;
break;
}
}
if !rejected[i] && bias_std > 1e-12 {
if (update.bias_gradient - bias_median).abs() > self.byzantine_threshold * bias_std
{
rejected[i] = true;
}
}
}
let n_rejected = rejected.iter().filter(|&&r| r).count();
let mut clean_feature_vecs: HashMap<TerrainFeature, Vec<f64>> = HashMap::new();
let mut clean_bias_vec: Vec<f64> = Vec::new();
for feat in &all_features {
clean_feature_vecs.insert(*feat, Vec::new());
}
for (i, update) in self.pending_gradients.iter().enumerate() {
if rejected[i] {
continue;
}
let grad_map: HashMap<TerrainFeature, f64> =
update.feature_gradients.iter().copied().collect();
for feat in &all_features {
clean_feature_vecs
.get_mut(feat)
.unwrap()
.push(grad_map.get(feat).copied().unwrap_or(0.0));
}
clean_bias_vec.push(update.bias_gradient);
}
let final_gradients: Vec<(TerrainFeature, f64)> = if clean_bias_vec.is_empty() {
all_features.iter().map(|&f| (f, medians[&f])).collect()
} else {
all_features
.iter()
.map(|&f| (f, median_of(clean_feature_vecs.get(&f).unwrap())))
.collect()
};
let final_bias = if clean_bias_vec.is_empty() {
bias_median
} else {
median_of(&clean_bias_vec)
};
Some(AggregatedGradient {
feature_gradients: final_gradients,
bias_gradient: final_bias,
n_contributors: n - n_rejected,
n_rejected,
})
}
pub fn clear(&mut self) {
self.pending_gradients.clear();
}
pub fn pending_count(&self) -> usize {
self.pending_gradients.len()
}
}
impl Default for FederatedAggregator {
fn default() -> Self {
Self::new()
}
}
fn median_of(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = sorted.len() / 2;
if sorted.len() % 2 == 0 {
(sorted[mid - 1] + sorted[mid]) / 2.0
} else {
sorted[mid]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsruYieldModel {
pub model_id: String,
pub regolith_type_weights: HashMap<String, f64>,
pub temperature_factor: f64,
pub energy_factor: f64,
}
impl IsruYieldModel {
pub fn new(model_id: String) -> Self {
Self {
model_id,
regolith_type_weights: HashMap::new(),
temperature_factor: 1.0,
energy_factor: 1.0,
}
}
pub fn predict_yield(&self, regolith_type: &str, temperature_k: f64, energy_wh: f64) -> f64 {
let base_weight = self
.regolith_type_weights
.get(regolith_type)
.copied()
.unwrap_or(0.5);
let temp_scaled = (temperature_k / 1000.0) * self.temperature_factor;
let energy_scaled = energy_wh * self.energy_factor;
(base_weight * temp_scaled * energy_scaled).max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_features() -> Vec<(TerrainFeature, f64)> {
vec![
(TerrainFeature::Slope, 0.3),
(TerrainFeature::Roughness, 0.5),
(TerrainFeature::DustDepth, 0.2),
(TerrainFeature::SolarExposure, 0.8),
]
}
fn make_gradient(agent: &str, slope_grad: f64, bias_grad: f64) -> GradientUpdate {
GradientUpdate {
model_id: "model-1".to_string(),
source_agent: agent.to_string(),
feature_gradients: vec![
(TerrainFeature::Slope, slope_grad),
(TerrainFeature::Roughness, 0.01),
(TerrainFeature::DustDepth, -0.005),
(TerrainFeature::BoulderDensity, 0.0),
(TerrainFeature::CraterProximity, 0.0),
(TerrainFeature::SolarExposure, 0.02),
(TerrainFeature::ThermalGradient, 0.0),
(TerrainFeature::RegolithCompaction, 0.0),
],
bias_gradient: bias_grad,
n_samples: 100,
timestamp_us: 1_000_000,
}
}
#[test]
fn test_model_prediction() {
let mut model = TraversabilityModel::new("test-model".to_string());
model.feature_weights = vec![
(TerrainFeature::Slope, -0.5),
(TerrainFeature::Roughness, -0.3),
(TerrainFeature::DustDepth, -0.1),
(TerrainFeature::SolarExposure, 0.4),
];
model.bias = 0.5;
let score = model.predict(&sample_features());
assert!((score - 0.5).abs() < 0.01);
}
#[test]
fn test_prediction_clamped() {
let mut model = TraversabilityModel::new("test".to_string());
model.feature_weights = vec![(TerrainFeature::Slope, 10.0)];
model.bias = 5.0;
let score = model.predict(&[(TerrainFeature::Slope, 1.0)]);
assert_eq!(score, 1.0);
model.feature_weights = vec![(TerrainFeature::Slope, -10.0)];
model.bias = -5.0;
let score = model.predict(&[(TerrainFeature::Slope, 1.0)]);
assert_eq!(score, 0.0); }
#[test]
fn test_weight_update() {
let mut model = TraversabilityModel::new("test".to_string());
assert_eq!(model.version, 0);
assert_eq!(model.training_samples, 0);
let new_weights = vec![
(TerrainFeature::Slope, -0.4),
(TerrainFeature::Roughness, -0.2),
];
model.update_weights(&new_weights, 0.3, 50);
assert_eq!(model.version, 1);
assert_eq!(model.training_samples, 50);
assert_eq!(model.bias, 0.3);
assert_eq!(model.feature_weights.len(), 2);
}
#[test]
fn test_gradient_aggregation_median() {
let mut agg = FederatedAggregator::new();
agg.min_updates_for_round = 3;
agg.submit_gradient(make_gradient("rover-1", 0.10, 0.01));
agg.submit_gradient(make_gradient("rover-2", 0.12, 0.02));
agg.submit_gradient(make_gradient("rover-3", 0.08, 0.015));
let result = agg.aggregate().expect("should aggregate with 3 updates");
assert_eq!(result.n_contributors, 3);
assert_eq!(result.n_rejected, 0);
let slope_grad = result
.feature_gradients
.iter()
.find(|(f, _)| *f == TerrainFeature::Slope)
.unwrap()
.1;
assert!((slope_grad - 0.10).abs() < 1e-9);
assert!((result.bias_gradient - 0.015).abs() < 1e-9);
}
#[test]
fn test_byzantine_rejection() {
let mut agg = FederatedAggregator::new();
agg.min_updates_for_round = 3;
agg.byzantine_threshold = 3.0;
agg.submit_gradient(make_gradient("rover-1", 0.10, 0.01));
agg.submit_gradient(make_gradient("rover-2", 0.12, 0.02));
agg.submit_gradient(make_gradient("rover-3", 0.11, 0.015));
agg.submit_gradient(make_gradient("byzantine", 50.0, 50.0));
let result = agg.aggregate().expect("should aggregate");
assert_eq!(result.n_rejected, 1);
assert_eq!(result.n_contributors, 3);
let slope_grad = result
.feature_gradients
.iter()
.find(|(f, _)| *f == TerrainFeature::Slope)
.unwrap()
.1;
assert!((slope_grad - 0.11).abs() < 1e-9);
}
#[test]
fn test_insufficient_updates_returns_none() {
let mut agg = FederatedAggregator::new();
agg.min_updates_for_round = 3;
agg.submit_gradient(make_gradient("rover-1", 0.10, 0.01));
assert!(agg.aggregate().is_none());
assert_eq!(agg.pending_count(), 1);
agg.submit_gradient(make_gradient("rover-2", 0.12, 0.02));
assert!(agg.aggregate().is_none());
assert_eq!(agg.pending_count(), 2);
}
#[test]
fn test_terrain_feature_classification() {
assert_eq!(TerrainFeature::ALL.len(), 8);
let mut seen = std::collections::HashSet::new();
for f in &TerrainFeature::ALL {
assert!(seen.insert(*f), "duplicate feature: {:?}", f);
}
}
#[test]
fn test_isru_yield_prediction() {
let mut model = IsruYieldModel::new("isru-1".to_string());
model
.regolith_type_weights
.insert("highland".to_string(), 0.8);
model.regolith_type_weights.insert("mare".to_string(), 0.4);
model.temperature_factor = 1.2;
model.energy_factor = 0.001;
let yield_highland = model.predict_yield("highland", 500.0, 1000.0);
assert!((yield_highland - 0.48).abs() < 0.01);
let yield_mare = model.predict_yield("mare", 500.0, 1000.0);
assert!(yield_mare < yield_highland);
let yield_unknown = model.predict_yield("polar_ice", 500.0, 1000.0);
let expected = 0.5 * 0.5 * 1.2 * 1.0;
assert!((yield_unknown - expected).abs() < 0.01);
}
#[test]
fn test_empty_aggregator() {
let mut agg = FederatedAggregator::new();
assert_eq!(agg.pending_count(), 0);
assert!(agg.aggregate().is_none());
agg.submit_gradient(make_gradient("rover-1", 0.1, 0.01));
agg.submit_gradient(make_gradient("rover-2", 0.2, 0.02));
assert_eq!(agg.pending_count(), 2);
let _ = agg.aggregate(); agg.clear();
assert_eq!(agg.pending_count(), 0);
}
#[test]
fn test_observation_roundtrip() {
let obs = TerrainObservation {
location: (-89.9, 45.0),
features: sample_features(),
traversability_score: 0.75,
observer_id: "rover-1".to_string(),
confidence: 0.9,
timestamp_us: 1_000_000,
};
let json = serde_json::to_string(&obs).expect("serialize");
let deser: TerrainObservation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deser.observer_id, "rover-1");
assert!((deser.traversability_score - 0.75).abs() < 1e-9);
}
}