#![allow(clippy::unnecessary_literal_bound)]
use crate::agent::Agent;
use crate::context::{ContextKey, Fact, ProposedFact};
use crate::effect::AgentEffect;
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub min_confidence: f64,
pub max_content_length: usize,
pub forbidden_terms: Vec<String>,
pub require_provenance: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
min_confidence: 0.5,
max_content_length: 10_000,
forbidden_terms: vec![],
require_provenance: true,
}
}
}
#[derive(Debug, Clone)]
pub enum ValidationResult {
Accepted(Fact),
Rejected { proposal_id: String, reason: String },
}
pub struct ValidationAgent {
config: ValidationConfig,
}
impl ValidationAgent {
#[must_use]
pub fn new(config: ValidationConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ValidationConfig::default())
}
fn validate_proposal(&self, proposal: &ProposedFact) -> ValidationResult {
if proposal.confidence < self.config.min_confidence {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!(
"confidence {} below threshold {}",
proposal.confidence, self.config.min_confidence
),
};
}
if proposal.content.len() > self.config.max_content_length {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!(
"content length {} exceeds max {}",
proposal.content.len(),
self.config.max_content_length
),
};
}
if proposal.content.trim().is_empty() {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: "content is empty".into(),
};
}
if self.config.require_provenance && proposal.provenance.trim().is_empty() {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: "provenance is required but empty".into(),
};
}
let content_lower = proposal.content.to_lowercase();
for term in &self.config.forbidden_terms {
if content_lower.contains(&term.to_lowercase()) {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!("content contains forbidden term '{term}'"),
};
}
}
match Fact::try_from(proposal.clone()) {
Ok(fact) => ValidationResult::Accepted(fact),
Err(e) => ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: e.reason,
},
}
}
fn parse_proposal(fact: &Fact) -> Option<ProposedFact> {
let id_parts: Vec<&str> = fact.id.splitn(3, ':').collect();
if id_parts.len() != 3 || id_parts[0] != "proposal" {
return None;
}
let target_key = match id_parts[1] {
"seeds" => ContextKey::Seeds,
"hypotheses" => ContextKey::Hypotheses,
"strategies" => ContextKey::Strategies,
"constraints" => ContextKey::Constraints,
"signals" => ContextKey::Signals,
"competitors" => ContextKey::Competitors,
"evaluations" => ContextKey::Evaluations,
_ => return None,
};
let actual_id = id_parts[2];
let content_parts: Vec<&str> = fact.content.splitn(3, '|').collect();
if content_parts.len() != 3 {
return None;
}
let confidence: f64 = content_parts[0].parse().ok()?;
let provenance = content_parts[1].to_string();
let content = content_parts[2].to_string();
Some(ProposedFact {
key: target_key,
id: actual_id.to_string(),
content,
confidence,
provenance,
})
}
}
impl Agent for ValidationAgent {
fn name(&self) -> &str {
"ValidationAgent"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Proposals]
}
fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
let proposals = ctx.get(ContextKey::Proposals);
for proposal_fact in proposals {
if let Some(proposal) = Self::parse_proposal(proposal_fact) {
let existing = ctx.get(proposal.key);
if !existing.iter().any(|f| f.id == proposal.id) {
return true; }
}
}
false
}
fn execute(&self, ctx: &dyn crate::ContextView) -> AgentEffect {
let proposals = ctx.get(ContextKey::Proposals);
let mut facts = Vec::new();
for proposal_fact in proposals {
if let Some(proposal) = Self::parse_proposal(proposal_fact) {
let existing = ctx.get(proposal.key);
if existing.iter().any(|f| f.id == proposal.id) {
continue;
}
match self.validate_proposal(&proposal) {
ValidationResult::Accepted(fact) => {
facts.push(fact);
}
ValidationResult::Rejected {
proposal_id,
reason,
} => {
facts.push(Fact {
key: ContextKey::Signals,
id: format!("validation:rejected:{proposal_id}"),
content: format!("Proposal '{proposal_id}' rejected: {reason}"),
});
}
}
}
}
AgentEffect::with_facts(facts)
}
}
#[must_use]
pub fn encode_proposal(proposal: &ProposedFact) -> Fact {
let target_key_str = match proposal.key {
ContextKey::Seeds => "seeds",
ContextKey::Hypotheses => "hypotheses",
ContextKey::Strategies => "strategies",
ContextKey::Constraints => "constraints",
ContextKey::Signals => "signals",
ContextKey::Competitors => "competitors",
ContextKey::Evaluations => "evaluations",
ContextKey::Proposals => "proposals", ContextKey::Diagnostic => "diagnostics",
};
Fact {
key: ContextKey::Proposals,
id: format!("proposal:{}:{}", target_key_str, proposal.id),
content: format!(
"{}|{}|{}",
proposal.confidence, proposal.provenance, proposal.content
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::Context;
use crate::engine::Engine;
#[test]
fn validation_accepts_good_proposal() {
let agent = ValidationAgent::with_defaults();
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "hyp-1".into(),
content: "Market is growing".into(),
confidence: 0.8,
provenance: "gpt-4:abc123".into(),
};
match agent.validate_proposal(&proposal) {
ValidationResult::Accepted(fact) => {
assert_eq!(fact.key, ContextKey::Hypotheses);
assert_eq!(fact.id, "hyp-1");
assert_eq!(fact.content, "Market is growing");
}
ValidationResult::Rejected { reason, .. } => {
panic!("Expected acceptance, got rejection: {reason}");
}
}
}
#[test]
fn validation_rejects_low_confidence() {
let agent = ValidationAgent::new(ValidationConfig {
min_confidence: 0.7,
..Default::default()
});
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "hyp-1".into(),
content: "Uncertain claim".into(),
confidence: 0.3, provenance: "gpt-4:abc123".into(),
};
match agent.validate_proposal(&proposal) {
ValidationResult::Rejected { reason, .. } => {
assert!(reason.contains("confidence"));
}
ValidationResult::Accepted(_) => {
panic!("Expected rejection for low confidence");
}
}
}
#[test]
fn validation_rejects_empty_content() {
let agent = ValidationAgent::with_defaults();
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "hyp-1".into(),
content: " ".into(), confidence: 0.9,
provenance: "gpt-4:abc123".into(),
};
match agent.validate_proposal(&proposal) {
ValidationResult::Rejected { reason, .. } => {
assert!(reason.contains("empty"));
}
ValidationResult::Accepted(_) => {
panic!("Expected rejection for empty content");
}
}
}
#[test]
fn validation_rejects_missing_provenance() {
let agent = ValidationAgent::new(ValidationConfig {
require_provenance: true,
..Default::default()
});
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "hyp-1".into(),
content: "Some claim".into(),
confidence: 0.9,
provenance: String::new(), };
match agent.validate_proposal(&proposal) {
ValidationResult::Rejected { reason, .. } => {
assert!(reason.contains("provenance"));
}
ValidationResult::Accepted(_) => {
panic!("Expected rejection for missing provenance");
}
}
}
#[test]
fn validation_rejects_forbidden_terms() {
let agent = ValidationAgent::new(ValidationConfig {
forbidden_terms: vec!["guaranteed".into(), "100%".into()],
..Default::default()
});
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "hyp-1".into(),
content: "This is GUARANTEED to work".into(),
confidence: 0.9,
provenance: "gpt-4:abc123".into(),
};
match agent.validate_proposal(&proposal) {
ValidationResult::Rejected { reason, .. } => {
assert!(reason.contains("guaranteed"));
}
ValidationResult::Accepted(_) => {
panic!("Expected rejection for forbidden term");
}
}
}
#[test]
fn encode_proposal_roundtrip() {
let proposal = ProposedFact {
key: ContextKey::Strategies,
id: "strat-1".into(),
content: "Focus on SMB".into(),
confidence: 0.85,
provenance: "claude-3:xyz".into(),
};
let encoded = encode_proposal(&proposal);
assert_eq!(encoded.key, ContextKey::Proposals);
assert_eq!(encoded.id, "proposal:strategies:strat-1");
let decoded = ValidationAgent::parse_proposal(&encoded).expect("should parse");
assert_eq!(decoded.key, proposal.key);
assert_eq!(decoded.id, proposal.id);
assert_eq!(decoded.content, proposal.content);
assert!((decoded.confidence - proposal.confidence).abs() < 0.001);
assert_eq!(decoded.provenance, proposal.provenance);
}
#[test]
fn validation_agent_promotes_in_engine() {
let mut engine = Engine::new();
engine.register(ValidationAgent::with_defaults());
let mut ctx = Context::new();
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "llm-hyp-1".into(),
content: "AI suggests market expansion".into(),
confidence: 0.75,
provenance: "gpt-4:test123".into(),
};
let _ = ctx.add_fact(encode_proposal(&proposal));
let result = engine.run(ctx).expect("should converge");
assert!(result.converged);
let hypotheses = result.context.get(ContextKey::Hypotheses);
assert_eq!(hypotheses.len(), 1);
assert_eq!(hypotheses[0].id, "llm-hyp-1");
assert_eq!(hypotheses[0].content, "AI suggests market expansion");
}
#[test]
fn validation_agent_rejects_bad_proposal_in_engine() {
let mut engine = Engine::new();
engine.register(ValidationAgent::new(ValidationConfig {
min_confidence: 0.8,
..Default::default()
}));
let mut ctx = Context::new();
let proposal = ProposedFact {
key: ContextKey::Hypotheses,
id: "bad-hyp".into(),
content: "Uncertain speculation".into(),
confidence: 0.3, provenance: "gpt-4:test".into(),
};
let _ = ctx.add_fact(encode_proposal(&proposal));
let result = engine.run(ctx).expect("should converge");
assert!(result.converged);
let hypotheses = result.context.get(ContextKey::Hypotheses);
assert!(hypotheses.is_empty());
let signals = result.context.get(ContextKey::Signals);
assert!(signals.iter().any(|s| s.id.contains("rejected")));
}
#[test]
fn llm_cannot_bypass_validation() {
}
}