use serde::{Deserialize, Serialize};
use crate::consciousness_profile::ConsciousnessTier;
use crate::constitutional_envelope::{apply_decay, sanitize_score, score_to_tier};
use crate::scoring_model::{ModelDescriptor, ScoringModel};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResult {
pub model_id: String,
pub score: f64,
pub tier: ConsciousnessTier,
pub weight_bp: u32,
pub dimensions: Vec<DimensionBreakdown>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DimensionBreakdown {
pub name: String,
pub value: f64,
pub weight: f64,
pub contribution: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DivergenceMetrics {
pub max_tier_divergence: u8,
pub score_stddev: f64,
pub gate_disagreement: bool,
pub tier_agreement_count: usize,
pub total_models: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShadowEvaluation {
pub active: ModelResult,
pub shadows: Vec<ModelResult>,
pub divergence: DivergenceMetrics,
pub evaluated_at: u64,
}
#[derive(Debug, Clone)]
pub struct EvaluationInput<'a> {
pub dimensions: &'a [f64],
pub lambda: f64,
pub elapsed_days: f64,
pub threshold: f64,
pub now_us: u64,
}
pub fn evaluate_model(
model: &dyn ScoringModel,
dimensions: &[f64],
lambda: f64,
elapsed_days: f64,
) -> Option<ModelResult> {
if dimensions.len() < model.dimension_count() {
return None;
}
let model_dims = &dimensions[..model.dimension_count()];
let raw_score = model.compute_score(model_dims);
let decayed_score = apply_decay(raw_score, lambda, elapsed_days);
let tier = score_to_tier(decayed_score);
let dim_breakdown: Vec<DimensionBreakdown> = model
.dimension_names()
.iter()
.zip(model.weights().iter())
.enumerate()
.map(|(i, (&name, &weight))| {
let value = sanitize_score(model_dims.get(i).copied().unwrap_or(0.0));
DimensionBreakdown {
name: name.to_string(),
value,
weight,
contribution: value * weight,
}
})
.collect();
let weight_bp = tier.vote_weight_bp();
Some(ModelResult {
model_id: model.model_id().to_string(),
score: decayed_score,
tier,
weight_bp,
dimensions: dim_breakdown,
})
}
pub fn evaluate_descriptor(
desc: &ModelDescriptor,
dimensions: &[f64],
lambda: f64,
elapsed_days: f64,
) -> Option<ModelResult> {
if dimensions.len() < desc.weights.len() {
return None;
}
let model_dims = &dimensions[..desc.weights.len()];
let raw_score = desc.compute_score(model_dims);
let decayed_score = apply_decay(raw_score, lambda, elapsed_days);
let tier = score_to_tier(decayed_score);
let dim_breakdown: Vec<DimensionBreakdown> = desc
.dimension_names
.iter()
.zip(desc.weights.iter())
.enumerate()
.map(|(i, (name, &weight))| {
let value = sanitize_score(model_dims.get(i).copied().unwrap_or(0.0));
DimensionBreakdown {
name: name.clone(),
value,
weight,
contribution: value * weight,
}
})
.collect();
let weight_bp = tier.vote_weight_bp();
Some(ModelResult {
model_id: desc.model_id.clone(),
score: decayed_score,
tier,
weight_bp,
dimensions: dim_breakdown,
})
}
pub fn evaluate_all(
active_model: &dyn ScoringModel,
shadow_models: &[&dyn ScoringModel],
input: &EvaluationInput,
) -> ShadowEvaluation {
let active = evaluate_model(
active_model,
input.dimensions,
input.lambda,
input.elapsed_days,
)
.unwrap_or(ModelResult {
model_id: active_model.model_id().to_string(),
score: 0.0,
tier: ConsciousnessTier::Observer,
weight_bp: 0,
dimensions: vec![],
});
let shadows: Vec<ModelResult> = shadow_models
.iter()
.filter_map(|m| evaluate_model(*m, input.dimensions, input.lambda, input.elapsed_days))
.collect();
let divergence = compute_divergence(&active, &shadows, input.threshold);
ShadowEvaluation {
active,
shadows,
divergence,
evaluated_at: input.now_us,
}
}
fn compute_divergence(
active: &ModelResult,
shadows: &[ModelResult],
threshold: f64,
) -> DivergenceMetrics {
if shadows.is_empty() {
return DivergenceMetrics {
max_tier_divergence: 0,
score_stddev: 0.0,
gate_disagreement: false,
tier_agreement_count: 1,
total_models: 1,
};
}
let active_passes = active.score >= threshold;
let active_tier_ord = tier_ordinal(active.tier);
let all_scores: Vec<f64> = core::iter::once(active.score)
.chain(shadows.iter().map(|s| s.score))
.collect();
let n = all_scores.len() as f64;
let mean = all_scores.iter().sum::<f64>() / n;
let variance = all_scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / n;
let stddev = variance.sqrt();
let mut max_tier_div: u8 = 0;
let mut tier_agree = 1usize; let mut gate_disagree = false;
for shadow in shadows {
let shadow_tier_ord = tier_ordinal(shadow.tier);
let tier_diff = active_tier_ord.abs_diff(shadow_tier_ord) as u8;
if tier_diff > max_tier_div {
max_tier_div = tier_diff;
}
if shadow.tier == active.tier {
tier_agree += 1;
}
let shadow_passes = shadow.score >= threshold;
if shadow_passes != active_passes {
gate_disagree = true;
}
}
DivergenceMetrics {
max_tier_divergence: max_tier_div,
score_stddev: if stddev.is_finite() { stddev } else { 0.0 },
gate_disagreement: gate_disagree,
tier_agreement_count: tier_agree,
total_models: 1 + shadows.len(),
}
}
fn tier_ordinal(tier: ConsciousnessTier) -> usize {
match tier {
ConsciousnessTier::Observer => 0,
ConsciousnessTier::Participant => 1,
ConsciousnessTier::Citizen => 2,
ConsciousnessTier::Steward => 3,
ConsciousnessTier::Guardian => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scoring_model::{Canonical4D, MinimalCivic, Sovereign8D};
fn make_input(dims: &[f64]) -> EvaluationInput {
EvaluationInput {
dimensions: dims,
lambda: 0.002,
elapsed_days: 0.0, threshold: 0.4, now_us: 1_000_000,
}
}
#[test]
fn evaluate_canonical_4d() {
let model = Canonical4D::default();
let dims = [0.8, 0.6, 0.7, 0.5]; let result = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
assert_eq!(result.model_id, "canonical-4d-v1");
assert!((result.score - 0.66).abs() < 1e-6);
assert_eq!(result.tier, ConsciousnessTier::Steward);
assert_eq!(result.dimensions.len(), 4);
}
#[test]
fn evaluate_with_decay() {
let model = Canonical4D::default();
let dims = [1.0, 1.0, 1.0, 1.0]; let result_no_decay = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
let result_30d = evaluate_model(&model, &dims, 0.002, 30.0).unwrap();
assert!(
result_30d.score < result_no_decay.score,
"30-day decay should reduce score"
);
assert!(
result_30d.score > 0.9,
"30 days at lambda=0.002 should still be high"
);
}
#[test]
fn evaluate_dimension_mismatch_returns_none() {
let model = Canonical4D::default();
let dims = [0.5, 0.5]; assert!(evaluate_model(&model, &dims, 0.002, 0.0).is_none());
}
#[test]
fn evaluate_extra_dimensions_ignored() {
let model = Canonical4D::default();
let dims = [0.8, 0.6, 0.7, 0.5, 0.9, 0.9, 0.9, 0.9]; let result = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
assert!((result.score - 0.66).abs() < 1e-6);
}
#[test]
fn shadow_eval_with_no_shadows() {
let active = Canonical4D::default();
let input = make_input(&[0.8, 0.6, 0.7, 0.5]);
let eval = evaluate_all(&active, &[], &input);
assert_eq!(eval.active.model_id, "canonical-4d-v1");
assert!(eval.shadows.is_empty());
assert_eq!(eval.divergence.max_tier_divergence, 0);
assert!(!eval.divergence.gate_disagreement);
assert_eq!(eval.divergence.total_models, 1);
}
#[test]
fn shadow_eval_models_agree() {
let active = Canonical4D::default();
let dims = [0.8, 0.6, 0.7, 0.5, 0.8, 0.6, 0.7, 0.5];
let sovereign = Sovereign8D::governance();
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&sovereign], &input);
assert_eq!(eval.active.model_id, "canonical-4d-v1");
assert_eq!(eval.shadows.len(), 1);
assert_eq!(eval.shadows[0].model_id, "sovereign-8d-v1");
assert_eq!(eval.divergence.total_models, 2);
}
#[test]
fn shadow_eval_detects_tier_divergence() {
let active = Canonical4D::default();
let minimal = MinimalCivic::default();
let dims = [0.0, 0.0, 1.0, 1.0];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&minimal], &input);
assert!(
eval.divergence.max_tier_divergence > 0,
"Models should diverge: active_tier={:?} shadow_tier={:?}",
eval.active.tier,
eval.shadows[0].tier
);
}
#[test]
fn shadow_eval_detects_gate_disagreement() {
let active = Canonical4D::default();
let minimal = MinimalCivic::default();
let dims = [0.0, 0.0, 1.0, 1.0];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&minimal], &input);
assert!(
eval.active.score >= 0.4,
"Active should pass: {}",
eval.active.score
);
assert!(
eval.shadows[0].score < 0.4,
"Shadow should fail: {}",
eval.shadows[0].score
);
assert!(
eval.divergence.gate_disagreement,
"Gate disagreement should be detected"
);
}
#[test]
fn shadow_eval_three_models() {
let active = Canonical4D::default();
let sovereign = Sovereign8D::governance();
let minimal = MinimalCivic::default();
let dims = [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&sovereign, &minimal], &input);
assert_eq!(eval.divergence.total_models, 3);
assert_eq!(eval.shadows.len(), 2);
}
#[test]
fn divergence_stddev_zero_when_all_agree() {
let active = Canonical4D::default();
let dims = [0.5, 0.5, 0.5, 0.5]; let input = make_input(&dims);
let eval = evaluate_all(&active, &[], &input);
assert_eq!(eval.divergence.score_stddev, 0.0);
}
#[test]
fn divergence_stddev_positive_when_models_differ() {
let active = Canonical4D::default();
let sovereign = Sovereign8D::governance();
let dims = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&sovereign], &input);
assert!(
eval.divergence.score_stddev > 0.0,
"Different weight distributions should produce different scores: active={}, shadow={}",
eval.active.score,
eval.shadows[0].score
);
}
#[test]
fn tier_agreement_count_correct() {
let active = Canonical4D::default();
let sovereign = Sovereign8D::governance();
let minimal = MinimalCivic::default();
let dims = [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&sovereign, &minimal], &input);
assert_eq!(
eval.divergence.tier_agreement_count, eval.divergence.total_models,
"All models should agree on tier for uniform 0.7 input"
);
}
#[test]
fn dimension_breakdown_has_correct_names() {
let model = Canonical4D::default();
let dims = [0.8, 0.6, 0.7, 0.5];
let result = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
assert_eq!(result.dimensions[0].name, "Identity");
assert_eq!(result.dimensions[1].name, "Reputation");
assert_eq!(result.dimensions[2].name, "Community");
assert_eq!(result.dimensions[3].name, "Engagement");
}
#[test]
fn dimension_contributions_sum_to_score() {
let model = Canonical4D::default();
let dims = [0.8, 0.6, 0.7, 0.5];
let result = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
let contribution_sum: f64 = result.dimensions.iter().map(|d| d.contribution).sum();
assert!(
(contribution_sum - result.score).abs() < 1e-6,
"Contributions should sum to score: {} vs {}",
contribution_sum,
result.score
);
}
#[test]
fn shadow_evaluation_serde_roundtrip() {
let active = Canonical4D::default();
let sovereign = Sovereign8D::governance();
let dims = [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7];
let input = make_input(&dims);
let eval = evaluate_all(&active, &[&sovereign], &input);
let json = serde_json::to_string(&eval).unwrap();
let back: ShadowEvaluation = serde_json::from_str(&json).unwrap();
assert_eq!(back.active.model_id, eval.active.model_id);
assert_eq!(back.shadows.len(), eval.shadows.len());
assert_eq!(back.divergence.total_models, eval.divergence.total_models);
}
#[test]
fn descriptor_evaluation_matches_model() {
let model = Canonical4D::default();
let desc = ModelDescriptor::from_model(&model, 1000, "test".into());
let dims = [0.8, 0.6, 0.7, 0.5];
let model_result = evaluate_model(&model, &dims, 0.002, 0.0).unwrap();
let desc_result = evaluate_descriptor(&desc, &dims, 0.002, 0.0).unwrap();
assert!(
(model_result.score - desc_result.score).abs() < 1e-10,
"Model and descriptor should produce identical scores"
);
assert_eq!(model_result.tier, desc_result.tier);
}
}