1pub mod agents;
12pub mod types;
13
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17pub use agents::{
18 AssumptionBreakerAgent, ConstraintCheck, ConstraintCheckerAgent, EconomicSkepticAgent,
19 OperationalSkepticAgent, OrgConstraint,
20};
21pub use types::{AdversarialVerdict, AgentId, Complexity};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Challenge {
27 pub id: Uuid,
28 pub kind: SkepticismKind,
29 pub target_plan: Uuid,
30 pub description: String,
31 pub severity: Severity,
32 pub evidence: Vec<String>,
33 pub suggestion: Option<String>,
34}
35
36impl Challenge {
37 pub fn new(
38 kind: SkepticismKind,
39 target_plan: Uuid,
40 description: impl Into<String>,
41 severity: Severity,
42 ) -> Self {
43 Self {
44 id: Uuid::new_v4(),
45 kind,
46 target_plan,
47 description: description.into(),
48 severity,
49 evidence: Vec::new(),
50 suggestion: None,
51 }
52 }
53
54 pub fn is_blocking(&self) -> bool {
55 self.severity == Severity::Blocker
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum SkepticismKind {
64 AssumptionBreaking,
65 ConstraintChecking,
66 CausalSkepticism,
67 EconomicSkepticism,
68 OperationalSkepticism,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Severity {
74 Advisory,
75 Warning,
76 Blocker,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Finding {
83 pub agent: String,
84 pub severity: Severity,
85 pub message: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct AdversarialSignal {
92 pub kind: SkepticismKind,
93 pub failed_assumption: String,
94 pub context: serde_json::Value,
95 pub revision_summary: Option<String>,
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use proptest::prelude::*;
102
103 fn plan_id() -> Uuid {
104 Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
105 }
106
107 #[test]
108 fn challenge_new_sets_defaults() {
109 let c = Challenge::new(
110 SkepticismKind::CausalSkepticism,
111 plan_id(),
112 "bad assumption",
113 Severity::Warning,
114 );
115 assert_eq!(c.kind, SkepticismKind::CausalSkepticism);
116 assert_eq!(c.target_plan, plan_id());
117 assert_eq!(c.description, "bad assumption");
118 assert_eq!(c.severity, Severity::Warning);
119 assert!(c.evidence.is_empty());
120 assert!(c.suggestion.is_none());
121 }
122
123 #[test]
124 fn challenge_new_generates_unique_ids() {
125 let a = Challenge::new(
126 SkepticismKind::AssumptionBreaking,
127 plan_id(),
128 "",
129 Severity::Advisory,
130 );
131 let b = Challenge::new(
132 SkepticismKind::AssumptionBreaking,
133 plan_id(),
134 "",
135 Severity::Advisory,
136 );
137 assert_ne!(a.id, b.id);
138 }
139
140 #[test]
141 fn is_blocking_only_for_blocker() {
142 let blocker = Challenge::new(
143 SkepticismKind::ConstraintChecking,
144 plan_id(),
145 "stop",
146 Severity::Blocker,
147 );
148 let warning = Challenge::new(
149 SkepticismKind::ConstraintChecking,
150 plan_id(),
151 "maybe",
152 Severity::Warning,
153 );
154 let advisory = Challenge::new(
155 SkepticismKind::ConstraintChecking,
156 plan_id(),
157 "fyi",
158 Severity::Advisory,
159 );
160 assert!(blocker.is_blocking());
161 assert!(!warning.is_blocking());
162 assert!(!advisory.is_blocking());
163 }
164
165 #[test]
166 fn challenge_new_accepts_string_and_str() {
167 let from_str = Challenge::new(
168 SkepticismKind::EconomicSkepticism,
169 plan_id(),
170 "lit",
171 Severity::Advisory,
172 );
173 let from_string = Challenge::new(
174 SkepticismKind::EconomicSkepticism,
175 plan_id(),
176 String::from("owned"),
177 Severity::Advisory,
178 );
179 assert_eq!(from_str.description, "lit");
180 assert_eq!(from_string.description, "owned");
181 }
182
183 #[test]
184 fn challenge_new_empty_description() {
185 let c = Challenge::new(
186 SkepticismKind::OperationalSkepticism,
187 plan_id(),
188 "",
189 Severity::Advisory,
190 );
191 assert_eq!(c.description, "");
192 }
193
194 #[test]
195 fn skepticism_kind_all_variants_distinct() {
196 let variants = [
197 SkepticismKind::AssumptionBreaking,
198 SkepticismKind::ConstraintChecking,
199 SkepticismKind::CausalSkepticism,
200 SkepticismKind::EconomicSkepticism,
201 SkepticismKind::OperationalSkepticism,
202 ];
203 for (i, a) in variants.iter().enumerate() {
204 for (j, b) in variants.iter().enumerate() {
205 assert_eq!(i == j, a == b);
206 }
207 }
208 }
209
210 #[test]
211 fn severity_all_variants_distinct() {
212 let variants = [Severity::Advisory, Severity::Warning, Severity::Blocker];
213 for (i, a) in variants.iter().enumerate() {
214 for (j, b) in variants.iter().enumerate() {
215 assert_eq!(i == j, a == b);
216 }
217 }
218 }
219
220 #[test]
221 fn challenge_serde_roundtrip() {
222 let mut c = Challenge::new(
223 SkepticismKind::EconomicSkepticism,
224 plan_id(),
225 "too expensive",
226 Severity::Blocker,
227 );
228 c.evidence = vec!["cost +40%".into()];
229 c.suggestion = Some("reduce scope".into());
230
231 let json = serde_json::to_string(&c).unwrap();
232 let back: Challenge = serde_json::from_str(&json).unwrap();
233 assert_eq!(back.id, c.id);
234 assert_eq!(back.kind, c.kind);
235 assert_eq!(back.description, c.description);
236 assert_eq!(back.severity, c.severity);
237 assert_eq!(back.evidence, c.evidence);
238 assert_eq!(back.suggestion, c.suggestion);
239 }
240
241 #[test]
242 fn finding_serde_roundtrip() {
243 let f = Finding {
244 agent: "economic-skeptic".into(),
245 severity: Severity::Warning,
246 message: "budget overrun".into(),
247 };
248 let json = serde_json::to_string(&f).unwrap();
249 let back: Finding = serde_json::from_str(&json).unwrap();
250 assert_eq!(back.agent, f.agent);
251 assert_eq!(back.message, f.message);
252 }
253
254 #[test]
255 fn adversarial_signal_serde_roundtrip() {
256 let s = AdversarialSignal {
257 kind: SkepticismKind::CausalSkepticism,
258 failed_assumption: "X causes Y".into(),
259 context: serde_json::json!({"key": "value"}),
260 revision_summary: Some("added control".into()),
261 };
262 let json = serde_json::to_string(&s).unwrap();
263 let back: AdversarialSignal = serde_json::from_str(&json).unwrap();
264 assert_eq!(back.kind, s.kind);
265 assert_eq!(back.failed_assumption, s.failed_assumption);
266 assert_eq!(back.context, s.context);
267 assert_eq!(back.revision_summary, s.revision_summary);
268 }
269
270 #[test]
271 fn adversarial_signal_none_revision() {
272 let s = AdversarialSignal {
273 kind: SkepticismKind::AssumptionBreaking,
274 failed_assumption: "assumption".into(),
275 context: serde_json::json!(null),
276 revision_summary: None,
277 };
278 let json = serde_json::to_string(&s).unwrap();
279 let back: AdversarialSignal = serde_json::from_str(&json).unwrap();
280 assert!(back.revision_summary.is_none());
281 }
282
283 #[test]
284 fn skepticism_kind_serde_snake_case() {
285 let json = serde_json::to_string(&SkepticismKind::AssumptionBreaking).unwrap();
286 assert_eq!(json, "\"assumption_breaking\"");
287 let json = serde_json::to_string(&SkepticismKind::CausalSkepticism).unwrap();
288 assert_eq!(json, "\"causal_skepticism\"");
289 }
290
291 #[test]
292 fn severity_serde_snake_case() {
293 let json = serde_json::to_string(&Severity::Blocker).unwrap();
294 assert_eq!(json, "\"blocker\"");
295 let json = serde_json::to_string(&Severity::Advisory).unwrap();
296 assert_eq!(json, "\"advisory\"");
297 }
298
299 proptest! {
300 #[test]
301 fn challenge_never_panics_on_arbitrary_description(desc in ".*") {
302 let c = Challenge::new(
303 SkepticismKind::OperationalSkepticism,
304 plan_id(),
305 desc.clone(),
306 Severity::Advisory,
307 );
308 prop_assert_eq!(c.description, desc);
309 }
310
311 #[test]
312 fn challenge_blocking_iff_blocker(sev in prop_oneof![
313 Just(Severity::Advisory),
314 Just(Severity::Warning),
315 Just(Severity::Blocker),
316 ]) {
317 let c = Challenge::new(SkepticismKind::AssumptionBreaking, plan_id(), "x", sev);
318 prop_assert_eq!(c.is_blocking(), sev == Severity::Blocker);
319 }
320 }
321}