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    // --- PSP-9 system 9: tool-loop and model-plane residuals ---
60    /// Tool-call arguments violated the tool schema.
61    ToolArgumentInvalid,
62    /// A precondition hash no longer matches; the model edited a stale view.
63    WitnessStale,
64    /// The same proposal (identical idempotency key) repeated without an
65    /// intervening evidence change — the tool-loop analogue of livelock.
66    ToolThrash,
67    /// One provider turn requested non-commuting calls without an explicit
68    /// dependency order; no conflicting mutation was executed.
69    ToolBatchConflict,
70    /// A file was mutated in a turn that never read it.
71    UnreadEdit,
72    /// Turn/token/capability budget consumed past a declared fraction.
73    BudgetPressure,
74    /// An advisory model validator objected (its fixed domain weight is
75    /// declared before the run; a conjunctive adjudicator is a gate verdict,
76    /// never a dynamic energy weight).
77    AdjudicatorObjection,
78    /// A configured route failed and failover was taken; sensor-availability
79    /// class matching `SensorUnavailable`'s treatment.
80    ProviderUnavailable,
81}
82
83impl ResidualClass {
84    /// Default SRBN energy component for this class (PSP-8 System 6 mapping).
85    pub fn default_component(self) -> EnergyComponent {
86        use EnergyComponent::*;
87        use ResidualClass::*;
88        match self {
89            Syntax | Type | Build => Syn,
90            Lint | Format | ImportGraph | SymbolMismatch | InterfaceMismatch
91            | OwnershipViolation | Manifest | Dependency => Str,
92            TestFailure | Runtime | Regression => Log,
93            SensorUnavailable | ToolFailure | ProviderUnavailable => Boot,
94            SheafInconsistency | ContextDrift => Sheaf,
95            // Tool-loop harness-progress residuals (PSP-9 system 9).
96            ToolArgumentInvalid | WitnessStale | ToolThrash | ToolBatchConflict | UnreadEdit
97            | BudgetPressure => Str,
98            AdjudicatorObjection => Log,
99            // Admissibility outcomes are routed to the blocked channel and are
100            // never summed into V; they are reported here for completeness only.
101            Policy | CapabilityDenied | BudgetExhausted => Str,
102        }
103    }
104
105    /// `CapabilityDenied` and `BudgetExhausted` are admissibility outcomes that
106    /// SHALL be recorded on a separate blocked channel and SHALL NOT be summed
107    /// into the Lyapunov energy `V` (PSP-8 System 6).
108    pub fn is_admissibility_outcome(self) -> bool {
109        matches!(
110            self,
111            ResidualClass::CapabilityDenied | ResidualClass::BudgetExhausted
112        )
113    }
114}
115
116/// Severity of a residual, independent of its numeric score.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ResidualSeverity {
120    Hint,
121    Warning,
122    Error,
123    /// Blocks acceptance regardless of energy descent (maps to a hard gate).
124    Blocking,
125}
126
127/// Verifier-independence route (PSP-8 System 6). Same-model critique is the
128/// weakest route and SHALL NOT contribute a full-weight descent acceptance.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum IndependenceRoute {
132    DeterministicTool,
133    Compiler,
134    Lsp,
135    TestOracle,
136    FormalSolver,
137    RepoScript,
138    ExternalApi,
139    SeparateModel,
140    SameModelCritique,
141}
142
143impl IndependenceRoute {
144    /// Whether this route may contribute a full-weight descent acceptance.
145    /// Same-model critique may not (PSP-8 System 6).
146    pub fn is_full_weight_eligible(self) -> bool {
147        !matches!(self, IndependenceRoute::SameModelCritique)
148    }
149}
150
151/// A sensor that produced a residual.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct SensorRef {
154    /// Stable sensor identifier, e.g. `"rust-analyzer"`, `"cargo-test"`.
155    pub id: String,
156    /// Independence route for this sensor.
157    pub route: IndependenceRoute,
158    /// Tool identity, version, and configuration fingerprint (PSP-10
159    /// Assumption 2). Empty on pre-PSP-10 records. Sensors with different
160    /// fingerprints are never pooled as one calibration stratum.
161    #[serde(default, skip_serializing_if = "String::is_empty")]
162    pub fingerprint: String,
163}
164
165impl SensorRef {
166    pub fn new(id: impl Into<String>, route: IndependenceRoute) -> Self {
167        Self {
168            id: id.into(),
169            route,
170            fingerprint: String::new(),
171        }
172    }
173
174    /// Attach the tool identity/version/configuration fingerprint.
175    pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
176        self.fingerprint = fingerprint.into();
177        self
178    }
179}
180
181/// Content-addressed reference to a recorded `CorrectionPacket`.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct CorrectionPacketRef(pub String);
184
185/// One root-cause cluster of residuals (PSP-10 system 26): two hundred
186/// cascade errors from one missing import are one cluster, not two hundred
187/// units of energy.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct ResidualClusterRef {
190    pub cluster_id: String,
191    pub class: ResidualClass,
192    /// The primary diagnostic the cascade folds into.
193    pub root_cause: String,
194    pub member_count: u32,
195    /// Profile-normalized magnitude, not a raw diagnostic count.
196    pub magnitude: f64,
197}
198
199/// One structured diagnostic preserved from a verifier's native output.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct StructuredDiagnosticRef {
202    /// Tool code (`E0432`, a pytest test id, a tsc `TSxxxx`), if any.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub code: Option<String>,
205    pub message: String,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub path: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub line: Option<u32>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub column: Option<u32>,
212}
213
214/// Everything a correction may touch, preserved from the diagnostics.
215#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
216pub struct AffectedSet {
217    pub paths: Vec<String>,
218    pub symbols: Vec<SymbolRef>,
219    /// `path:line:column` spans, when the tool reports them.
220    pub spans: Vec<String>,
221    pub tests: Vec<String>,
222    pub graph_edges: Vec<String>,
223}
224
225/// One correction operator the domain proposes for the dominant cluster.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct CorrectionOperator {
228    pub operator_id: String,
229    pub instruction: String,
230    pub addresses: ResidualClass,
231    #[serde(default, skip_serializing_if = "String::is_empty")]
232    pub rationale: String,
233}
234
235/// A declared sensor that could not run; correction proceeds under this
236/// named uncertainty instead of pretending completeness.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct MissingSensorDecl {
239    pub sensor: String,
240    pub reason: String,
241}
242
243/// Reference to a recorded no-good (PSP-10 system 21; the exact-key store
244/// arrives with the search plane).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct NoGoodRef {
247    pub key: String,
248    pub evidence_hash: String,
249}
250
251/// The typed correction packet carried to the next search quantum
252/// (PSP-10 system 26). It preserves paths, symbols, spans, and rationale —
253/// the correction channel no longer flattens to one instruction string.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct CorrectionPacket {
256    pub dominant_cluster: ResidualClusterRef,
257    pub diagnostics: Vec<StructuredDiagnosticRef>,
258    pub affected: AffectedSet,
259    pub operators: Vec<CorrectionOperator>,
260    pub expected_footprint: crate::toolset::FootprintSpec,
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub follow_up_stages: Vec<String>,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub no_goods: Vec<NoGoodRef>,
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub uncertainty: Vec<MissingSensorDecl>,
267}
268
269impl CorrectionPacket {
270    /// A packet with no operators and no diagnostics carries no direction;
271    /// the caller expands or escalates instead of blind-retrying.
272    pub fn is_empty(&self) -> bool {
273        self.operators.is_empty() && self.diagnostics.is_empty()
274    }
275}
276
277/// Reference to a code symbol implicated by a residual.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct SymbolRef {
280    pub name: String,
281    /// Enclosing container (module, file, namespace), if known.
282    pub container: Option<String>,
283}
284
285/// Normalized evidence payload behind a residual.
286#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287pub struct EvidencePayload {
288    /// Human-readable one-line summary.
289    pub summary: String,
290    /// Raw tool/LSP/test output, retained for replay and prompt context.
291    pub raw: Option<String>,
292    /// Structured detail (diagnostic JSON, AST query result, etc.).
293    pub structured: Option<serde_json::Value>,
294}
295
296/// A correction direction: the targeted instruction the controller derives from
297/// a dominant residual cluster (PSP-8 System 6). Undirected retries are a bug.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct CorrectionDirection {
300    pub direction_id: String,
301    /// The residual class this direction addresses.
302    pub addresses: ResidualClass,
303    /// What to do, in domain terms (e.g. "add `use crate::foo::Bar;`").
304    pub instruction: String,
305    /// Files the correction is expected to touch.
306    pub target_paths: Vec<String>,
307    /// Symbols the correction is expected to touch.
308    pub target_symbols: Vec<SymbolRef>,
309    /// Why this direction was chosen.
310    pub rationale: String,
311}
312
313impl CorrectionDirection {
314    pub fn new(addresses: ResidualClass, instruction: impl Into<String>) -> Self {
315        Self {
316            direction_id: uuid::Uuid::new_v4().to_string(),
317            addresses,
318            instruction: instruction.into(),
319            target_paths: Vec::new(),
320            target_symbols: Vec::new(),
321            rationale: String::new(),
322        }
323    }
324
325    pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
326        self.rationale = rationale.into();
327        self
328    }
329
330    pub fn with_paths(mut self, paths: Vec<String>) -> Self {
331        self.target_paths = paths;
332        self
333    }
334}
335
336/// A first-class residual event (PSP-8 System 6).
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
338pub struct ResidualEvent {
339    pub residual_id: String,
340    pub node_id: String,
341    pub generation: u32,
342    pub component: EnergyComponent,
343    pub class: ResidualClass,
344    pub severity: ResidualSeverity,
345    /// Raw non-negative magnitude `r_e >= 0`. The SDK squares and weights it.
346    pub score: f64,
347    pub sensor: SensorRef,
348    pub evidence: EvidencePayload,
349    pub affected_paths: Vec<String>,
350    pub affected_symbols: Vec<SymbolRef>,
351    pub correction_directions: Vec<CorrectionDirection>,
352}
353
354impl ResidualEvent {
355    /// Construct a residual, validating that the raw score is finite and
356    /// non-negative. The component defaults to the class mapping but may be
357    /// overridden afterward by a domain package.
358    pub fn new(
359        node_id: impl Into<String>,
360        generation: u32,
361        class: ResidualClass,
362        severity: ResidualSeverity,
363        score: f64,
364        sensor: SensorRef,
365    ) -> Result<Self> {
366        check_non_negative_finite(score, "residual score")?;
367        Ok(Self {
368            residual_id: uuid::Uuid::new_v4().to_string(),
369            node_id: node_id.into(),
370            generation,
371            component: class.default_component(),
372            class,
373            severity,
374            score,
375            sensor,
376            evidence: EvidencePayload::default(),
377            affected_paths: Vec::new(),
378            affected_symbols: Vec::new(),
379            correction_directions: Vec::new(),
380        })
381    }
382
383    pub fn with_evidence(mut self, evidence: EvidencePayload) -> Self {
384        self.evidence = evidence;
385        self
386    }
387
388    pub fn with_component(mut self, component: EnergyComponent) -> Self {
389        self.component = component;
390        self
391    }
392
393    pub fn with_paths(mut self, paths: Vec<String>) -> Self {
394        self.affected_paths = paths;
395        self
396    }
397
398    pub fn with_correction(mut self, direction: CorrectionDirection) -> Self {
399        self.correction_directions.push(direction);
400        self
401    }
402
403    /// Whether this residual is an admissibility outcome (blocked channel) and
404    /// therefore excluded from the Lyapunov energy.
405    pub fn is_admissibility_outcome(&self) -> bool {
406        self.class.is_admissibility_outcome()
407    }
408}
409
410/// A lightweight reference to a residual, used in energy traces and gate
411/// decisions to point at dominant residuals without copying the full payload.
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413pub struct ResidualEventRef {
414    pub residual_id: String,
415    pub class: ResidualClass,
416    pub component: EnergyComponent,
417    /// Weighted energy contribution `w_e * r_e^2` of this residual.
418    pub weighted_energy: f64,
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn sensor() -> SensorRef {
426        SensorRef::new("compiler", IndependenceRoute::Compiler)
427    }
428
429    #[test]
430    fn rejects_negative_score() {
431        let err = ResidualEvent::new(
432            "n1",
433            0,
434            ResidualClass::Type,
435            ResidualSeverity::Error,
436            -1.0,
437            sensor(),
438        );
439        assert!(err.is_err());
440    }
441
442    #[test]
443    fn rejects_nan_and_inf_score() {
444        assert!(ResidualEvent::new(
445            "n1",
446            0,
447            ResidualClass::Type,
448            ResidualSeverity::Error,
449            f64::NAN,
450            sensor()
451        )
452        .is_err());
453        assert!(ResidualEvent::new(
454            "n1",
455            0,
456            ResidualClass::Type,
457            ResidualSeverity::Error,
458            f64::INFINITY,
459            sensor()
460        )
461        .is_err());
462    }
463
464    #[test]
465    fn class_maps_to_default_component() {
466        assert_eq!(
467            ResidualClass::Type.default_component(),
468            EnergyComponent::Syn
469        );
470        assert_eq!(
471            ResidualClass::TestFailure.default_component(),
472            EnergyComponent::Log
473        );
474        assert_eq!(
475            ResidualClass::ImportGraph.default_component(),
476            EnergyComponent::Str
477        );
478        assert_eq!(
479            ResidualClass::ToolFailure.default_component(),
480            EnergyComponent::Boot
481        );
482        assert_eq!(
483            ResidualClass::SheafInconsistency.default_component(),
484            EnergyComponent::Sheaf
485        );
486    }
487
488    #[test]
489    fn admissibility_outcomes_flagged() {
490        assert!(ResidualClass::CapabilityDenied.is_admissibility_outcome());
491        assert!(ResidualClass::BudgetExhausted.is_admissibility_outcome());
492        assert!(!ResidualClass::Type.is_admissibility_outcome());
493    }
494
495    #[test]
496    fn same_model_critique_not_full_weight() {
497        assert!(!IndependenceRoute::SameModelCritique.is_full_weight_eligible());
498        assert!(IndependenceRoute::Compiler.is_full_weight_eligible());
499    }
500}