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
119impl Suggestor for ValidationAgent {
120 fn name(&self) -> &str {
121 "ValidationAgent"
122 }
123
124 fn dependencies(&self) -> &[ContextKey] {
125 &[]
126 }
127
128 fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
129 ContextKey::iter().any(|key| !ctx.get_proposals(key).is_empty())
130 }
131
132 fn execute(&self, ctx: &dyn crate::ContextView) -> AgentEffect {
133 let mut diagnostics = Vec::new();
134
135 for key in ContextKey::iter() {
136 for proposal in ctx.get_proposals(key) {
137 if let ValidationResult::Rejected {
138 proposal_id,
139 reason,
140 } = self.validate_proposal(proposal)
141 {
142 diagnostics.push(
143 ProposedFact::new(
144 ContextKey::Diagnostic,
145 format!("validation:rejected:{proposal_id}"),
146 format!("Proposal '{proposal_id}' rejected: {reason}"),
147 self.name(),
148 )
149 .with_confidence(1.0),
150 );
151 }
152 }
153 }
154
155 AgentEffect::with_proposals(diagnostics)
156 }
157}
158
159#[must_use]
161pub fn encode_proposal(proposal: &ProposedFact) -> ProposedFact {
162 proposal.clone()
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn validation_accepts_good_proposal() {
171 let agent = ValidationAgent::with_defaults();
172 let proposal = ProposedFact::new(
173 ContextKey::Hypotheses,
174 "hyp-1",
175 "Market is growing",
176 "gpt-4:abc123",
177 )
178 .with_confidence(0.8);
179
180 match agent.validate_proposal(&proposal) {
181 ValidationResult::Accepted(accepted) => {
182 assert_eq!(accepted.id, "hyp-1");
183 }
184 ValidationResult::Rejected { reason, .. } => {
185 panic!("Expected acceptance, got rejection: {reason}");
186 }
187 }
188 }
189
190 #[test]
191 fn validation_rejects_low_confidence() {
192 let agent = ValidationAgent::new(ValidationConfig {
193 min_confidence: 0.7,
194 ..Default::default()
195 });
196 let proposal =
197 ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "Uncertain claim", "gpt-4")
198 .with_confidence(0.3);
199
200 match agent.validate_proposal(&proposal) {
201 ValidationResult::Rejected { reason, .. } => {
202 assert!(reason.contains("confidence"));
203 }
204 ValidationResult::Accepted(_) => {
205 panic!("Expected rejection for low confidence");
206 }
207 }
208 }
209}