1use serde::{Deserialize, Serialize};
19
20use crate::invariant::InvariantClass;
21use crate::{ApprovalPointId, CriterionId, GateId, ProposalId};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[non_exhaustive]
36pub enum StopReason {
37 Converged,
43
44 CriteriaMet {
47 criteria: Vec<CriterionId>,
49 },
50
51 UserCancelled,
54
55 HumanInterventionRequired {
60 criteria: Vec<CriterionId>,
62 approval_refs: Vec<ApprovalPointId>,
64 },
65
66 CycleBudgetExhausted {
72 cycles_executed: u32,
74 limit: u32,
76 },
77
78 FactBudgetExhausted {
81 facts_count: u32,
83 limit: u32,
85 },
86
87 TokenBudgetExhausted {
90 tokens_consumed: u64,
92 limit: u64,
94 },
95
96 TimeBudgetExhausted {
99 duration_ms: u64,
101 limit_ms: u64,
103 },
104
105 InvariantViolated {
111 class: InvariantClass,
113 name: String,
115 reason: String,
117 },
118
119 PromotionRejected {
122 proposal_id: ProposalId,
124 reason: String,
126 },
127
128 Error {
134 message: String,
136 category: ErrorCategory,
138 },
139
140 AgentRefused {
143 agent_id: String,
145 reason: String,
147 },
148
149 HitlGatePending {
157 gate_id: GateId,
159 proposal_id: ProposalId,
161 summary: String,
163 agent_id: String,
165 cycle: u32,
167 },
168}
169
170impl StopReason {
171 pub fn converged() -> Self {
177 Self::Converged
178 }
179
180 pub fn criteria_met(criteria: Vec<CriterionId>) -> Self {
182 Self::CriteriaMet { criteria }
183 }
184
185 pub fn user_cancelled() -> Self {
187 Self::UserCancelled
188 }
189
190 pub fn human_intervention_required(
192 criteria: Vec<CriterionId>,
193 approval_refs: Vec<ApprovalPointId>,
194 ) -> Self {
195 Self::HumanInterventionRequired {
196 criteria,
197 approval_refs,
198 }
199 }
200
201 pub fn cycle_budget_exhausted(cycles_executed: u32, limit: u32) -> Self {
203 Self::CycleBudgetExhausted {
204 cycles_executed,
205 limit,
206 }
207 }
208
209 pub fn fact_budget_exhausted(facts_count: u32, limit: u32) -> Self {
211 Self::FactBudgetExhausted { facts_count, limit }
212 }
213
214 pub fn token_budget_exhausted(tokens_consumed: u64, limit: u64) -> Self {
216 Self::TokenBudgetExhausted {
217 tokens_consumed,
218 limit,
219 }
220 }
221
222 pub fn time_budget_exhausted(duration_ms: u64, limit_ms: u64) -> Self {
224 Self::TimeBudgetExhausted {
225 duration_ms,
226 limit_ms,
227 }
228 }
229
230 pub fn invariant_violated(
232 class: InvariantClass,
233 name: impl Into<String>,
234 reason: impl Into<String>,
235 ) -> Self {
236 Self::InvariantViolated {
237 class,
238 name: name.into(),
239 reason: reason.into(),
240 }
241 }
242
243 pub fn promotion_rejected(
245 proposal_id: impl Into<ProposalId>,
246 reason: impl Into<String>,
247 ) -> Self {
248 Self::PromotionRejected {
249 proposal_id: proposal_id.into(),
250 reason: reason.into(),
251 }
252 }
253
254 pub fn error(message: impl Into<String>, category: ErrorCategory) -> Self {
256 Self::Error {
257 message: message.into(),
258 category,
259 }
260 }
261
262 pub fn agent_refused(agent_id: impl Into<String>, reason: impl Into<String>) -> Self {
264 Self::AgentRefused {
265 agent_id: agent_id.into(),
266 reason: reason.into(),
267 }
268 }
269
270 pub fn hitl_gate_pending(
272 gate_id: impl Into<GateId>,
273 proposal_id: impl Into<ProposalId>,
274 summary: impl Into<String>,
275 agent_id: impl Into<String>,
276 cycle: u32,
277 ) -> Self {
278 Self::HitlGatePending {
279 gate_id: gate_id.into(),
280 proposal_id: proposal_id.into(),
281 summary: summary.into(),
282 agent_id: agent_id.into(),
283 cycle,
284 }
285 }
286
287 pub fn is_success(&self) -> bool {
293 matches!(
294 self,
295 Self::Converged | Self::CriteriaMet { .. } | Self::UserCancelled
296 )
297 }
298
299 pub fn is_budget_exhausted(&self) -> bool {
301 matches!(
302 self,
303 Self::CycleBudgetExhausted { .. }
304 | Self::FactBudgetExhausted { .. }
305 | Self::TokenBudgetExhausted { .. }
306 | Self::TimeBudgetExhausted { .. }
307 )
308 }
309
310 pub fn is_validation_failure(&self) -> bool {
312 matches!(
313 self,
314 Self::InvariantViolated { .. } | Self::PromotionRejected { .. }
315 )
316 }
317
318 pub fn is_error(&self) -> bool {
320 matches!(self, Self::Error { .. } | Self::AgentRefused { .. })
321 }
322
323 pub fn is_hitl_pending(&self) -> bool {
325 matches!(self, Self::HitlGatePending { .. })
326 }
327
328 pub fn is_human_intervention_required(&self) -> bool {
330 matches!(self, Self::HumanInterventionRequired { .. })
331 }
332}
333
334impl std::fmt::Display for StopReason {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 match self {
337 Self::Converged => write!(f, "Converged"),
338 Self::CriteriaMet { criteria } => {
339 write!(
340 f,
341 "Criteria met: {}",
342 criteria
343 .iter()
344 .map(ToString::to_string)
345 .collect::<Vec<_>>()
346 .join(", ")
347 )
348 }
349 Self::UserCancelled => write!(f, "User cancelled"),
350 Self::HumanInterventionRequired {
351 criteria,
352 approval_refs,
353 } => {
354 if approval_refs.is_empty() {
355 write!(
356 f,
357 "Human intervention required for: {}",
358 criteria
359 .iter()
360 .map(ToString::to_string)
361 .collect::<Vec<_>>()
362 .join(", ")
363 )
364 } else {
365 write!(
366 f,
367 "Human intervention required for: {} (refs: {})",
368 criteria
369 .iter()
370 .map(ToString::to_string)
371 .collect::<Vec<_>>()
372 .join(", "),
373 approval_refs
374 .iter()
375 .map(ToString::to_string)
376 .collect::<Vec<_>>()
377 .join(", ")
378 )
379 }
380 }
381 Self::CycleBudgetExhausted {
382 cycles_executed,
383 limit,
384 } => {
385 write!(f, "Cycle budget exhausted: {}/{}", cycles_executed, limit)
386 }
387 Self::FactBudgetExhausted { facts_count, limit } => {
388 write!(f, "Fact budget exhausted: {}/{}", facts_count, limit)
389 }
390 Self::TokenBudgetExhausted {
391 tokens_consumed,
392 limit,
393 } => {
394 write!(f, "Token budget exhausted: {}/{}", tokens_consumed, limit)
395 }
396 Self::TimeBudgetExhausted {
397 duration_ms,
398 limit_ms,
399 } => {
400 write!(f, "Time budget exhausted: {}ms/{}ms", duration_ms, limit_ms)
401 }
402 Self::InvariantViolated {
403 class,
404 name,
405 reason,
406 } => {
407 write!(f, "{:?} invariant '{}' violated: {}", class, name, reason)
408 }
409 Self::PromotionRejected {
410 proposal_id,
411 reason,
412 } => {
413 write!(f, "Promotion rejected for '{}': {}", proposal_id, reason)
414 }
415 Self::Error { message, category } => {
416 write!(f, "Error ({:?}): {}", category, message)
417 }
418 Self::AgentRefused { agent_id, reason } => {
419 write!(f, "Suggestor '{}' refused: {}", agent_id, reason)
420 }
421 Self::HitlGatePending {
422 gate_id,
423 agent_id,
424 cycle,
425 ..
426 } => {
427 write!(
428 f,
429 "HITL gate pending: {} (agent: {}, cycle: {})",
430 gate_id, agent_id, cycle
431 )
432 }
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
439pub enum ErrorCategory {
440 Internal,
442 Configuration,
444 External,
446 Resource,
448 Unknown,
450}
451
452impl Default for ErrorCategory {
453 fn default() -> Self {
454 Self::Unknown
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_converged_constructor() {
464 let reason = StopReason::converged();
465 assert!(matches!(reason, StopReason::Converged));
466 assert!(reason.is_success());
467 assert!(!reason.is_budget_exhausted());
468 assert!(!reason.is_validation_failure());
469 assert!(!reason.is_error());
470 }
471
472 #[test]
473 fn test_criteria_met_constructor() {
474 let reason = StopReason::criteria_met(vec!["goal1".into(), "goal2".into()]);
475 if let StopReason::CriteriaMet { criteria } = &reason {
476 assert_eq!(criteria.len(), 2);
477 assert_eq!(criteria[0], "goal1");
478 assert_eq!(criteria[1], "goal2");
479 } else {
480 panic!("Expected CriteriaMet");
481 }
482 assert!(reason.is_success());
483 }
484
485 #[test]
486 fn test_user_cancelled_constructor() {
487 let reason = StopReason::user_cancelled();
488 assert!(matches!(reason, StopReason::UserCancelled));
489 assert!(reason.is_success());
490 }
491
492 #[test]
493 fn test_human_intervention_required_constructor() {
494 let reason = StopReason::human_intervention_required(
495 vec!["payment.confirmed".into()],
496 vec!["approval:top-up".into()],
497 );
498 if let StopReason::HumanInterventionRequired {
499 criteria,
500 approval_refs,
501 } = &reason
502 {
503 assert_eq!(criteria, &vec!["payment.confirmed".to_string()]);
504 assert_eq!(approval_refs, &vec!["approval:top-up".to_string()]);
505 } else {
506 panic!("Expected HumanInterventionRequired");
507 }
508 assert!(!reason.is_success());
509 assert!(reason.is_human_intervention_required());
510 }
511
512 #[test]
513 fn test_cycle_budget_exhausted_constructor() {
514 let reason = StopReason::cycle_budget_exhausted(100, 100);
515 if let StopReason::CycleBudgetExhausted {
516 cycles_executed,
517 limit,
518 } = &reason
519 {
520 assert_eq!(*cycles_executed, 100);
521 assert_eq!(*limit, 100);
522 } else {
523 panic!("Expected CycleBudgetExhausted");
524 }
525 assert!(!reason.is_success());
526 assert!(reason.is_budget_exhausted());
527 }
528
529 #[test]
530 fn test_fact_budget_exhausted_constructor() {
531 let reason = StopReason::fact_budget_exhausted(10000, 10000);
532 assert!(reason.is_budget_exhausted());
533 }
534
535 #[test]
536 fn test_token_budget_exhausted_constructor() {
537 let reason = StopReason::token_budget_exhausted(1_000_000, 1_000_000);
538 assert!(reason.is_budget_exhausted());
539 }
540
541 #[test]
542 fn test_time_budget_exhausted_constructor() {
543 let reason = StopReason::time_budget_exhausted(60000, 60000);
544 assert!(reason.is_budget_exhausted());
545 }
546
547 #[test]
548 fn test_invariant_violated_constructor() {
549 let reason = StopReason::invariant_violated(
550 InvariantClass::Structural,
551 "no_empty_facts",
552 "Found empty fact content",
553 );
554 if let StopReason::InvariantViolated {
555 class,
556 name,
557 reason: r,
558 } = &reason
559 {
560 assert_eq!(*class, InvariantClass::Structural);
561 assert_eq!(name, "no_empty_facts");
562 assert_eq!(r, "Found empty fact content");
563 } else {
564 panic!("Expected InvariantViolated");
565 }
566 assert!(reason.is_validation_failure());
567 }
568
569 #[test]
570 fn test_promotion_rejected_constructor() {
571 let reason = StopReason::promotion_rejected("proposal-123", "schema validation failed");
572 assert!(reason.is_validation_failure());
573 }
574
575 #[test]
576 fn test_error_constructor() {
577 let reason = StopReason::error("connection refused", ErrorCategory::External);
578 if let StopReason::Error { message, category } = &reason {
579 assert_eq!(message, "connection refused");
580 assert_eq!(*category, ErrorCategory::External);
581 } else {
582 panic!("Expected Error");
583 }
584 assert!(reason.is_error());
585 }
586
587 #[test]
588 fn test_agent_refused_constructor() {
589 let reason = StopReason::agent_refused("agent-1", "cannot process unsafe content");
590 assert!(reason.is_error());
591 }
592
593 #[test]
594 fn test_display_converged() {
595 let reason = StopReason::converged();
596 assert_eq!(reason.to_string(), "Converged");
597 }
598
599 #[test]
600 fn test_display_criteria_met() {
601 let reason = StopReason::criteria_met(vec!["g1".into(), "g2".into()]);
602 assert_eq!(reason.to_string(), "Criteria met: g1, g2");
603 }
604
605 #[test]
606 fn test_display_human_intervention_required() {
607 let reason = StopReason::human_intervention_required(
608 vec!["payment.confirmed".into()],
609 vec!["approval:top-up".into()],
610 );
611 assert_eq!(
612 reason.to_string(),
613 "Human intervention required for: payment.confirmed (refs: approval:top-up)"
614 );
615 }
616
617 #[test]
618 fn test_display_cycle_budget_exhausted() {
619 let reason = StopReason::cycle_budget_exhausted(50, 100);
620 assert_eq!(reason.to_string(), "Cycle budget exhausted: 50/100");
621 }
622
623 #[test]
624 fn test_display_invariant_violated() {
625 let reason =
626 StopReason::invariant_violated(InvariantClass::Semantic, "test_inv", "test reason");
627 assert_eq!(
628 reason.to_string(),
629 "Semantic invariant 'test_inv' violated: test reason"
630 );
631 }
632
633 #[test]
634 fn test_display_error() {
635 let reason = StopReason::error("oops", ErrorCategory::Internal);
636 assert_eq!(reason.to_string(), "Error (Internal): oops");
637 }
638
639 #[test]
640 fn test_serde_roundtrip() {
641 let reasons = vec![
642 StopReason::converged(),
643 StopReason::criteria_met(vec!["done".into()]),
644 StopReason::human_intervention_required(
645 vec!["approval".into()],
646 vec!["workflow:1".into()],
647 ),
648 StopReason::cycle_budget_exhausted(10, 10),
649 StopReason::invariant_violated(InvariantClass::Acceptance, "test", "reason"),
650 StopReason::error("msg", ErrorCategory::Configuration),
651 ];
652
653 for reason in reasons {
654 let json = serde_json::to_string(&reason).expect("serialize");
655 let back: StopReason = serde_json::from_str(&json).expect("deserialize");
656 assert_eq!(reason, back);
657 }
658 }
659
660 #[test]
661 fn test_error_category_default() {
662 let category = ErrorCategory::default();
663 assert_eq!(category, ErrorCategory::Unknown);
664 }
665}