use serde::{Deserialize, Serialize};
use crate::error::{check_positive_finite, Result, SdkError};
use crate::residual::{EnergyComponent, ResidualClass, ResidualEvent, ResidualEventRef, SensorRef};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResidualWeight {
pub class: ResidualClass,
pub sensor: Option<String>,
pub component: EnergyComponent,
pub weight: f64,
pub hard_threshold: Option<f64>,
}
impl ResidualWeight {
pub fn new(class: ResidualClass, component: EnergyComponent, weight: f64) -> Self {
Self {
class,
sensor: None,
component,
weight,
hard_threshold: None,
}
}
pub fn for_sensor(mut self, sensor: impl Into<String>) -> Self {
self.sensor = Some(sensor.into());
self
}
pub fn with_hard_threshold(mut self, threshold: f64) -> Self {
self.hard_threshold = Some(threshold);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnergyModel {
pub model_id: String,
pub domain: String,
pub residual_weights: Vec<ResidualWeight>,
pub rho_gate: f64,
pub energy_tolerance: f64,
pub correction_budget: u32,
pub stability_claim: Option<crate::stability::StabilityClaim>,
}
impl EnergyModel {
pub fn new(domain: impl Into<String>, rho_gate: f64) -> Self {
Self {
model_id: uuid::Uuid::new_v4().to_string(),
domain: domain.into(),
residual_weights: Vec::new(),
rho_gate,
energy_tolerance: 0.0,
correction_budget: 4,
stability_claim: None,
}
}
pub fn with_weight(mut self, weight: ResidualWeight) -> Self {
self.residual_weights.push(weight);
self
}
pub fn with_correction_budget(mut self, budget: u32) -> Self {
self.correction_budget = budget;
self
}
pub fn validate(&self) -> Result<()> {
check_positive_finite(self.rho_gate, "rho_gate")?;
if !self.energy_tolerance.is_finite() || self.energy_tolerance < 0.0 {
return Err(SdkError::InvalidGate(format!(
"energy_tolerance must be finite and non-negative: {}",
self.energy_tolerance
)));
}
for w in &self.residual_weights {
check_positive_finite(w.weight, "residual weight")?;
if let Some(t) = w.hard_threshold {
if !t.is_finite() || t < 0.0 {
return Err(SdkError::InvalidWeight(format!(
"hard_threshold must be finite and non-negative: {t}"
)));
}
}
}
Ok(())
}
pub fn resolve(&self, class: ResidualClass, sensor: &SensorRef) -> Option<&ResidualWeight> {
self.residual_weights
.iter()
.find(|w| w.class == class && w.sensor.as_deref() == Some(sensor.id.as_str()))
.or_else(|| {
self.residual_weights
.iter()
.find(|w| w.class == class && w.sensor.is_none())
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct EnergyComponents {
pub v_syn: f64,
pub v_str: f64,
pub v_log: f64,
pub v_boot: f64,
pub v_sheaf: f64,
}
impl EnergyComponents {
pub fn total(&self) -> f64 {
self.v_syn + self.v_str + self.v_log + self.v_boot + self.v_sheaf
}
fn add(&mut self, component: EnergyComponent, energy: f64) {
match component {
EnergyComponent::Syn => self.v_syn += energy,
EnergyComponent::Str => self.v_str += energy,
EnergyComponent::Log => self.v_log += energy,
EnergyComponent::Boot => self.v_boot += energy,
EnergyComponent::Sheaf => self.v_sheaf += energy,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnergyScore {
pub total: f64,
pub components: EnergyComponents,
pub dominant: Vec<ResidualEventRef>,
pub hard_violations: Vec<ResidualClass>,
pub blocked: Vec<ResidualEventRef>,
}
pub fn score_candidate(model: &EnergyModel, residuals: &[ResidualEvent]) -> Result<EnergyScore> {
model.validate()?;
let mut components = EnergyComponents::default();
let mut dominant: Vec<ResidualEventRef> = Vec::new();
let mut blocked: Vec<ResidualEventRef> = Vec::new();
let mut hard_violations: Vec<ResidualClass> = Vec::new();
for r in residuals {
crate::error::check_non_negative_finite(r.score, "residual score")?;
if r.is_admissibility_outcome() {
blocked.push(ResidualEventRef {
residual_id: r.residual_id.clone(),
class: r.class,
component: r.component,
weighted_energy: 0.0,
});
continue;
}
let weight = model.resolve(r.class, &r.sensor).ok_or_else(|| {
SdkError::InvalidWeight(format!(
"no declared weight for residual class {:?} from sensor {}",
r.class, r.sensor.id
))
})?;
if let Some(threshold) = weight.hard_threshold {
if r.score > threshold {
hard_violations.push(r.class);
}
}
let weighted = weight.weight * r.score * r.score;
components.add(weight.component, weighted);
dominant.push(ResidualEventRef {
residual_id: r.residual_id.clone(),
class: r.class,
component: weight.component,
weighted_energy: weighted,
});
}
dominant.sort_by(|a, b| {
b.weighted_energy
.partial_cmp(&a.weighted_energy)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total = components.total();
debug_assert!(total.is_finite() && total >= 0.0);
Ok(EnergyScore {
total,
components,
dominant,
hard_violations,
blocked,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::residual::{IndependenceRoute, ResidualSeverity};
fn model() -> EnergyModel {
EnergyModel::new("test", 0.5)
.with_weight(ResidualWeight::new(
ResidualClass::Type,
EnergyComponent::Syn,
2.0,
))
.with_weight(ResidualWeight::new(
ResidualClass::TestFailure,
EnergyComponent::Log,
1.0,
))
}
fn residual(class: ResidualClass, score: f64) -> ResidualEvent {
ResidualEvent::new(
"n1",
0,
class,
ResidualSeverity::Error,
score,
SensorRef::new("compiler", IndependenceRoute::Compiler),
)
.unwrap()
}
#[test]
fn energy_is_weighted_sum_of_squares() {
let residuals = vec![
residual(ResidualClass::Type, 3.0),
residual(ResidualClass::TestFailure, 2.0),
];
let score = score_candidate(&model(), &residuals).unwrap();
assert_eq!(score.total, 22.0);
assert_eq!(score.components.v_syn, 18.0);
assert_eq!(score.components.v_log, 4.0);
assert_eq!(score.dominant[0].class, ResidualClass::Type);
}
#[test]
fn missing_weight_is_error_not_implicit_one() {
let residuals = vec![residual(ResidualClass::Build, 1.0)];
assert!(score_candidate(&model(), &residuals).is_err());
}
#[test]
fn admissibility_outcomes_excluded_from_energy() {
let residuals = vec![
residual(ResidualClass::Type, 3.0),
residual(ResidualClass::CapabilityDenied, 99.0),
];
let score = score_candidate(&model(), &residuals).unwrap();
assert_eq!(score.total, 18.0);
assert_eq!(score.blocked.len(), 1);
}
#[test]
fn hard_threshold_flags_violation() {
let model = EnergyModel::new("test", 0.5).with_weight(
ResidualWeight::new(ResidualClass::Type, EnergyComponent::Syn, 1.0)
.with_hard_threshold(0.0),
);
let score = score_candidate(&model, &[residual(ResidualClass::Type, 1.0)]).unwrap();
assert_eq!(score.hard_violations, vec![ResidualClass::Type]);
}
#[test]
fn rejects_non_positive_weight() {
let model = EnergyModel::new("test", 0.5).with_weight(ResidualWeight::new(
ResidualClass::Type,
EnergyComponent::Syn,
0.0,
));
assert!(model.validate().is_err());
}
#[test]
fn empty_residuals_give_zero_energy() {
let score = score_candidate(&model(), &[]).unwrap();
assert_eq!(score.total, 0.0);
}
}