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 ToolArgumentInvalid,
62 WitnessStale,
64 ToolThrash,
67 ToolBatchConflict,
70 UnreadEdit,
72 BudgetPressure,
74 AdjudicatorObjection,
78 ProviderUnavailable,
81}
82
83impl ResidualClass {
84 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 ToolArgumentInvalid | WitnessStale | ToolThrash | ToolBatchConflict | UnreadEdit
97 | BudgetPressure => Str,
98 AdjudicatorObjection => Log,
99 Policy | CapabilityDenied | BudgetExhausted => Str,
102 }
103 }
104
105 pub fn is_admissibility_outcome(self) -> bool {
109 matches!(
110 self,
111 ResidualClass::CapabilityDenied | ResidualClass::BudgetExhausted
112 )
113 }
114}
115
116#[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 Blocking,
125}
126
127#[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 pub fn is_full_weight_eligible(self) -> bool {
147 !matches!(self, IndependenceRoute::SameModelCritique)
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct SensorRef {
154 pub id: String,
156 pub route: IndependenceRoute,
158 #[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 pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
176 self.fingerprint = fingerprint.into();
177 self
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct CorrectionPacketRef(pub String);
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct ResidualClusterRef {
190 pub cluster_id: String,
191 pub class: ResidualClass,
192 pub root_cause: String,
194 pub member_count: u32,
195 pub magnitude: f64,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct StructuredDiagnosticRef {
202 #[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
216pub struct AffectedSet {
217 pub paths: Vec<String>,
218 pub symbols: Vec<SymbolRef>,
219 pub spans: Vec<String>,
221 pub tests: Vec<String>,
222 pub graph_edges: Vec<String>,
223}
224
225#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct MissingSensorDecl {
239 pub sensor: String,
240 pub reason: String,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct NoGoodRef {
247 pub key: String,
248 pub evidence_hash: String,
249}
250
251#[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 pub fn is_empty(&self) -> bool {
273 self.operators.is_empty() && self.diagnostics.is_empty()
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct SymbolRef {
280 pub name: String,
281 pub container: Option<String>,
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287pub struct EvidencePayload {
288 pub summary: String,
290 pub raw: Option<String>,
292 pub structured: Option<serde_json::Value>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct CorrectionDirection {
300 pub direction_id: String,
301 pub addresses: ResidualClass,
303 pub instruction: String,
305 pub target_paths: Vec<String>,
307 pub target_symbols: Vec<SymbolRef>,
309 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#[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 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 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 pub fn is_admissibility_outcome(&self) -> bool {
406 self.class.is_admissibility_outcome()
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413pub struct ResidualEventRef {
414 pub residual_id: String,
415 pub class: ResidualClass,
416 pub component: EnergyComponent,
417 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}