Skip to main content

perspt_sdk/
residual.rs

1//! Residual evidence model (PSP-8 System 6).
2//!
3//! A residual is the measured reason the current state is unsafe or incomplete.
4//! Each [`ResidualEvent`] stores the *raw* non-negative magnitude `r_e >= 0`;
5//! the SDK squares and weights it when computing the canonical energy
6//! `V = sum_e w_e r_e^2` (see [`crate::energy`]). Residuals never carry a
7//! pre-squared or pre-weighted value, so the energy model stays the single
8//! authority over weighting.
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{check_non_negative_finite, Result};
13
14/// The five SRBN energy components. These are *derived rollups* of the single
15/// quadratic residual energy, grouped for telemetry; they do not carry
16/// independent weights (PSP-8 System 2).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum EnergyComponent {
20    /// Syntax, parser, typechecker, and compiler diagnostics.
21    Syn,
22    /// Structural contract, ownership, import/symbol/interface, format, lint.
23    Str,
24    /// Failing tests, snapshots, property checks, behavioral validators.
25    Log,
26    /// Toolchain, dependency, sandbox, missing binary, degraded sensors.
27    Boot,
28    /// Cross-node, cross-domain, cross-adapter consistency residuals.
29    Sheaf,
30}
31
32/// Residual taxonomy (PSP-8 System 6). Every verifier residual is one class.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ResidualClass {
36    Syntax,
37    Type,
38    Build,
39    TestFailure,
40    Lint,
41    Format,
42    Runtime,
43    Dependency,
44    Manifest,
45    ImportGraph,
46    SymbolMismatch,
47    InterfaceMismatch,
48    OwnershipViolation,
49    ContextDrift,
50    Regression,
51    Policy,
52    SensorUnavailable,
53    ToolFailure,
54    SheafInconsistency,
55    /// Admissibility outcome, not a verifier consistency residual.
56    CapabilityDenied,
57    /// Admissibility outcome, not a verifier consistency residual.
58    BudgetExhausted,
59}
60
61impl ResidualClass {
62    /// Default SRBN energy component for this class (PSP-8 System 6 mapping).
63    pub fn default_component(self) -> EnergyComponent {
64        use EnergyComponent::*;
65        use ResidualClass::*;
66        match self {
67            Syntax | Type | Build => Syn,
68            Lint | Format | ImportGraph | SymbolMismatch | InterfaceMismatch
69            | OwnershipViolation | Manifest | Dependency => Str,
70            TestFailure | Runtime | Regression => Log,
71            SensorUnavailable | ToolFailure => Boot,
72            SheafInconsistency | ContextDrift => Sheaf,
73            // Admissibility outcomes are routed to the blocked channel and are
74            // never summed into V; they are reported here for completeness only.
75            Policy | CapabilityDenied | BudgetExhausted => Str,
76        }
77    }
78
79    /// `CapabilityDenied` and `BudgetExhausted` are admissibility outcomes that
80    /// SHALL be recorded on a separate blocked channel and SHALL NOT be summed
81    /// into the Lyapunov energy `V` (PSP-8 System 6).
82    pub fn is_admissibility_outcome(self) -> bool {
83        matches!(
84            self,
85            ResidualClass::CapabilityDenied | ResidualClass::BudgetExhausted
86        )
87    }
88}
89
90/// Severity of a residual, independent of its numeric score.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ResidualSeverity {
94    Hint,
95    Warning,
96    Error,
97    /// Blocks acceptance regardless of energy descent (maps to a hard gate).
98    Blocking,
99}
100
101/// Verifier-independence route (PSP-8 System 6). Same-model critique is the
102/// weakest route and SHALL NOT contribute a full-weight descent acceptance.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum IndependenceRoute {
106    DeterministicTool,
107    Compiler,
108    Lsp,
109    TestOracle,
110    FormalSolver,
111    RepoScript,
112    ExternalApi,
113    SeparateModel,
114    SameModelCritique,
115}
116
117impl IndependenceRoute {
118    /// Whether this route may contribute a full-weight descent acceptance.
119    /// Same-model critique may not (PSP-8 System 6).
120    pub fn is_full_weight_eligible(self) -> bool {
121        !matches!(self, IndependenceRoute::SameModelCritique)
122    }
123}
124
125/// A sensor that produced a residual.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SensorRef {
128    /// Stable sensor identifier, e.g. `"rust-analyzer"`, `"cargo-test"`.
129    pub id: String,
130    /// Independence route for this sensor.
131    pub route: IndependenceRoute,
132}
133
134impl SensorRef {
135    pub fn new(id: impl Into<String>, route: IndependenceRoute) -> Self {
136        Self {
137            id: id.into(),
138            route,
139        }
140    }
141}
142
143/// Reference to a code symbol implicated by a residual.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SymbolRef {
146    pub name: String,
147    /// Enclosing container (module, file, namespace), if known.
148    pub container: Option<String>,
149}
150
151/// Normalized evidence payload behind a residual.
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153pub struct EvidencePayload {
154    /// Human-readable one-line summary.
155    pub summary: String,
156    /// Raw tool/LSP/test output, retained for replay and prompt context.
157    pub raw: Option<String>,
158    /// Structured detail (diagnostic JSON, AST query result, etc.).
159    pub structured: Option<serde_json::Value>,
160}
161
162/// A correction direction: the targeted instruction the controller derives from
163/// a dominant residual cluster (PSP-8 System 6). Undirected retries are a bug.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct CorrectionDirection {
166    pub direction_id: String,
167    /// The residual class this direction addresses.
168    pub addresses: ResidualClass,
169    /// What to do, in domain terms (e.g. "add `use crate::foo::Bar;`").
170    pub instruction: String,
171    /// Files the correction is expected to touch.
172    pub target_paths: Vec<String>,
173    /// Symbols the correction is expected to touch.
174    pub target_symbols: Vec<SymbolRef>,
175    /// Why this direction was chosen.
176    pub rationale: String,
177}
178
179impl CorrectionDirection {
180    pub fn new(addresses: ResidualClass, instruction: impl Into<String>) -> Self {
181        Self {
182            direction_id: uuid::Uuid::new_v4().to_string(),
183            addresses,
184            instruction: instruction.into(),
185            target_paths: Vec::new(),
186            target_symbols: Vec::new(),
187            rationale: String::new(),
188        }
189    }
190
191    pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
192        self.rationale = rationale.into();
193        self
194    }
195
196    pub fn with_paths(mut self, paths: Vec<String>) -> Self {
197        self.target_paths = paths;
198        self
199    }
200}
201
202/// A first-class residual event (PSP-8 System 6).
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct ResidualEvent {
205    pub residual_id: String,
206    pub node_id: String,
207    pub generation: u32,
208    pub component: EnergyComponent,
209    pub class: ResidualClass,
210    pub severity: ResidualSeverity,
211    /// Raw non-negative magnitude `r_e >= 0`. The SDK squares and weights it.
212    pub score: f64,
213    pub sensor: SensorRef,
214    pub evidence: EvidencePayload,
215    pub affected_paths: Vec<String>,
216    pub affected_symbols: Vec<SymbolRef>,
217    pub correction_directions: Vec<CorrectionDirection>,
218}
219
220impl ResidualEvent {
221    /// Construct a residual, validating that the raw score is finite and
222    /// non-negative. The component defaults to the class mapping but may be
223    /// overridden afterward by a domain package.
224    pub fn new(
225        node_id: impl Into<String>,
226        generation: u32,
227        class: ResidualClass,
228        severity: ResidualSeverity,
229        score: f64,
230        sensor: SensorRef,
231    ) -> Result<Self> {
232        check_non_negative_finite(score, "residual score")?;
233        Ok(Self {
234            residual_id: uuid::Uuid::new_v4().to_string(),
235            node_id: node_id.into(),
236            generation,
237            component: class.default_component(),
238            class,
239            severity,
240            score,
241            sensor,
242            evidence: EvidencePayload::default(),
243            affected_paths: Vec::new(),
244            affected_symbols: Vec::new(),
245            correction_directions: Vec::new(),
246        })
247    }
248
249    pub fn with_evidence(mut self, evidence: EvidencePayload) -> Self {
250        self.evidence = evidence;
251        self
252    }
253
254    pub fn with_component(mut self, component: EnergyComponent) -> Self {
255        self.component = component;
256        self
257    }
258
259    pub fn with_paths(mut self, paths: Vec<String>) -> Self {
260        self.affected_paths = paths;
261        self
262    }
263
264    pub fn with_correction(mut self, direction: CorrectionDirection) -> Self {
265        self.correction_directions.push(direction);
266        self
267    }
268
269    /// Whether this residual is an admissibility outcome (blocked channel) and
270    /// therefore excluded from the Lyapunov energy.
271    pub fn is_admissibility_outcome(&self) -> bool {
272        self.class.is_admissibility_outcome()
273    }
274}
275
276/// A lightweight reference to a residual, used in energy traces and gate
277/// decisions to point at dominant residuals without copying the full payload.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct ResidualEventRef {
280    pub residual_id: String,
281    pub class: ResidualClass,
282    pub component: EnergyComponent,
283    /// Weighted energy contribution `w_e * r_e^2` of this residual.
284    pub weighted_energy: f64,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn sensor() -> SensorRef {
292        SensorRef::new("compiler", IndependenceRoute::Compiler)
293    }
294
295    #[test]
296    fn rejects_negative_score() {
297        let err = ResidualEvent::new(
298            "n1",
299            0,
300            ResidualClass::Type,
301            ResidualSeverity::Error,
302            -1.0,
303            sensor(),
304        );
305        assert!(err.is_err());
306    }
307
308    #[test]
309    fn rejects_nan_and_inf_score() {
310        assert!(ResidualEvent::new(
311            "n1",
312            0,
313            ResidualClass::Type,
314            ResidualSeverity::Error,
315            f64::NAN,
316            sensor()
317        )
318        .is_err());
319        assert!(ResidualEvent::new(
320            "n1",
321            0,
322            ResidualClass::Type,
323            ResidualSeverity::Error,
324            f64::INFINITY,
325            sensor()
326        )
327        .is_err());
328    }
329
330    #[test]
331    fn class_maps_to_default_component() {
332        assert_eq!(
333            ResidualClass::Type.default_component(),
334            EnergyComponent::Syn
335        );
336        assert_eq!(
337            ResidualClass::TestFailure.default_component(),
338            EnergyComponent::Log
339        );
340        assert_eq!(
341            ResidualClass::ImportGraph.default_component(),
342            EnergyComponent::Str
343        );
344        assert_eq!(
345            ResidualClass::ToolFailure.default_component(),
346            EnergyComponent::Boot
347        );
348        assert_eq!(
349            ResidualClass::SheafInconsistency.default_component(),
350            EnergyComponent::Sheaf
351        );
352    }
353
354    #[test]
355    fn admissibility_outcomes_flagged() {
356        assert!(ResidualClass::CapabilityDenied.is_admissibility_outcome());
357        assert!(ResidualClass::BudgetExhausted.is_admissibility_outcome());
358        assert!(!ResidualClass::Type.is_admissibility_outcome());
359    }
360
361    #[test]
362    fn same_model_critique_not_full_weight() {
363        assert!(!IndependenceRoute::SameModelCritique.is_full_weight_eligible());
364        assert!(IndependenceRoute::Compiler.is_full_weight_eligible());
365    }
366}