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