systemprompt_models/brain/declarations/
validate.rs1use 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#[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}