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};
14use crate::effect::AgentEffect;
15use strum::IntoEnumIterator;
16
17/// Configuration for the validation agent.
18#[derive(Debug, Clone)]
19pub struct ValidationConfig {
20    /// Minimum confidence threshold (0.0 - 1.0).
21    pub min_confidence: f64,
22    /// Maximum content length allowed.
23    pub max_content_length: usize,
24    /// Forbidden terms that cause rejection.
25    pub forbidden_terms: Vec<String>,
26    /// Whether to require provenance information.
27    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/// Result of validating a proposal.
42#[derive(Debug, Clone)]
43pub enum ValidationResult {
44    /// Proposal accepted.
45    Accepted(ProposedFact),
46    /// Proposal rejected with reason.
47    Rejected { proposal_id: String, reason: String },
48}
49
50/// Compatibility validator for staged proposals.
51pub struct ValidationAgent {
52    config: ValidationConfig,
53}
54
55impl ValidationAgent {
56    /// Creates a new validation agent with the given config.
57    #[must_use]
58    pub fn new(config: ValidationConfig) -> Self {
59        Self { config }
60    }
61
62    /// Creates a validation agent with default config.
63    #[must_use]
64    pub fn with_defaults() -> Self {
65        Self::new(ValidationConfig::default())
66    }
67
68    /// Validates a single proposal against the config.
69    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.to_string(),
73                reason: format!(
74                    "confidence {} below threshold {}",
75                    proposal.confidence(),
76                    self.config.min_confidence
77                ),
78            };
79        }
80
81        if proposal.content.len() > self.config.max_content_length {
82            return ValidationResult::Rejected {
83                proposal_id: proposal.id.to_string(),
84                reason: format!(
85                    "content length {} exceeds max {}",
86                    proposal.content.len(),
87                    self.config.max_content_length
88                ),
89            };
90        }
91
92        if proposal.content.trim().is_empty() {
93            return ValidationResult::Rejected {
94                proposal_id: proposal.id.to_string(),
95                reason: "content is empty".into(),
96            };
97        }
98
99        if self.config.require_provenance && proposal.provenance.trim().is_empty() {
100            return ValidationResult::Rejected {
101                proposal_id: proposal.id.to_string(),
102                reason: "provenance is required but empty".into(),
103            };
104        }
105
106        let content_lower = proposal.content.to_lowercase();
107        for term in &self.config.forbidden_terms {
108            if content_lower.contains(&term.to_lowercase()) {
109                return ValidationResult::Rejected {
110                    proposal_id: proposal.id.to_string(),
111                    reason: format!("content contains forbidden term '{term}'"),
112                };
113            }
114        }
115
116        ValidationResult::Accepted(proposal.clone())
117    }
118}
119
120#[async_trait::async_trait]
121impl Suggestor for ValidationAgent {
122    fn name(&self) -> &str {
123        "ValidationAgent"
124    }
125
126    fn dependencies(&self) -> &[ContextKey] {
127        &[]
128    }
129
130    fn accepts(&self, ctx: &dyn crate::Context) -> bool {
131        ContextKey::iter().any(|key| !ctx.get_proposals(key).is_empty())
132    }
133
134    async fn execute(&self, ctx: &dyn crate::Context) -> AgentEffect {
135        let mut diagnostics = Vec::new();
136
137        for key in ContextKey::iter() {
138            for proposal in ctx.get_proposals(key) {
139                if let ValidationResult::Rejected {
140                    proposal_id,
141                    reason,
142                } = self.validate_proposal(proposal)
143                {
144                    diagnostics.push(
145                        ProposedFact::new(
146                            ContextKey::Diagnostic,
147                            format!("validation:rejected:{proposal_id}"),
148                            format!("Proposal '{proposal_id}' rejected: {reason}"),
149                            self.name(),
150                        )
151                        .with_confidence(1.0),
152                    );
153                }
154            }
155        }
156
157        AgentEffect::with_proposals(diagnostics)
158    }
159}
160
161/// Compatibility helper retained for legacy call sites and tests.
162#[must_use]
163pub fn encode_proposal(proposal: &ProposedFact) -> ProposedFact {
164    proposal.clone()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn validation_accepts_good_proposal() {
173        let agent = ValidationAgent::with_defaults();
174        let proposal = ProposedFact::new(
175            ContextKey::Hypotheses,
176            "hyp-1",
177            "Market is growing",
178            "gpt-4:abc123",
179        )
180        .with_confidence(0.8);
181
182        match agent.validate_proposal(&proposal) {
183            ValidationResult::Accepted(accepted) => {
184                assert_eq!(accepted.id, "hyp-1");
185            }
186            ValidationResult::Rejected { reason, .. } => {
187                panic!("Expected acceptance, got rejection: {reason}");
188            }
189        }
190    }
191
192    #[test]
193    fn validation_rejects_low_confidence() {
194        let agent = ValidationAgent::new(ValidationConfig {
195            min_confidence: 0.7,
196            ..Default::default()
197        });
198        let proposal =
199            ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "Uncertain claim", "gpt-4")
200                .with_confidence(0.3);
201
202        match agent.validate_proposal(&proposal) {
203            ValidationResult::Rejected { reason, .. } => {
204                assert!(reason.contains("confidence"));
205            }
206            ValidationResult::Accepted(_) => {
207                panic!("Expected rejection for low confidence");
208            }
209        }
210    }
211}