converge_core/
validation.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! LLM output validation for Converge.
8//!
9//! This module provides the validation layer that ensures LLM outputs
10//! cannot corrupt the trusted context without explicit approval.
11//!
12//! # Safety Model
13//!
14//! ```text
15//! LLM Output → ProposedFact → ValidationAgent → Fact (if valid)
16//!                                  ↓
17//!                            Rejected (if invalid)
18//! ```
19//!
20//! # Example
21//!
22//! ```
23//! use converge_core::{Engine, Context, ContextKey, Fact};
24//! use converge_core::validation::{ValidationAgent, ValidationConfig};
25//!
26//! let mut engine = Engine::new();
27//!
28//! // Register validation agent with config
29//! engine.register(ValidationAgent::new(ValidationConfig {
30//!     min_confidence: 0.7,
31//!     ..Default::default()
32//! }));
33//!
34//! // Proposals in context will be validated and promoted to facts
35//! ```
36
37// Agent trait returns &str, but we return literals. This is fine.
38#![allow(clippy::unnecessary_literal_bound)]
39
40use crate::agent::Agent;
41use crate::context::{Context, ContextKey, Fact, ProposedFact};
42use crate::effect::AgentEffect;
43
44/// Configuration for the validation agent.
45#[derive(Debug, Clone)]
46pub struct ValidationConfig {
47    /// Minimum confidence threshold (0.0 - 1.0).
48    /// Proposals below this are rejected.
49    pub min_confidence: f64,
50
51    /// Maximum content length allowed.
52    pub max_content_length: usize,
53
54    /// Forbidden terms that cause rejection.
55    pub forbidden_terms: Vec<String>,
56
57    /// Whether to require provenance information.
58    pub require_provenance: bool,
59}
60
61impl Default for ValidationConfig {
62    fn default() -> Self {
63        Self {
64            min_confidence: 0.5,
65            max_content_length: 10_000,
66            forbidden_terms: vec![],
67            require_provenance: true,
68        }
69    }
70}
71
72/// Result of validating a proposal.
73#[derive(Debug, Clone)]
74pub enum ValidationResult {
75    /// Proposal accepted, here's the promoted fact.
76    Accepted(Fact),
77    /// Proposal rejected with reason.
78    Rejected { proposal_id: String, reason: String },
79}
80
81/// Agent that validates `ProposedFacts` and promotes them to Facts.
82///
83/// This is the **gateway** between untrusted LLM outputs and the trusted context.
84/// No LLM output can become a Fact without passing through validation.
85pub struct ValidationAgent {
86    config: ValidationConfig,
87}
88
89impl ValidationAgent {
90    /// Creates a new validation agent with the given config.
91    #[must_use]
92    pub fn new(config: ValidationConfig) -> Self {
93        Self { config }
94    }
95
96    /// Creates a validation agent with default config.
97    #[must_use]
98    pub fn with_defaults() -> Self {
99        Self::new(ValidationConfig::default())
100    }
101
102    /// Validates a single proposal against the config.
103    fn validate_proposal(&self, proposal: &ProposedFact) -> ValidationResult {
104        // Check confidence threshold
105        if proposal.confidence < self.config.min_confidence {
106            return ValidationResult::Rejected {
107                proposal_id: proposal.id.clone(),
108                reason: format!(
109                    "confidence {} below threshold {}",
110                    proposal.confidence, self.config.min_confidence
111                ),
112            };
113        }
114
115        // Check content length
116        if proposal.content.len() > self.config.max_content_length {
117            return ValidationResult::Rejected {
118                proposal_id: proposal.id.clone(),
119                reason: format!(
120                    "content length {} exceeds max {}",
121                    proposal.content.len(),
122                    self.config.max_content_length
123                ),
124            };
125        }
126
127        // Check for empty content
128        if proposal.content.trim().is_empty() {
129            return ValidationResult::Rejected {
130                proposal_id: proposal.id.clone(),
131                reason: "content is empty".into(),
132            };
133        }
134
135        // Check provenance requirement
136        if self.config.require_provenance && proposal.provenance.trim().is_empty() {
137            return ValidationResult::Rejected {
138                proposal_id: proposal.id.clone(),
139                reason: "provenance is required but empty".into(),
140            };
141        }
142
143        // Check forbidden terms
144        let content_lower = proposal.content.to_lowercase();
145        for term in &self.config.forbidden_terms {
146            if content_lower.contains(&term.to_lowercase()) {
147                return ValidationResult::Rejected {
148                    proposal_id: proposal.id.clone(),
149                    reason: format!("content contains forbidden term '{term}'"),
150                };
151            }
152        }
153
154        // All checks passed - try to convert
155        match Fact::try_from(proposal.clone()) {
156            Ok(fact) => ValidationResult::Accepted(fact),
157            Err(e) => ValidationResult::Rejected {
158                proposal_id: proposal.id.clone(),
159                reason: e.reason,
160            },
161        }
162    }
163
164    /// Parses a proposal fact from the Proposals key.
165    ///
166    /// Proposals are stored as Facts with a special encoding:
167    /// - id: "`proposal:{target_key}:{actual_id`}"
168    /// - content: JSON-like "{confidence}|{provenance}|{content}"
169    fn parse_proposal(fact: &Fact) -> Option<ProposedFact> {
170        // Parse id: "proposal:{target_key}:{id}"
171        let id_parts: Vec<&str> = fact.id.splitn(3, ':').collect();
172        if id_parts.len() != 3 || id_parts[0] != "proposal" {
173            return None;
174        }
175
176        let target_key = match id_parts[1] {
177            "seeds" => ContextKey::Seeds,
178            "hypotheses" => ContextKey::Hypotheses,
179            "strategies" => ContextKey::Strategies,
180            "constraints" => ContextKey::Constraints,
181            "signals" => ContextKey::Signals,
182            "competitors" => ContextKey::Competitors,
183            "evaluations" => ContextKey::Evaluations,
184            _ => return None,
185        };
186
187        let actual_id = id_parts[2];
188
189        // Parse content: "{confidence}|{provenance}|{content}"
190        let content_parts: Vec<&str> = fact.content.splitn(3, '|').collect();
191        if content_parts.len() != 3 {
192            return None;
193        }
194
195        let confidence: f64 = content_parts[0].parse().ok()?;
196        let provenance = content_parts[1].to_string();
197        let content = content_parts[2].to_string();
198
199        Some(ProposedFact {
200            key: target_key,
201            id: actual_id.to_string(),
202            content,
203            confidence,
204            provenance,
205        })
206    }
207}
208
209impl Agent for ValidationAgent {
210    fn name(&self) -> &str {
211        "ValidationAgent"
212    }
213
214    fn dependencies(&self) -> &[ContextKey] {
215        &[ContextKey::Proposals]
216    }
217
218    fn accepts(&self, ctx: &Context) -> bool {
219        // Run when there are proposals that haven't been validated yet
220        let proposals = ctx.get(ContextKey::Proposals);
221
222        // Check if any proposal hasn't been processed
223        // (i.e., its target fact doesn't exist yet)
224        for proposal_fact in proposals {
225            if let Some(proposal) = Self::parse_proposal(proposal_fact) {
226                // Check if this proposal has already been promoted
227                let existing = ctx.get(proposal.key);
228                if !existing.iter().any(|f| f.id == proposal.id) {
229                    return true; // Found an unprocessed proposal
230                }
231            }
232        }
233
234        false
235    }
236
237    fn execute(&self, ctx: &Context) -> AgentEffect {
238        let proposals = ctx.get(ContextKey::Proposals);
239        let mut facts = Vec::new();
240
241        for proposal_fact in proposals {
242            if let Some(proposal) = Self::parse_proposal(proposal_fact) {
243                // Skip if already promoted
244                let existing = ctx.get(proposal.key);
245                if existing.iter().any(|f| f.id == proposal.id) {
246                    continue;
247                }
248
249                // Validate and potentially promote
250                match self.validate_proposal(&proposal) {
251                    ValidationResult::Accepted(fact) => {
252                        facts.push(fact);
253                    }
254                    ValidationResult::Rejected {
255                        proposal_id,
256                        reason,
257                    } => {
258                        // Emit a rejection record for auditability
259                        facts.push(Fact {
260                            key: ContextKey::Signals,
261                            id: format!("validation:rejected:{proposal_id}"),
262                            content: format!("Proposal '{proposal_id}' rejected: {reason}"),
263                        });
264                    }
265                }
266            }
267        }
268
269        AgentEffect::with_facts(facts)
270    }
271}
272
273/// Helper to create a proposal fact for testing.
274///
275/// Encodes a `ProposedFact` into the format expected by `ValidationAgent`.
276#[must_use]
277pub fn encode_proposal(proposal: &ProposedFact) -> Fact {
278    let target_key_str = match proposal.key {
279        ContextKey::Seeds => "seeds",
280        ContextKey::Hypotheses => "hypotheses",
281        ContextKey::Strategies => "strategies",
282        ContextKey::Constraints => "constraints",
283        ContextKey::Signals => "signals",
284        ContextKey::Competitors => "competitors",
285        ContextKey::Evaluations => "evaluations",
286        ContextKey::Proposals => "proposals", // shouldn't happen but handle it
287        ContextKey::Diagnostic => "diagnostics",
288    };
289
290    Fact {
291        key: ContextKey::Proposals,
292        id: format!("proposal:{}:{}", target_key_str, proposal.id),
293        content: format!(
294            "{}|{}|{}",
295            proposal.confidence, proposal.provenance, proposal.content
296        ),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::engine::Engine;
304
305    #[test]
306    fn validation_accepts_good_proposal() {
307        let agent = ValidationAgent::with_defaults();
308
309        let proposal = ProposedFact {
310            key: ContextKey::Hypotheses,
311            id: "hyp-1".into(),
312            content: "Market is growing".into(),
313            confidence: 0.8,
314            provenance: "gpt-4:abc123".into(),
315        };
316
317        match agent.validate_proposal(&proposal) {
318            ValidationResult::Accepted(fact) => {
319                assert_eq!(fact.key, ContextKey::Hypotheses);
320                assert_eq!(fact.id, "hyp-1");
321                assert_eq!(fact.content, "Market is growing");
322            }
323            ValidationResult::Rejected { reason, .. } => {
324                panic!("Expected acceptance, got rejection: {reason}");
325            }
326        }
327    }
328
329    #[test]
330    fn validation_rejects_low_confidence() {
331        let agent = ValidationAgent::new(ValidationConfig {
332            min_confidence: 0.7,
333            ..Default::default()
334        });
335
336        let proposal = ProposedFact {
337            key: ContextKey::Hypotheses,
338            id: "hyp-1".into(),
339            content: "Uncertain claim".into(),
340            confidence: 0.3, // Below threshold
341            provenance: "gpt-4:abc123".into(),
342        };
343
344        match agent.validate_proposal(&proposal) {
345            ValidationResult::Rejected { reason, .. } => {
346                assert!(reason.contains("confidence"));
347            }
348            ValidationResult::Accepted(_) => {
349                panic!("Expected rejection for low confidence");
350            }
351        }
352    }
353
354    #[test]
355    fn validation_rejects_empty_content() {
356        let agent = ValidationAgent::with_defaults();
357
358        let proposal = ProposedFact {
359            key: ContextKey::Hypotheses,
360            id: "hyp-1".into(),
361            content: "   ".into(), // Empty after trim
362            confidence: 0.9,
363            provenance: "gpt-4:abc123".into(),
364        };
365
366        match agent.validate_proposal(&proposal) {
367            ValidationResult::Rejected { reason, .. } => {
368                assert!(reason.contains("empty"));
369            }
370            ValidationResult::Accepted(_) => {
371                panic!("Expected rejection for empty content");
372            }
373        }
374    }
375
376    #[test]
377    fn validation_rejects_missing_provenance() {
378        let agent = ValidationAgent::new(ValidationConfig {
379            require_provenance: true,
380            ..Default::default()
381        });
382
383        let proposal = ProposedFact {
384            key: ContextKey::Hypotheses,
385            id: "hyp-1".into(),
386            content: "Some claim".into(),
387            confidence: 0.9,
388            provenance: String::new(), // Missing
389        };
390
391        match agent.validate_proposal(&proposal) {
392            ValidationResult::Rejected { reason, .. } => {
393                assert!(reason.contains("provenance"));
394            }
395            ValidationResult::Accepted(_) => {
396                panic!("Expected rejection for missing provenance");
397            }
398        }
399    }
400
401    #[test]
402    fn validation_rejects_forbidden_terms() {
403        let agent = ValidationAgent::new(ValidationConfig {
404            forbidden_terms: vec!["guaranteed".into(), "100%".into()],
405            ..Default::default()
406        });
407
408        let proposal = ProposedFact {
409            key: ContextKey::Hypotheses,
410            id: "hyp-1".into(),
411            content: "This is GUARANTEED to work".into(),
412            confidence: 0.9,
413            provenance: "gpt-4:abc123".into(),
414        };
415
416        match agent.validate_proposal(&proposal) {
417            ValidationResult::Rejected { reason, .. } => {
418                assert!(reason.contains("guaranteed"));
419            }
420            ValidationResult::Accepted(_) => {
421                panic!("Expected rejection for forbidden term");
422            }
423        }
424    }
425
426    #[test]
427    fn encode_proposal_roundtrip() {
428        let proposal = ProposedFact {
429            key: ContextKey::Strategies,
430            id: "strat-1".into(),
431            content: "Focus on SMB".into(),
432            confidence: 0.85,
433            provenance: "claude-3:xyz".into(),
434        };
435
436        let encoded = encode_proposal(&proposal);
437        assert_eq!(encoded.key, ContextKey::Proposals);
438        assert_eq!(encoded.id, "proposal:strategies:strat-1");
439
440        let decoded = ValidationAgent::parse_proposal(&encoded).expect("should parse");
441        assert_eq!(decoded.key, proposal.key);
442        assert_eq!(decoded.id, proposal.id);
443        assert_eq!(decoded.content, proposal.content);
444        assert!((decoded.confidence - proposal.confidence).abs() < 0.001);
445        assert_eq!(decoded.provenance, proposal.provenance);
446    }
447
448    #[test]
449    fn validation_agent_promotes_in_engine() {
450        let mut engine = Engine::new();
451        engine.register(ValidationAgent::with_defaults());
452
453        // Create initial context with a proposal
454        let mut ctx = Context::new();
455        let proposal = ProposedFact {
456            key: ContextKey::Hypotheses,
457            id: "llm-hyp-1".into(),
458            content: "AI suggests market expansion".into(),
459            confidence: 0.75,
460            provenance: "gpt-4:test123".into(),
461        };
462        let _ = ctx.add_fact(encode_proposal(&proposal));
463
464        let result = engine.run(ctx).expect("should converge");
465
466        assert!(result.converged);
467
468        // The proposal should have been promoted to a Hypothesis
469        let hypotheses = result.context.get(ContextKey::Hypotheses);
470        assert_eq!(hypotheses.len(), 1);
471        assert_eq!(hypotheses[0].id, "llm-hyp-1");
472        assert_eq!(hypotheses[0].content, "AI suggests market expansion");
473    }
474
475    #[test]
476    fn validation_agent_rejects_bad_proposal_in_engine() {
477        let mut engine = Engine::new();
478        engine.register(ValidationAgent::new(ValidationConfig {
479            min_confidence: 0.8,
480            ..Default::default()
481        }));
482
483        // Create context with a low-confidence proposal
484        let mut ctx = Context::new();
485        let proposal = ProposedFact {
486            key: ContextKey::Hypotheses,
487            id: "bad-hyp".into(),
488            content: "Uncertain speculation".into(),
489            confidence: 0.3, // Below threshold
490            provenance: "gpt-4:test".into(),
491        };
492        let _ = ctx.add_fact(encode_proposal(&proposal));
493
494        let result = engine.run(ctx).expect("should converge");
495
496        assert!(result.converged);
497
498        // The proposal should NOT have been promoted
499        let hypotheses = result.context.get(ContextKey::Hypotheses);
500        assert!(hypotheses.is_empty());
501
502        // But there should be a rejection signal
503        let signals = result.context.get(ContextKey::Signals);
504        assert!(signals.iter().any(|s| s.id.contains("rejected")));
505    }
506
507    #[test]
508    fn llm_cannot_bypass_validation() {
509        // This test documents the compile-time safety:
510        // An LLM agent cannot emit Facts directly - only ProposedFacts.
511        // Those must go through ValidationAgent to become Facts.
512        //
513        // The type system enforces this:
514        //   - AgentEffect only accepts Vec<Fact>
515        //   - ProposedFact is a different type
516        //   - You cannot add ProposedFact to AgentEffect
517        //
518        // This is the core LLM containment guarantee.
519    }
520}