1#![allow(clippy::unnecessary_literal_bound)]
39
40use crate::agent::Agent;
41use crate::context::{Context, ContextKey, Fact, ProposedFact};
42use crate::effect::AgentEffect;
43
44#[derive(Debug, Clone)]
46pub struct ValidationConfig {
47 pub min_confidence: f64,
50
51 pub max_content_length: usize,
53
54 pub forbidden_terms: Vec<String>,
56
57 pub require_provenance: bool,
59}
60
61impl Default for ValidationConfig {
62 fn default() -> Self {
63 Self {
64 min_confidence: 0.5,
65 max_content_length: 10_000,
66 forbidden_terms: vec![],
67 require_provenance: true,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub enum ValidationResult {
75 Accepted(Fact),
77 Rejected { proposal_id: String, reason: String },
79}
80
81pub struct ValidationAgent {
86 config: ValidationConfig,
87}
88
89impl ValidationAgent {
90 #[must_use]
92 pub fn new(config: ValidationConfig) -> Self {
93 Self { config }
94 }
95
96 #[must_use]
98 pub fn with_defaults() -> Self {
99 Self::new(ValidationConfig::default())
100 }
101
102 fn validate_proposal(&self, proposal: &ProposedFact) -> ValidationResult {
104 if proposal.confidence < self.config.min_confidence {
106 return ValidationResult::Rejected {
107 proposal_id: proposal.id.clone(),
108 reason: format!(
109 "confidence {} below threshold {}",
110 proposal.confidence, self.config.min_confidence
111 ),
112 };
113 }
114
115 if proposal.content.len() > self.config.max_content_length {
117 return ValidationResult::Rejected {
118 proposal_id: proposal.id.clone(),
119 reason: format!(
120 "content length {} exceeds max {}",
121 proposal.content.len(),
122 self.config.max_content_length
123 ),
124 };
125 }
126
127 if proposal.content.trim().is_empty() {
129 return ValidationResult::Rejected {
130 proposal_id: proposal.id.clone(),
131 reason: "content is empty".into(),
132 };
133 }
134
135 if self.config.require_provenance && proposal.provenance.trim().is_empty() {
137 return ValidationResult::Rejected {
138 proposal_id: proposal.id.clone(),
139 reason: "provenance is required but empty".into(),
140 };
141 }
142
143 let content_lower = proposal.content.to_lowercase();
145 for term in &self.config.forbidden_terms {
146 if content_lower.contains(&term.to_lowercase()) {
147 return ValidationResult::Rejected {
148 proposal_id: proposal.id.clone(),
149 reason: format!("content contains forbidden term '{term}'"),
150 };
151 }
152 }
153
154 match Fact::try_from(proposal.clone()) {
156 Ok(fact) => ValidationResult::Accepted(fact),
157 Err(e) => ValidationResult::Rejected {
158 proposal_id: proposal.id.clone(),
159 reason: e.reason,
160 },
161 }
162 }
163
164 fn parse_proposal(fact: &Fact) -> Option<ProposedFact> {
170 let id_parts: Vec<&str> = fact.id.splitn(3, ':').collect();
172 if id_parts.len() != 3 || id_parts[0] != "proposal" {
173 return None;
174 }
175
176 let target_key = match id_parts[1] {
177 "seeds" => ContextKey::Seeds,
178 "hypotheses" => ContextKey::Hypotheses,
179 "strategies" => ContextKey::Strategies,
180 "constraints" => ContextKey::Constraints,
181 "signals" => ContextKey::Signals,
182 "competitors" => ContextKey::Competitors,
183 "evaluations" => ContextKey::Evaluations,
184 _ => return None,
185 };
186
187 let actual_id = id_parts[2];
188
189 let content_parts: Vec<&str> = fact.content.splitn(3, '|').collect();
191 if content_parts.len() != 3 {
192 return None;
193 }
194
195 let confidence: f64 = content_parts[0].parse().ok()?;
196 let provenance = content_parts[1].to_string();
197 let content = content_parts[2].to_string();
198
199 Some(ProposedFact {
200 key: target_key,
201 id: actual_id.to_string(),
202 content,
203 confidence,
204 provenance,
205 })
206 }
207}
208
209impl Agent for ValidationAgent {
210 fn name(&self) -> &str {
211 "ValidationAgent"
212 }
213
214 fn dependencies(&self) -> &[ContextKey] {
215 &[ContextKey::Proposals]
216 }
217
218 fn accepts(&self, ctx: &Context) -> bool {
219 let proposals = ctx.get(ContextKey::Proposals);
221
222 for proposal_fact in proposals {
225 if let Some(proposal) = Self::parse_proposal(proposal_fact) {
226 let existing = ctx.get(proposal.key);
228 if !existing.iter().any(|f| f.id == proposal.id) {
229 return true; }
231 }
232 }
233
234 false
235 }
236
237 fn execute(&self, ctx: &Context) -> AgentEffect {
238 let proposals = ctx.get(ContextKey::Proposals);
239 let mut facts = Vec::new();
240
241 for proposal_fact in proposals {
242 if let Some(proposal) = Self::parse_proposal(proposal_fact) {
243 let existing = ctx.get(proposal.key);
245 if existing.iter().any(|f| f.id == proposal.id) {
246 continue;
247 }
248
249 match self.validate_proposal(&proposal) {
251 ValidationResult::Accepted(fact) => {
252 facts.push(fact);
253 }
254 ValidationResult::Rejected {
255 proposal_id,
256 reason,
257 } => {
258 facts.push(Fact {
260 key: ContextKey::Signals,
261 id: format!("validation:rejected:{proposal_id}"),
262 content: format!("Proposal '{proposal_id}' rejected: {reason}"),
263 });
264 }
265 }
266 }
267 }
268
269 AgentEffect::with_facts(facts)
270 }
271}
272
273#[must_use]
277pub fn encode_proposal(proposal: &ProposedFact) -> Fact {
278 let target_key_str = match proposal.key {
279 ContextKey::Seeds => "seeds",
280 ContextKey::Hypotheses => "hypotheses",
281 ContextKey::Strategies => "strategies",
282 ContextKey::Constraints => "constraints",
283 ContextKey::Signals => "signals",
284 ContextKey::Competitors => "competitors",
285 ContextKey::Evaluations => "evaluations",
286 ContextKey::Proposals => "proposals", ContextKey::Diagnostic => "diagnostics",
288 };
289
290 Fact {
291 key: ContextKey::Proposals,
292 id: format!("proposal:{}:{}", target_key_str, proposal.id),
293 content: format!(
294 "{}|{}|{}",
295 proposal.confidence, proposal.provenance, proposal.content
296 ),
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::engine::Engine;
304
305 #[test]
306 fn validation_accepts_good_proposal() {
307 let agent = ValidationAgent::with_defaults();
308
309 let proposal = ProposedFact {
310 key: ContextKey::Hypotheses,
311 id: "hyp-1".into(),
312 content: "Market is growing".into(),
313 confidence: 0.8,
314 provenance: "gpt-4:abc123".into(),
315 };
316
317 match agent.validate_proposal(&proposal) {
318 ValidationResult::Accepted(fact) => {
319 assert_eq!(fact.key, ContextKey::Hypotheses);
320 assert_eq!(fact.id, "hyp-1");
321 assert_eq!(fact.content, "Market is growing");
322 }
323 ValidationResult::Rejected { reason, .. } => {
324 panic!("Expected acceptance, got rejection: {reason}");
325 }
326 }
327 }
328
329 #[test]
330 fn validation_rejects_low_confidence() {
331 let agent = ValidationAgent::new(ValidationConfig {
332 min_confidence: 0.7,
333 ..Default::default()
334 });
335
336 let proposal = ProposedFact {
337 key: ContextKey::Hypotheses,
338 id: "hyp-1".into(),
339 content: "Uncertain claim".into(),
340 confidence: 0.3, provenance: "gpt-4:abc123".into(),
342 };
343
344 match agent.validate_proposal(&proposal) {
345 ValidationResult::Rejected { reason, .. } => {
346 assert!(reason.contains("confidence"));
347 }
348 ValidationResult::Accepted(_) => {
349 panic!("Expected rejection for low confidence");
350 }
351 }
352 }
353
354 #[test]
355 fn validation_rejects_empty_content() {
356 let agent = ValidationAgent::with_defaults();
357
358 let proposal = ProposedFact {
359 key: ContextKey::Hypotheses,
360 id: "hyp-1".into(),
361 content: " ".into(), confidence: 0.9,
363 provenance: "gpt-4:abc123".into(),
364 };
365
366 match agent.validate_proposal(&proposal) {
367 ValidationResult::Rejected { reason, .. } => {
368 assert!(reason.contains("empty"));
369 }
370 ValidationResult::Accepted(_) => {
371 panic!("Expected rejection for empty content");
372 }
373 }
374 }
375
376 #[test]
377 fn validation_rejects_missing_provenance() {
378 let agent = ValidationAgent::new(ValidationConfig {
379 require_provenance: true,
380 ..Default::default()
381 });
382
383 let proposal = ProposedFact {
384 key: ContextKey::Hypotheses,
385 id: "hyp-1".into(),
386 content: "Some claim".into(),
387 confidence: 0.9,
388 provenance: String::new(), };
390
391 match agent.validate_proposal(&proposal) {
392 ValidationResult::Rejected { reason, .. } => {
393 assert!(reason.contains("provenance"));
394 }
395 ValidationResult::Accepted(_) => {
396 panic!("Expected rejection for missing provenance");
397 }
398 }
399 }
400
401 #[test]
402 fn validation_rejects_forbidden_terms() {
403 let agent = ValidationAgent::new(ValidationConfig {
404 forbidden_terms: vec!["guaranteed".into(), "100%".into()],
405 ..Default::default()
406 });
407
408 let proposal = ProposedFact {
409 key: ContextKey::Hypotheses,
410 id: "hyp-1".into(),
411 content: "This is GUARANTEED to work".into(),
412 confidence: 0.9,
413 provenance: "gpt-4:abc123".into(),
414 };
415
416 match agent.validate_proposal(&proposal) {
417 ValidationResult::Rejected { reason, .. } => {
418 assert!(reason.contains("guaranteed"));
419 }
420 ValidationResult::Accepted(_) => {
421 panic!("Expected rejection for forbidden term");
422 }
423 }
424 }
425
426 #[test]
427 fn encode_proposal_roundtrip() {
428 let proposal = ProposedFact {
429 key: ContextKey::Strategies,
430 id: "strat-1".into(),
431 content: "Focus on SMB".into(),
432 confidence: 0.85,
433 provenance: "claude-3:xyz".into(),
434 };
435
436 let encoded = encode_proposal(&proposal);
437 assert_eq!(encoded.key, ContextKey::Proposals);
438 assert_eq!(encoded.id, "proposal:strategies:strat-1");
439
440 let decoded = ValidationAgent::parse_proposal(&encoded).expect("should parse");
441 assert_eq!(decoded.key, proposal.key);
442 assert_eq!(decoded.id, proposal.id);
443 assert_eq!(decoded.content, proposal.content);
444 assert!((decoded.confidence - proposal.confidence).abs() < 0.001);
445 assert_eq!(decoded.provenance, proposal.provenance);
446 }
447
448 #[test]
449 fn validation_agent_promotes_in_engine() {
450 let mut engine = Engine::new();
451 engine.register(ValidationAgent::with_defaults());
452
453 let mut ctx = Context::new();
455 let proposal = ProposedFact {
456 key: ContextKey::Hypotheses,
457 id: "llm-hyp-1".into(),
458 content: "AI suggests market expansion".into(),
459 confidence: 0.75,
460 provenance: "gpt-4:test123".into(),
461 };
462 let _ = ctx.add_fact(encode_proposal(&proposal));
463
464 let result = engine.run(ctx).expect("should converge");
465
466 assert!(result.converged);
467
468 let hypotheses = result.context.get(ContextKey::Hypotheses);
470 assert_eq!(hypotheses.len(), 1);
471 assert_eq!(hypotheses[0].id, "llm-hyp-1");
472 assert_eq!(hypotheses[0].content, "AI suggests market expansion");
473 }
474
475 #[test]
476 fn validation_agent_rejects_bad_proposal_in_engine() {
477 let mut engine = Engine::new();
478 engine.register(ValidationAgent::new(ValidationConfig {
479 min_confidence: 0.8,
480 ..Default::default()
481 }));
482
483 let mut ctx = Context::new();
485 let proposal = ProposedFact {
486 key: ContextKey::Hypotheses,
487 id: "bad-hyp".into(),
488 content: "Uncertain speculation".into(),
489 confidence: 0.3, provenance: "gpt-4:test".into(),
491 };
492 let _ = ctx.add_fact(encode_proposal(&proposal));
493
494 let result = engine.run(ctx).expect("should converge");
495
496 assert!(result.converged);
497
498 let hypotheses = result.context.get(ContextKey::Hypotheses);
500 assert!(hypotheses.is_empty());
501
502 let signals = result.context.get(ContextKey::Signals);
504 assert!(signals.iter().any(|s| s.id.contains("rejected")));
505 }
506
507 #[test]
508 fn llm_cannot_bypass_validation() {
509 }
520}