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