converge_core/
validation.rs1#![allow(clippy::unnecessary_literal_bound)]
11
12use crate::agent::Suggestor;
13use crate::context::{ContextKey, ProposedFact};
14use crate::effect::AgentEffect;
15use strum::IntoEnumIterator;
16
17#[derive(Debug, Clone)]
19pub struct ValidationConfig {
20 pub min_confidence: f64,
22 pub max_content_length: usize,
24 pub forbidden_terms: Vec<String>,
26 pub require_provenance: bool,
28}
29
30impl Default for ValidationConfig {
31 fn default() -> Self {
32 Self {
33 min_confidence: 0.5,
34 max_content_length: 10_000,
35 forbidden_terms: vec![],
36 require_provenance: true,
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub enum ValidationResult {
44 Accepted(ProposedFact),
46 Rejected { proposal_id: String, reason: String },
48}
49
50pub struct ValidationAgent {
52 config: ValidationConfig,
53}
54
55impl ValidationAgent {
56 #[must_use]
58 pub fn new(config: ValidationConfig) -> Self {
59 Self { config }
60 }
61
62 #[must_use]
64 pub fn with_defaults() -> Self {
65 Self::new(ValidationConfig::default())
66 }
67
68 pub fn validate_proposal(&self, proposal: &ProposedFact) -> ValidationResult {
70 if proposal.confidence() < self.config.min_confidence {
71 return ValidationResult::Rejected {
72 proposal_id: proposal.id.to_string(),
73 reason: format!(
74 "confidence {} below threshold {}",
75 proposal.confidence(),
76 self.config.min_confidence
77 ),
78 };
79 }
80
81 if proposal.content.len() > self.config.max_content_length {
82 return ValidationResult::Rejected {
83 proposal_id: proposal.id.to_string(),
84 reason: format!(
85 "content length {} exceeds max {}",
86 proposal.content.len(),
87 self.config.max_content_length
88 ),
89 };
90 }
91
92 if proposal.content.trim().is_empty() {
93 return ValidationResult::Rejected {
94 proposal_id: proposal.id.to_string(),
95 reason: "content is empty".into(),
96 };
97 }
98
99 if self.config.require_provenance && proposal.provenance.trim().is_empty() {
100 return ValidationResult::Rejected {
101 proposal_id: proposal.id.to_string(),
102 reason: "provenance is required but empty".into(),
103 };
104 }
105
106 let content_lower = proposal.content.to_lowercase();
107 for term in &self.config.forbidden_terms {
108 if content_lower.contains(&term.to_lowercase()) {
109 return ValidationResult::Rejected {
110 proposal_id: proposal.id.to_string(),
111 reason: format!("content contains forbidden term '{term}'"),
112 };
113 }
114 }
115
116 ValidationResult::Accepted(proposal.clone())
117 }
118}
119
120#[async_trait::async_trait]
121impl Suggestor for ValidationAgent {
122 fn name(&self) -> &str {
123 "ValidationAgent"
124 }
125
126 fn dependencies(&self) -> &[ContextKey] {
127 &[]
128 }
129
130 fn accepts(&self, ctx: &dyn crate::Context) -> bool {
131 ContextKey::iter().any(|key| !ctx.get_proposals(key).is_empty())
132 }
133
134 async fn execute(&self, ctx: &dyn crate::Context) -> AgentEffect {
135 let mut diagnostics = Vec::new();
136
137 for key in ContextKey::iter() {
138 for proposal in ctx.get_proposals(key) {
139 if let ValidationResult::Rejected {
140 proposal_id,
141 reason,
142 } = self.validate_proposal(proposal)
143 {
144 diagnostics.push(
145 ProposedFact::new(
146 ContextKey::Diagnostic,
147 format!("validation:rejected:{proposal_id}"),
148 format!("Proposal '{proposal_id}' rejected: {reason}"),
149 self.name(),
150 )
151 .with_confidence(1.0),
152 );
153 }
154 }
155 }
156
157 AgentEffect::with_proposals(diagnostics)
158 }
159}
160
161#[must_use]
163pub fn encode_proposal(proposal: &ProposedFact) -> ProposedFact {
164 proposal.clone()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn validation_accepts_good_proposal() {
173 let agent = ValidationAgent::with_defaults();
174 let proposal = ProposedFact::new(
175 ContextKey::Hypotheses,
176 "hyp-1",
177 "Market is growing",
178 "gpt-4:abc123",
179 )
180 .with_confidence(0.8);
181
182 match agent.validate_proposal(&proposal) {
183 ValidationResult::Accepted(accepted) => {
184 assert_eq!(accepted.id, "hyp-1");
185 }
186 ValidationResult::Rejected { reason, .. } => {
187 panic!("Expected acceptance, got rejection: {reason}");
188 }
189 }
190 }
191
192 #[test]
193 fn validation_rejects_low_confidence() {
194 let agent = ValidationAgent::new(ValidationConfig {
195 min_confidence: 0.7,
196 ..Default::default()
197 });
198 let proposal =
199 ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "Uncertain claim", "gpt-4")
200 .with_confidence(0.3);
201
202 match agent.validate_proposal(&proposal) {
203 ValidationResult::Rejected { reason, .. } => {
204 assert!(reason.contains("confidence"));
205 }
206 ValidationResult::Accepted(_) => {
207 panic!("Expected rejection for low confidence");
208 }
209 }
210 }
211}