1use serde::{Deserialize, Serialize};
11
12use crate::error::{check_non_negative_finite, Result};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum EnergyComponent {
20 Syn,
22 Str,
24 Log,
26 Boot,
28 Sheaf,
30}
31
32#[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 CapabilityDenied,
57 BudgetExhausted,
59}
60
61impl ResidualClass {
62 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 Policy | CapabilityDenied | BudgetExhausted => Str,
76 }
77 }
78
79 pub fn is_admissibility_outcome(self) -> bool {
83 matches!(
84 self,
85 ResidualClass::CapabilityDenied | ResidualClass::BudgetExhausted
86 )
87 }
88}
89
90#[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 Blocking,
99}
100
101#[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 pub fn is_full_weight_eligible(self) -> bool {
121 !matches!(self, IndependenceRoute::SameModelCritique)
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SensorRef {
128 pub id: String,
130 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SymbolRef {
146 pub name: String,
147 pub container: Option<String>,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153pub struct EvidencePayload {
154 pub summary: String,
156 pub raw: Option<String>,
158 pub structured: Option<serde_json::Value>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct CorrectionDirection {
166 pub direction_id: String,
167 pub addresses: ResidualClass,
169 pub instruction: String,
171 pub target_paths: Vec<String>,
173 pub target_symbols: Vec<SymbolRef>,
175 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#[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 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 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 pub fn is_admissibility_outcome(&self) -> bool {
272 self.class.is_admissibility_outcome()
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct ResidualEventRef {
280 pub residual_id: String,
281 pub class: ResidualClass,
282 pub component: EnergyComponent,
283 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}