Skip to main content

systemprompt_models/brain/declarations/
validate.rs

1//! Cross-reference validation of a [`BrainDocs`] set.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::borrow::Borrow;
7use std::collections::HashSet;
8
9use systemprompt_identifiers::{FactKindId, TopicId};
10
11use super::{BrainDocs, ImportanceConfig, RetrievalDoc};
12use crate::brain::kind_decl::{ExtractorDecl, KindDecl};
13use crate::brain::statement::{StatementError, placeholders};
14
15/// A declaration set that is internally inconsistent.
16#[derive(Debug, Clone, PartialEq, thiserror::Error)]
17pub enum BrainDeclError {
18    #[error("{what} {id} is declared more than once")]
19    Duplicate { what: &'static str, id: String },
20    #[error("{owner} references undeclared topic {topic}")]
21    UnknownTopic { owner: String, topic: TopicId },
22    #[error("{owner} references undeclared kind {kind}")]
23    UnknownKind { owner: String, kind: FactKindId },
24    #[error("kind {kind} has an invalid statement template")]
25    Statement {
26        kind: FactKindId,
27        #[source]
28        source: StatementError,
29    },
30    #[error("kind {kind} statement names unknown field {name}")]
31    UnknownPlaceholder { kind: FactKindId, name: String },
32    #[error("kind {0} declares no identity")]
33    EmptyIdentity(FactKindId),
34    #[error("kind {kind} identity names unknown field {name}")]
35    UnknownIdentityField { kind: FactKindId, name: String },
36    #[error("kind {kind} materiality names unknown field {name}")]
37    UnknownMaterialityField { kind: FactKindId, name: String },
38    #[error("kind {0} has a model extractor without a prompt_version")]
39    MissingPromptVersion(FactKindId),
40    #[error("weight {name} is {value}, outside 0.0..=1.0")]
41    WeightOutOfRange { name: String, value: f64 },
42    #[error("recency half_life_days must be positive")]
43    ZeroHalfLife,
44    #[error("retrieval {name} must be positive")]
45    ZeroLimit { name: &'static str },
46    #[error("retrieval {name} is {value}, not a finite number at or above zero")]
47    NegativeScore { name: &'static str, value: f64 },
48}
49
50pub fn validate(docs: &BrainDocs) -> Result<(), BrainDeclError> {
51    let mut topics = HashSet::new();
52    for topic in &docs.topics.topics {
53        if !topics.insert(&topic.id) {
54            return Err(duplicate("topic", topic.id.as_str()));
55        }
56    }
57    let mut kinds = HashSet::new();
58    for kind in &docs.kinds {
59        if !kinds.insert(&kind.kind) {
60            return Err(duplicate("kind", kind.kind.as_str()));
61        }
62        validate_kind(kind, &topics)?;
63    }
64    let mut interests = HashSet::new();
65    for interest in &docs.interests {
66        if !interests.insert(&interest.id) {
67            return Err(duplicate("interest", interest.id.as_str()));
68        }
69        let owner = format!("interest {}", interest.id);
70        known_topics(&owner, &interest.r#match.topics, &topics)?;
71        known_kinds(&owner, &interest.r#match.kinds, &kinds)?;
72    }
73    let mut workflows = HashSet::new();
74    for workflow in &docs.workflows {
75        if !workflows.insert(&workflow.id) {
76            return Err(duplicate("workflow", workflow.id.as_str()));
77        }
78        let owner = format!("workflow {}", workflow.id);
79        known_kinds(&owner, &workflow.kinds, &kinds)?;
80        known_kinds(&owner, workflow.quotas.keys(), &kinds)?;
81    }
82    for filter in &docs.filters.filters {
83        known_kinds(&format!("filter {}", filter.name), &filter.kinds, &kinds)?;
84    }
85    known_kinds(
86        "importance",
87        docs.importance.base.kind_weight.keys(),
88        &kinds,
89    )?;
90    known_kinds("retrieval", docs.retrieval.quotas.keys(), &kinds)?;
91    validate_importance(&docs.importance)?;
92    validate_retrieval(&docs.retrieval)
93}
94
95fn duplicate(what: &'static str, id: &str) -> BrainDeclError {
96    BrainDeclError::Duplicate {
97        what,
98        id: id.to_owned(),
99    }
100}
101
102fn validate_kind(kind: &KindDecl, topics: &HashSet<&TopicId>) -> Result<(), BrainDeclError> {
103    known_topics(&format!("kind {}", kind.kind), &kind.topics, topics)?;
104    let names = placeholders(&kind.statement).map_err(|source| BrainDeclError::Statement {
105        kind: kind.kind.clone(),
106        source,
107    })?;
108    if let Some(name) = names.into_iter().find(|name| !kind.knows(name)) {
109        return Err(BrainDeclError::UnknownPlaceholder {
110            kind: kind.kind.clone(),
111            name,
112        });
113    }
114    if kind.identity.is_empty() {
115        return Err(BrainDeclError::EmptyIdentity(kind.kind.clone()));
116    }
117    if let Some(name) = kind.identity.iter().find(|name| !kind.knows(name)) {
118        return Err(BrainDeclError::UnknownIdentityField {
119            kind: kind.kind.clone(),
120            name: name.clone(),
121        });
122    }
123    if let Some(name) = kind
124        .materiality
125        .require
126        .iter()
127        .find(|name| !kind.fields.contains_key(*name))
128    {
129        return Err(BrainDeclError::UnknownMaterialityField {
130            kind: kind.kind.clone(),
131            name: name.clone(),
132        });
133    }
134    if let Some(ExtractorDecl::Model { prompt_version, .. }) = &kind.extractor
135        && prompt_version.as_deref().is_none_or(str::is_empty)
136    {
137        return Err(BrainDeclError::MissingPromptVersion(kind.kind.clone()));
138    }
139    Ok(())
140}
141
142fn known_topics<T: Borrow<TopicId>>(
143    owner: &str,
144    named: impl IntoIterator<Item = T>,
145    declared: &HashSet<&TopicId>,
146) -> Result<(), BrainDeclError> {
147    named
148        .into_iter()
149        .find(|topic| !declared.contains(&topic.borrow()))
150        .map_or(Ok(()), |topic| {
151            Err(BrainDeclError::UnknownTopic {
152                owner: owner.to_owned(),
153                topic: topic.borrow().clone(),
154            })
155        })
156}
157
158fn known_kinds<T: Borrow<FactKindId>>(
159    owner: &str,
160    named: impl IntoIterator<Item = T>,
161    declared: &HashSet<&FactKindId>,
162) -> Result<(), BrainDeclError> {
163    named
164        .into_iter()
165        .find(|kind| !declared.contains(&kind.borrow()))
166        .map_or(Ok(()), |kind| {
167            Err(BrainDeclError::UnknownKind {
168                owner: owner.to_owned(),
169                kind: kind.borrow().clone(),
170            })
171        })
172}
173
174fn weight(name: impl Into<String>, value: f64) -> Result<(), BrainDeclError> {
175    if value.is_finite() && (0.0..=1.0).contains(&value) {
176        return Ok(());
177    }
178    Err(BrainDeclError::WeightOutOfRange {
179        name: name.into(),
180        value,
181    })
182}
183
184fn validate_importance(config: &ImportanceConfig) -> Result<(), BrainDeclError> {
185    let base = &config.base;
186    for (kind, value) in &base.kind_weight {
187        weight(format!("kind_weight.{kind}"), *value)?;
188    }
189    for (source, value) in &base.source_weight {
190        weight(format!("source_weight.{}", source.as_str()), *value)?;
191    }
192    if let Some(hint) = &base.model_hint {
193        weight("model_hint.weight", hint.weight)?;
194    }
195    weight("confirmed_by_owner", base.confirmed_by_owner)?;
196    let ctx = &config.in_context;
197    for (workflow, kinds) in &ctx.workflow_weight {
198        for (kind, value) in kinds {
199            weight(format!("workflow_weight.{workflow}.{kind}"), *value)?;
200        }
201    }
202    weight("subject_match", ctx.subject_match)?;
203    weight("usage.cited_rate_weight", ctx.usage.cited_rate_weight)?;
204    weight("usage.ignored_penalty", ctx.usage.ignored_penalty)?;
205    if ctx.recency.half_life_days == 0 {
206        return Err(BrainDeclError::ZeroHalfLife);
207    }
208    Ok(())
209}
210
211fn validate_retrieval(doc: &RetrievalDoc) -> Result<(), BrainDeclError> {
212    let tiers = &doc.tiers;
213    weight("tiers.pinned", tiers.pinned)?;
214    weight("tiers.subject", tiers.subject)?;
215    weight("tiers.context", tiers.context)?;
216    weight("tiers.full_text", tiers.full_text)?;
217    weight("tiers.extension", tiers.extension)?;
218    weight("tiers.linked", tiers.linked)?;
219    weight("links_weight", doc.links_weight)?;
220    weight("search_max_df", doc.search_max_df)?;
221    weight("search_quorum", doc.search_quorum)?;
222    weight("search_retry_quorum", doc.search_retry_quorum)?;
223    weight("alias_min_sim", doc.alias_min_sim)?;
224    weight("linked_share", doc.linked_share)?;
225    weight("context_share", doc.context_share)?;
226    weight("usage_weight", doc.usage_weight)?;
227    if !doc.min_fused_score.is_finite() || doc.min_fused_score < 0.0 {
228        return Err(BrainDeclError::NegativeScore {
229            name: "min_fused_score",
230            value: doc.min_fused_score,
231        });
232    }
233    for (name, value) in [
234        ("max_facts", doc.max_facts),
235        ("max_subjects", doc.max_subjects),
236        ("extension_max_candidates", doc.extension_max_candidates),
237    ] {
238        if value == 0 {
239            return Err(BrainDeclError::ZeroLimit { name });
240        }
241    }
242    Ok(())
243}