1use serde::{Deserialize, Serialize};
22
23use crate::error::{check_positive_finite, Result, SdkError};
24use crate::residual::{EnergyComponent, ResidualClass, ResidualEvent, ResidualEventRef, SensorRef};
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ResidualWeight {
29 pub class: ResidualClass,
30 pub sensor: Option<String>,
32 pub component: EnergyComponent,
33 pub weight: f64,
35 pub hard_threshold: Option<f64>,
37}
38
39impl ResidualWeight {
40 pub fn new(class: ResidualClass, component: EnergyComponent, weight: f64) -> Self {
41 Self {
42 class,
43 sensor: None,
44 component,
45 weight,
46 hard_threshold: None,
47 }
48 }
49
50 pub fn for_sensor(mut self, sensor: impl Into<String>) -> Self {
51 self.sensor = Some(sensor.into());
52 self
53 }
54
55 pub fn with_hard_threshold(mut self, threshold: f64) -> Self {
56 self.hard_threshold = Some(threshold);
57 self
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct EnergyModel {
64 pub model_id: String,
65 pub domain: String,
66 pub residual_weights: Vec<ResidualWeight>,
67 pub rho_gate: f64,
69 pub energy_tolerance: f64,
71 pub correction_budget: u32,
73 pub stability_claim: Option<crate::stability::StabilityClaim>,
75}
76
77impl EnergyModel {
78 pub fn new(domain: impl Into<String>, rho_gate: f64) -> Self {
79 Self {
80 model_id: uuid::Uuid::new_v4().to_string(),
81 domain: domain.into(),
82 residual_weights: Vec::new(),
83 rho_gate,
84 energy_tolerance: 0.0,
85 correction_budget: 4,
86 stability_claim: None,
87 }
88 }
89
90 pub fn with_weight(mut self, weight: ResidualWeight) -> Self {
91 self.residual_weights.push(weight);
92 self
93 }
94
95 pub fn with_correction_budget(mut self, budget: u32) -> Self {
96 self.correction_budget = budget;
97 self
98 }
99
100 pub fn validate(&self) -> Result<()> {
103 check_positive_finite(self.rho_gate, "rho_gate")?;
104 if !self.energy_tolerance.is_finite() || self.energy_tolerance < 0.0 {
105 return Err(SdkError::InvalidGate(format!(
106 "energy_tolerance must be finite and non-negative: {}",
107 self.energy_tolerance
108 )));
109 }
110 for w in &self.residual_weights {
111 check_positive_finite(w.weight, "residual weight")?;
112 if let Some(t) = w.hard_threshold {
113 if !t.is_finite() || t < 0.0 {
114 return Err(SdkError::InvalidWeight(format!(
115 "hard_threshold must be finite and non-negative: {t}"
116 )));
117 }
118 }
119 }
120 Ok(())
121 }
122
123 pub fn resolve(&self, class: ResidualClass, sensor: &SensorRef) -> Option<&ResidualWeight> {
127 self.residual_weights
128 .iter()
129 .find(|w| w.class == class && w.sensor.as_deref() == Some(sensor.id.as_str()))
130 .or_else(|| {
131 self.residual_weights
132 .iter()
133 .find(|w| w.class == class && w.sensor.is_none())
134 })
135 }
136}
137
138#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
140pub struct EnergyComponents {
141 pub v_syn: f64,
142 pub v_str: f64,
143 pub v_log: f64,
144 pub v_boot: f64,
145 pub v_sheaf: f64,
146}
147
148impl EnergyComponents {
149 pub fn total(&self) -> f64 {
152 self.v_syn + self.v_str + self.v_log + self.v_boot + self.v_sheaf
153 }
154
155 fn add(&mut self, component: EnergyComponent, energy: f64) {
156 match component {
157 EnergyComponent::Syn => self.v_syn += energy,
158 EnergyComponent::Str => self.v_str += energy,
159 EnergyComponent::Log => self.v_log += energy,
160 EnergyComponent::Boot => self.v_boot += energy,
161 EnergyComponent::Sheaf => self.v_sheaf += energy,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct EnergyScore {
169 pub total: f64,
171 pub components: EnergyComponents,
173 pub dominant: Vec<ResidualEventRef>,
175 pub hard_violations: Vec<ResidualClass>,
177 pub blocked: Vec<ResidualEventRef>,
179}
180
181pub fn score_candidate(model: &EnergyModel, residuals: &[ResidualEvent]) -> Result<EnergyScore> {
189 model.validate()?;
190
191 let mut components = EnergyComponents::default();
192 let mut dominant: Vec<ResidualEventRef> = Vec::new();
193 let mut blocked: Vec<ResidualEventRef> = Vec::new();
194 let mut hard_violations: Vec<ResidualClass> = Vec::new();
195
196 for r in residuals {
197 crate::error::check_non_negative_finite(r.score, "residual score")?;
200
201 if r.is_admissibility_outcome() {
202 blocked.push(ResidualEventRef {
203 residual_id: r.residual_id.clone(),
204 class: r.class,
205 component: r.component,
206 weighted_energy: 0.0,
207 });
208 continue;
209 }
210
211 let weight = model.resolve(r.class, &r.sensor).ok_or_else(|| {
212 SdkError::InvalidWeight(format!(
213 "no declared weight for residual class {:?} from sensor {}",
214 r.class, r.sensor.id
215 ))
216 })?;
217
218 if let Some(threshold) = weight.hard_threshold {
219 if r.score > threshold {
220 hard_violations.push(r.class);
221 }
222 }
223
224 let weighted = weight.weight * r.score * r.score;
225 components.add(weight.component, weighted);
226 dominant.push(ResidualEventRef {
227 residual_id: r.residual_id.clone(),
228 class: r.class,
229 component: weight.component,
230 weighted_energy: weighted,
231 });
232 }
233
234 dominant.sort_by(|a, b| {
235 b.weighted_energy
236 .partial_cmp(&a.weighted_energy)
237 .unwrap_or(std::cmp::Ordering::Equal)
238 });
239
240 let total = components.total();
241 debug_assert!(total.is_finite() && total >= 0.0);
243
244 Ok(EnergyScore {
245 total,
246 components,
247 dominant,
248 hard_violations,
249 blocked,
250 })
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::residual::{IndependenceRoute, ResidualSeverity};
257
258 fn model() -> EnergyModel {
259 EnergyModel::new("test", 0.5)
260 .with_weight(ResidualWeight::new(
261 ResidualClass::Type,
262 EnergyComponent::Syn,
263 2.0,
264 ))
265 .with_weight(ResidualWeight::new(
266 ResidualClass::TestFailure,
267 EnergyComponent::Log,
268 1.0,
269 ))
270 }
271
272 fn residual(class: ResidualClass, score: f64) -> ResidualEvent {
273 ResidualEvent::new(
274 "n1",
275 0,
276 class,
277 ResidualSeverity::Error,
278 score,
279 SensorRef::new("compiler", IndependenceRoute::Compiler),
280 )
281 .unwrap()
282 }
283
284 #[test]
285 fn energy_is_weighted_sum_of_squares() {
286 let residuals = vec![
288 residual(ResidualClass::Type, 3.0),
289 residual(ResidualClass::TestFailure, 2.0),
290 ];
291 let score = score_candidate(&model(), &residuals).unwrap();
292 assert_eq!(score.total, 22.0);
293 assert_eq!(score.components.v_syn, 18.0);
294 assert_eq!(score.components.v_log, 4.0);
295 assert_eq!(score.dominant[0].class, ResidualClass::Type);
297 }
298
299 #[test]
300 fn missing_weight_is_error_not_implicit_one() {
301 let residuals = vec![residual(ResidualClass::Build, 1.0)];
302 assert!(score_candidate(&model(), &residuals).is_err());
303 }
304
305 #[test]
306 fn admissibility_outcomes_excluded_from_energy() {
307 let residuals = vec![
308 residual(ResidualClass::Type, 3.0),
309 residual(ResidualClass::CapabilityDenied, 99.0),
310 ];
311 let score = score_candidate(&model(), &residuals).unwrap();
312 assert_eq!(score.total, 18.0);
313 assert_eq!(score.blocked.len(), 1);
314 }
315
316 #[test]
317 fn hard_threshold_flags_violation() {
318 let model = EnergyModel::new("test", 0.5).with_weight(
319 ResidualWeight::new(ResidualClass::Type, EnergyComponent::Syn, 1.0)
320 .with_hard_threshold(0.0),
321 );
322 let score = score_candidate(&model, &[residual(ResidualClass::Type, 1.0)]).unwrap();
323 assert_eq!(score.hard_violations, vec![ResidualClass::Type]);
324 }
325
326 #[test]
327 fn rejects_non_positive_weight() {
328 let model = EnergyModel::new("test", 0.5).with_weight(ResidualWeight::new(
329 ResidualClass::Type,
330 EnergyComponent::Syn,
331 0.0,
332 ));
333 assert!(model.validate().is_err());
334 }
335
336 #[test]
337 fn empty_residuals_give_zero_energy() {
338 let score = score_candidate(&model(), &[]).unwrap();
339 assert_eq!(score.total, 0.0);
340 }
341}