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 crate::suggestors::CONVERGE_CORE_PROVENANCE;
16use converge_pack::{Provenance, ProvenanceSource, UnitInterval};
17use strum::IntoEnumIterator;
18
19/// Configuration for the validation agent.
20#[derive(Debug, Clone)]
21pub struct ValidationConfig {
22    /// Minimum confidence threshold (0.0 - 1.0).
23    pub min_confidence: UnitInterval,
24    /// Maximum content length allowed.
25    pub max_content_length: usize,
26    /// Forbidden terms that cause rejection.
27    pub forbidden_terms: Vec<String>,
28    /// Whether to require provenance information.
29    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/// Result of validating a proposal.
44#[derive(Debug, Clone)]
45pub enum ValidationResult {
46    /// Proposal accepted.
47    Accepted(ProposedFact),
48    /// Proposal rejected with reason.
49    Rejected { proposal_id: String, reason: String },
50}
51
52/// Compatibility validator for staged proposals.
53pub struct ValidationAgent {
54    config: ValidationConfig,
55}
56
57impl ValidationAgent {
58    /// Creates a new validation agent with the given config.
59    #[must_use]
60    pub fn new(config: ValidationConfig) -> Self {
61        Self { config }
62    }
63
64    /// Creates a validation agent with default config.
65    #[must_use]
66    pub fn with_defaults() -> Self {
67        Self::new(ValidationConfig::default())
68    }
69
70    /// Validates a single proposal against the config.
71    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/// Compatibility helper retained for legacy call sites and tests.
179#[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}