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.clone(),
73 reason: format!(
74 "confidence {} below threshold {}",
75 proposal.confidence, self.config.min_confidence
76 ),
77 };
78 }
79
80 if proposal.content.len() > self.config.max_content_length {
81 return ValidationResult::Rejected {
82 proposal_id: proposal.id.clone(),
83 reason: format!(
84 "content length {} exceeds max {}",
85 proposal.content.len(),
86 self.config.max_content_length
87 ),
88 };
89 }
90
91 if proposal.content.trim().is_empty() {
92 return ValidationResult::Rejected {
93 proposal_id: proposal.id.clone(),
94 reason: "content is empty".into(),
95 };
96 }
97
98 if self.config.require_provenance && proposal.provenance.trim().is_empty() {
99 return ValidationResult::Rejected {
100 proposal_id: proposal.id.clone(),
101 reason: "provenance is required but empty".into(),
102 };
103 }
104
105 let content_lower = proposal.content.to_lowercase();
106 for term in &self.config.forbidden_terms {
107 if content_lower.contains(&term.to_lowercase()) {
108 return ValidationResult::Rejected {
109 proposal_id: proposal.id.clone(),
110 reason: format!("content contains forbidden term '{term}'"),
111 };
112 }
113 }
114
115 ValidationResult::Accepted(proposal.clone())
116 }
117}
118
119#[async_trait::async_trait]
120impl Suggestor for ValidationAgent {
121 fn name(&self) -> &str {
122 "ValidationAgent"
123 }
124
125 fn dependencies(&self) -> &[ContextKey] {
126 &[]
127 }
128
129 fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
130 ContextKey::iter().any(|key| !ctx.get_proposals(key).is_empty())
131 }
132
133 async fn execute(&self, ctx: &dyn crate::ContextView) -> AgentEffect {
134 let mut diagnostics = Vec::new();
135
136 for key in ContextKey::iter() {
137 for proposal in ctx.get_proposals(key) {
138 if let ValidationResult::Rejected {
139 proposal_id,
140 reason,
141 } = self.validate_proposal(proposal)
142 {
143 diagnostics.push(
144 ProposedFact::new(
145 ContextKey::Diagnostic,
146 format!("validation:rejected:{proposal_id}"),
147 format!("Proposal '{proposal_id}' rejected: {reason}"),
148 self.name(),
149 )
150 .with_confidence(1.0),
151 );
152 }
153 }
154 }
155
156 AgentEffect::with_proposals(diagnostics)
157 }
158}
159
160#[must_use]
162pub fn encode_proposal(proposal: &ProposedFact) -> ProposedFact {
163 proposal.clone()
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn validation_accepts_good_proposal() {
172 let agent = ValidationAgent::with_defaults();
173 let proposal = ProposedFact::new(
174 ContextKey::Hypotheses,
175 "hyp-1",
176 "Market is growing",
177 "gpt-4:abc123",
178 )
179 .with_confidence(0.8);
180
181 match agent.validate_proposal(&proposal) {
182 ValidationResult::Accepted(accepted) => {
183 assert_eq!(accepted.id, "hyp-1");
184 }
185 ValidationResult::Rejected { reason, .. } => {
186 panic!("Expected acceptance, got rejection: {reason}");
187 }
188 }
189 }
190
191 #[test]
192 fn validation_rejects_low_confidence() {
193 let agent = ValidationAgent::new(ValidationConfig {
194 min_confidence: 0.7,
195 ..Default::default()
196 });
197 let proposal =
198 ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "Uncertain claim", "gpt-4")
199 .with_confidence(0.3);
200
201 match agent.validate_proposal(&proposal) {
202 ValidationResult::Rejected { reason, .. } => {
203 assert!(reason.contains("confidence"));
204 }
205 ValidationResult::Accepted(_) => {
206 panic!("Expected rejection for low confidence");
207 }
208 }
209 }
210}