1use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::ContextState;
10use crate::gates::StopReason;
11use crate::invariant::InvariantClass;
12
13#[derive(Debug, Error, Serialize)]
18pub enum ConvergeError {
19 #[error("budget exhausted: {kind}")]
21 BudgetExhausted { kind: String },
22
23 #[error("{class:?} invariant '{name}' violated: {reason}")]
25 InvariantViolation {
26 name: String,
28 class: InvariantClass,
30 reason: String,
32 context: Box<ContextState>,
34 },
35
36 #[error("agent failed: {agent_id}")]
38 AgentFailed { agent_id: String },
39
40 #[error(
44 "suggestor '{suggestor}' emitted proposals with empty provenance — \
45 stamp each ProposedFact with a non-empty ProvenanceSource string"
46 )]
47 EmptyProvenance {
48 suggestor: String,
50 },
51
52 #[error("invalid gate resume: {reason}")]
54 InvalidResume {
55 reason: String,
57 },
58
59 #[error("invalid admission: {reason}")]
61 InvalidAdmission {
62 reason: String,
64 },
65
66 #[error("invalid context snapshot: {reason}")]
68 InvalidSnapshot {
69 reason: String,
71 },
72
73 #[error(
75 "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
76 )]
77 Conflict {
78 id: String,
80 existing: String,
82 new: String,
84 context: Box<ContextState>,
86 },
87}
88
89impl ConvergeError {
90 #[must_use]
92 pub fn context(&self) -> Option<&ContextState> {
93 match self {
94 Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
95 Some(context)
96 }
97 Self::BudgetExhausted { .. }
98 | Self::AgentFailed { .. }
99 | Self::EmptyProvenance { .. }
100 | Self::InvalidResume { .. }
101 | Self::InvalidAdmission { .. }
102 | Self::InvalidSnapshot { .. } => None,
103 }
104 }
105
106 #[must_use]
108 pub fn stop_reason(&self) -> StopReason {
109 match self {
110 Self::BudgetExhausted { kind } => StopReason::Error {
111 message: format!("budget exhausted: {kind}"),
112 category: crate::gates::ErrorCategory::Resource,
113 },
114 Self::InvariantViolation {
115 name,
116 class,
117 reason,
118 ..
119 } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
120 Self::AgentFailed { agent_id } => StopReason::AgentRefused {
121 agent_id: agent_id.clone(),
122 reason: "agent execution failed".to_string(),
123 },
124 Self::EmptyProvenance { suggestor } => StopReason::AgentRefused {
125 agent_id: suggestor.clone(),
126 reason: "suggestor emitted proposals with empty provenance".to_string(),
127 },
128 Self::InvalidResume { reason } => StopReason::Error {
129 message: format!("invalid gate resume: {reason}"),
130 category: crate::gates::ErrorCategory::Internal,
131 },
132 Self::InvalidAdmission { reason } => StopReason::Error {
133 message: format!("invalid admission: {reason}"),
134 category: crate::gates::ErrorCategory::Configuration,
135 },
136 Self::InvalidSnapshot { reason } => StopReason::Error {
137 message: format!("invalid context snapshot: {reason}"),
138 category: crate::gates::ErrorCategory::Configuration,
139 },
140 Self::Conflict {
141 id, existing, new, ..
142 } => StopReason::Error {
143 message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
144 category: crate::gates::ErrorCategory::Internal,
145 },
146 }
147 }
148}
149
150impl From<crate::AdmissionError> for ConvergeError {
151 fn from(value: crate::AdmissionError) -> Self {
152 Self::InvalidAdmission {
153 reason: value.to_string(),
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn empty_context() -> ContextState {
163 ContextState::default()
164 }
165
166 #[test]
167 fn budget_exhausted_display() {
168 let err = ConvergeError::BudgetExhausted {
169 kind: "cycles".into(),
170 };
171 assert_eq!(err.to_string(), "budget exhausted: cycles");
172 }
173
174 #[test]
175 fn budget_exhausted_has_no_context() {
176 let err = ConvergeError::BudgetExhausted {
177 kind: "tokens".into(),
178 };
179 assert!(err.context().is_none());
180 }
181
182 #[test]
183 fn agent_failed_display() {
184 let err = ConvergeError::AgentFailed {
185 agent_id: "agent-x".into(),
186 };
187 assert_eq!(err.to_string(), "agent failed: agent-x");
188 }
189
190 #[test]
191 fn agent_failed_has_no_context() {
192 let err = ConvergeError::AgentFailed {
193 agent_id: "a".into(),
194 };
195 assert!(err.context().is_none());
196 }
197
198 #[test]
199 fn invariant_violation_has_context() {
200 let err = ConvergeError::InvariantViolation {
201 name: "no_empty".into(),
202 class: InvariantClass::Structural,
203 reason: "empty found".into(),
204 context: Box::new(empty_context()),
205 };
206 assert!(err.context().is_some());
207 }
208
209 #[test]
210 fn invariant_violation_display() {
211 let err = ConvergeError::InvariantViolation {
212 name: "no_empty".into(),
213 class: InvariantClass::Semantic,
214 reason: "bad".into(),
215 context: Box::new(empty_context()),
216 };
217 assert_eq!(
218 err.to_string(),
219 "Semantic invariant 'no_empty' violated: bad"
220 );
221 }
222
223 #[test]
224 fn conflict_has_context() {
225 let err = ConvergeError::Conflict {
226 id: "fact-1".into(),
227 existing: "old".into(),
228 new: "new".into(),
229 context: Box::new(empty_context()),
230 };
231 assert!(err.context().is_some());
232 }
233
234 #[test]
235 fn conflict_display() {
236 let err = ConvergeError::Conflict {
237 id: "f".into(),
238 existing: "a".into(),
239 new: "b".into(),
240 context: Box::new(empty_context()),
241 };
242 assert!(err.to_string().contains("conflict detected for fact 'f'"));
243 }
244
245 #[test]
246 fn stop_reason_budget_exhausted() {
247 let err = ConvergeError::BudgetExhausted {
248 kind: "time".into(),
249 };
250 let reason = err.stop_reason();
251 assert!(matches!(reason, StopReason::Error { .. }));
252 }
253
254 #[test]
255 fn stop_reason_invariant_violated() {
256 let err = ConvergeError::InvariantViolation {
257 name: "inv".into(),
258 class: InvariantClass::Acceptance,
259 reason: "fail".into(),
260 context: Box::new(empty_context()),
261 };
262 let reason = err.stop_reason();
263 assert!(matches!(reason, StopReason::InvariantViolated { .. }));
264 }
265
266 #[test]
267 fn stop_reason_agent_refused() {
268 let err = ConvergeError::AgentFailed {
269 agent_id: "bot".into(),
270 };
271 let reason = err.stop_reason();
272 assert!(matches!(reason, StopReason::AgentRefused { .. }));
273 }
274
275 #[test]
276 fn invalid_resume_display() {
277 let err = ConvergeError::InvalidResume {
278 reason: "gate_id mismatch".into(),
279 };
280 assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
281 }
282
283 #[test]
284 fn invalid_resume_has_no_context() {
285 let err = ConvergeError::InvalidResume {
286 reason: "test".into(),
287 };
288 assert!(err.context().is_none());
289 }
290
291 #[test]
292 fn stop_reason_invalid_resume() {
293 let err = ConvergeError::InvalidResume {
294 reason: "wrong gate".into(),
295 };
296 let reason = err.stop_reason();
297 assert!(matches!(reason, StopReason::Error { .. }));
298 }
299
300 #[test]
301 fn stop_reason_conflict() {
302 let err = ConvergeError::Conflict {
303 id: "x".into(),
304 existing: "old".into(),
305 new: "new".into(),
306 context: Box::new(empty_context()),
307 };
308 let reason = err.stop_reason();
309 assert!(matches!(reason, StopReason::Error { .. }));
310 }
311}