Skip to main content

converge_core/
validation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! LLM and staged-proposal validation for Converge.
5//!
6//! The engine owns promotion. This module provides a compatibility validator
7//! that can inspect staged proposals and emit diagnostic proposals when they
8//! fail basic policy checks.
9
10#![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/// Configuration for the validation agent.
19#[derive(Debug, Clone)]
20pub struct ValidationConfig {
21    /// Minimum confidence threshold (0.0 - 1.0).
22    pub min_confidence: UnitInterval,
23    /// Maximum content length allowed.
24    pub max_content_length: usize,
25    /// Forbidden terms that cause rejection.
26    pub forbidden_terms: Vec<String>,
27    /// Whether to require provenance information.
28    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/// Result of validating a proposal.
43#[derive(Debug, Clone)]
44pub enum ValidationResult {
45    /// Proposal accepted.
46    Accepted(ProposedFact),
47    /// Proposal rejected with reason.
48    Rejected { proposal_id: String, reason: String },
49}
50
51/// Compatibility validator for staged proposals.
52pub struct ValidationAgent {
53    config: ValidationConfig,
54}
55
56impl ValidationAgent {
57    /// Creates a new validation agent with the given config.
58    #[must_use]
59    pub fn new(config: ValidationConfig) -> Self {
60        Self { config }
61    }
62
63    /// Creates a validation agent with default config.
64    #[must_use]
65    pub fn with_defaults() -> Self {
66        Self::new(ValidationConfig::default())
67    }
68
69    /// Validates a single proposal against the config.
70    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/// Compatibility helper retained for legacy call sites and tests.
174#[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}