Skip to main content

perspt_sdk/
energy.rs

1//! Canonical quadratic residual energy (PSP-8 System 2).
2//!
3//! The single energy that gates acceptance, feeds the finite-decision bound,
4//! and is recorded for replay is
5//!
6//! ```text
7//! V(x) = sum_{e in E} w_e * ||r_e(x)||^2,   w_e > 0.
8//! ```
9//!
10//! Each [`ResidualEvent`] stores the raw magnitude `r_e >= 0`; the SDK squares
11//! and weights it here. The component aggregates `V_syn, V_str, V_log, V_boot,
12//! V_sheaf` are *derived rollups* of this same quadratic energy:
13//!
14//! ```text
15//! V_comp = sum_{e in comp} w_e * ||r_e||^2,   V(x) = sum_comp V_comp.
16//! ```
17//!
18//! The rollups are user-visible projections only: they carry no independent
19//! weights and are never summed through a second weighting pass.
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{check_positive_finite, Result, SdkError};
24use crate::residual::{EnergyComponent, ResidualClass, ResidualEvent, ResidualEventRef, SensorRef};
25
26/// One residual weight entry in an [`EnergyModel`].
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ResidualWeight {
29    pub class: ResidualClass,
30    /// Optional sensor specificity; `None` matches any sensor for the class.
31    pub sensor: Option<String>,
32    pub component: EnergyComponent,
33    /// Strictly positive edge weight `w_e`.
34    pub weight: f64,
35    /// Optional hard-check threshold on the raw residual magnitude.
36    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/// The declared energy model for a domain scope (PSP-8 System 5 `EnergyModel`).
62#[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    /// The single descent tolerance `rho_gate > 0`.
68    pub rho_gate: f64,
69    /// Energy at or below which the candidate is treated as inside tolerance.
70    pub energy_tolerance: f64,
71    /// Finite correction budget (number of permitted regenerations).
72    pub correction_budget: u32,
73    /// Optional analytic stability claim (continuous constants).
74    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    /// Validate the model: `rho_gate > 0`, finite tolerance, and every declared
101    /// weight strictly positive and finite.
102    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    /// Resolve the weight and component for a residual. A sensor-specific entry
124    /// wins over a class-wide entry. Returns `None` when no weight is declared
125    /// (the caller treats that as an error: PSP-8 forbids implicit weight `1`).
126    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/// Derived component rollups (PSP-8 System 2). Telemetry projections only.
139#[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    /// Total energy `V(x) = sum_comp V_comp`. Because the components are already
150    /// weighted-squared rollups, this is a plain sum with no second weighting.
151    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/// The full energy evaluation of a candidate.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct EnergyScore {
169    /// Total energy `V`.
170    pub total: f64,
171    /// Derived component rollups.
172    pub components: EnergyComponents,
173    /// Residuals sorted by weighted energy, descending: dominant first.
174    pub dominant: Vec<ResidualEventRef>,
175    /// Residual classes that hit a hard threshold (force a hard-fail).
176    pub hard_violations: Vec<ResidualClass>,
177    /// Admissibility-outcome residuals excluded from `V` (blocked channel).
178    pub blocked: Vec<ResidualEventRef>,
179}
180
181/// Compute the canonical quadratic energy for a candidate's residual vector
182/// against a declared [`EnergyModel`].
183///
184/// Admissibility-outcome residuals (`CapabilityDenied`, `BudgetExhausted`) are
185/// routed to the `blocked` channel and excluded from `V`. Every consistency
186/// residual SHALL have a declared weight; a missing weight is an error rather
187/// than an implicit weight of `1`.
188pub 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        // Raw score already validated at construction, but re-check defensively
198        // so a hand-built residual cannot smuggle in a non-finite score.
199        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    // Total is a sum of finite non-negative terms; assert the invariant.
242    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        // V = 2.0 * 3^2 + 1.0 * 2^2 = 18 + 4 = 22.
287        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        // Dominant residual is the type error (18 > 4).
296        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}