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