Skip to main content

openkind_core/error/
validate.rs

1//! Pure validation functions for requests and responses under the Jev protocol specification.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::answer::Answer;
6use crate::error::types::{ValidationError, ValidationResult};
7use crate::question::Question;
8use crate::request::{SystemRequest, WireHashState};
9use crate::response::SystemResponse;
10
11/// Maximum number of questions allowed in a single evaluation request to prevent DoS.
12pub const MAX_QUESTIONS_PER_REQUEST: usize = 10_000;
13
14/// Maximum number of criteria options allowed per question to prevent DoS.
15pub const MAX_CRITERIA_OPTIONS: usize = 10_000;
16
17/// Validate a request. Pure — does no I/O.
18///
19/// Rules derived from the spec:
20/// - at least one question
21/// - all question kinds validate their own `criteria` shape
22/// - for Choice: criteria must be non-empty (no explicit lower bound in spec
23///   but a zero-option question is meaningless and would 422 from the real API)
24#[inline]
25pub fn validate_request(req: &SystemRequest) -> ValidationResult<()> {
26    if req.questions.is_empty() {
27        return Err(ValidationError::NoQuestions);
28    }
29    if req.questions.len() > MAX_QUESTIONS_PER_REQUEST {
30        return Err(ValidationError::TooManyQuestions {
31            count: req.questions.len(),
32            max: MAX_QUESTIONS_PER_REQUEST,
33        });
34    }
35    for (id, q) in &req.questions {
36        validate_question(id, q)?;
37    }
38    Ok(())
39}
40
41#[inline]
42fn validate_question(id: &str, q: &Question) -> ValidationResult<()> {
43    match q {
44        Question::Noul(n) => {
45            if instructions_missing(&n.instructions) {
46                return Err(ValidationError::MissingInstructions(id.to_string()));
47            }
48            if let Some(c) = &n.criteria {
49                if c.r#true.is_empty() {
50                    return Err(ValidationError::NoulTrueEmpty(id.to_string()));
51                }
52                if c.r#false.is_empty() {
53                    return Err(ValidationError::NoulFalseEmpty(id.to_string()));
54                }
55            }
56        }
57        Question::Choice(c) => {
58            if instructions_missing(&c.instructions) {
59                return Err(ValidationError::MissingInstructions(id.to_string()));
60            }
61            if c.criteria.is_empty() {
62                return Err(ValidationError::ChoiceCriteriaEmpty(id.to_string()));
63            }
64            if c.criteria.len() > MAX_CRITERIA_OPTIONS {
65                return Err(ValidationError::TooManyCriteriaOptions {
66                    id: id.to_string(),
67                    count: c.criteria.len(),
68                    max: MAX_CRITERIA_OPTIONS,
69                });
70            }
71        }
72        Question::Score(s) => {
73            if instructions_missing(&s.instructions) {
74                return Err(ValidationError::MissingInstructions(id.to_string()));
75            }
76            if s.criteria.len() < 2 {
77                return Err(ValidationError::ScoreCriteriaTooFew(id.to_string()));
78            }
79            if s.criteria.len() > MAX_CRITERIA_OPTIONS {
80                return Err(ValidationError::TooManyCriteriaOptions {
81                    id: id.to_string(),
82                    count: s.criteria.len(),
83                    max: MAX_CRITERIA_OPTIONS,
84                });
85            }
86            for (i, level) in s.criteria.iter().enumerate() {
87                if level.is_empty() {
88                    return Err(ValidationError::ScoreLevelEmpty {
89                        id: id.to_string(),
90                        index: i,
91                    });
92                }
93            }
94        }
95    }
96    Ok(())
97}
98
99#[inline]
100fn instructions_missing(v: &serde_json::Value) -> bool {
101    match v {
102        serde_json::Value::String(s) => s.is_empty(),
103        serde_json::Value::Object(m) => m.is_empty(),
104        serde_json::Value::Array(a) => a.is_empty(),
105        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => true,
106    }
107}
108
109/// Validate a response. Catches the common engine-side bugs:
110/// - missing or unexpected answers (when `criteria` covers the request's question ids)
111/// - probabilities don't sum to 1
112/// - confidence out of range
113/// - choice answer's keys don't match criteria
114/// - score answer legend doesn't match probabilities
115///
116/// `criteria` maps question id → the question's choice-criteria keys (empty for
117/// noul/score questions). When the map is non-empty it is treated as the full
118/// question-id set of the originating request, and answer coverage is enforced
119/// both ways: every requested id must be answered, and no extra answers may
120/// appear. An empty map skips coverage checks (per-answer checks still run).
121pub fn validate_response(
122    resp: &SystemResponse,
123    criteria: &HashMap<String, Vec<String>>,
124) -> ValidationResult<()> {
125    if !criteria.is_empty() {
126        for id in criteria.keys() {
127            if !resp.answers.contains_key(id) {
128                return Err(ValidationError::MissingAnswer(id.clone()));
129            }
130        }
131        if resp.answers.len() != criteria.len() {
132            for id in resp.answers.keys() {
133                if !criteria.contains_key(id) {
134                    return Err(ValidationError::UnexpectedAnswer(id.clone()));
135                }
136            }
137        }
138    }
139    validate_response_inner(resp, |id| criteria.get(id).map(Vec::as_slice))
140}
141
142fn validate_response_inner<'a>(
143    resp: &SystemResponse,
144    keys_for: impl Fn(&str) -> Option<&'a [String]>,
145) -> ValidationResult<()> {
146    // Every question id in the request must have a matching answer.
147    // (Caller passes criteria so we can validate cross-references.)
148    for (id, ans) in &resp.answers {
149        match ans {
150            Answer::Noul(n) => {
151                if n.noul.is_nan() || !n.noul.is_finite() || !(0.0..=1.0).contains(&n.noul) {
152                    return Err(ValidationError::NoulOutOfRange {
153                        id: id.clone(),
154                        value: n.noul,
155                    });
156                }
157            }
158            Answer::Choice(c) => {
159                let expected_keys: HashSet<&str> = keys_for(id)
160                    .map(|v| v.iter().map(String::as_str).collect())
161                    .unwrap_or_default();
162                if !expected_keys.is_empty() && !expected_keys.contains(c.choice.as_str()) {
163                    return Err(ValidationError::ChoiceNotInCriteria(id.clone()));
164                }
165                let prob_keys: HashSet<&str> = c.probabilities.keys().map(String::as_str).collect();
166                if !expected_keys.is_empty() && prob_keys != expected_keys {
167                    return Err(ValidationError::ProbabilityKeysMismatch(id.clone()));
168                }
169                check_confidence(id, c.confidence)?;
170                check_probabilities(id, &c.probabilities)?;
171            }
172            Answer::Score(s) => {
173                let prob_keys: HashSet<&str> = s.probabilities.keys().map(String::as_str).collect();
174                let legend_keys: HashSet<&str> = s.legend.keys().map(String::as_str).collect();
175                if prob_keys != legend_keys {
176                    return Err(ValidationError::ScoreLegendMismatch(id.clone()));
177                }
178                if let Some(levels) = keys_for(id).filter(|levels| !levels.is_empty()) {
179                    // A self-consistent returned legend can still invent or
180                    // omit levels, or change the meaning of the requested rubric.
181                    if s.legend.len() != levels.len()
182                        || levels.iter().enumerate().any(|(index, description)| {
183                            s.legend.get(&index.to_string()) != Some(description)
184                        })
185                    {
186                        return Err(ValidationError::ScoreLegendMismatch(id.clone()));
187                    }
188                }
189                let mut max_idx: u32 = 0;
190                for k in &prob_keys {
191                    let idx =
192                        k.parse::<u32>()
193                            .map_err(|_| ValidationError::ScoreIndexNotNumeric {
194                                id: id.clone(),
195                                key: k.to_string(),
196                            })?;
197                    if idx.to_string() != *k {
198                        return Err(ValidationError::ScoreIndexNotNumeric {
199                            id: id.clone(),
200                            key: k.to_string(),
201                        });
202                    }
203                    max_idx = max_idx.max(idx);
204                }
205                let max_score = max_idx as f64;
206                if s.score.is_nan() || !s.score.is_finite() || s.score < 0.0 || s.score > max_score
207                {
208                    return Err(ValidationError::ScoreOutOfRange {
209                        id: id.clone(),
210                        max: max_score,
211                        value: s.score,
212                    });
213                }
214                check_confidence(id, s.confidence)?;
215                check_probabilities(id, &s.probabilities)?;
216                let expected: f64 = s
217                    .probabilities
218                    .iter()
219                    .map(|(key, probability)| {
220                        key.parse::<u32>().expect("validated score index") as f64 * probability
221                    })
222                    .sum();
223                // Scale the existing probability tolerance to the rubric's numeric range.
224                if (s.score - expected).abs() > 1e-3 * max_score.max(1.0) {
225                    return Err(ValidationError::ScoreExpectationMismatch {
226                        id: id.clone(),
227                        expected,
228                        value: s.score,
229                    });
230                }
231            }
232        }
233    }
234    Ok(())
235}
236
237/// Request-bound answer expectations retained while an engine consumes the request.
238pub struct ResponseContract {
239    questions: HashMap<String, (AnswerKind, Vec<String>), WireHashState>,
240}
241
242#[derive(Clone, Copy, PartialEq, Eq)]
243enum AnswerKind {
244    Noul,
245    Choice,
246    Score,
247}
248
249impl AnswerKind {
250    fn as_str(self) -> &'static str {
251        match self {
252            Self::Noul => "noul",
253            Self::Choice => "choice",
254            Self::Score => "score",
255        }
256    }
257}
258
259impl ResponseContract {
260    /// Capture question IDs, types, Choice keys, and Score levels without copying state or instructions.
261    pub fn from_request(req: &SystemRequest) -> ValidationResult<Self> {
262        if req.questions.is_empty() {
263            return Err(ValidationError::NoQuestions);
264        }
265        if req.questions.len() > MAX_QUESTIONS_PER_REQUEST {
266            return Err(ValidationError::TooManyQuestions {
267                count: req.questions.len(),
268                max: MAX_QUESTIONS_PER_REQUEST,
269            });
270        }
271        let mut questions =
272            HashMap::with_capacity_and_hasher(req.questions.len(), WireHashState::default());
273        for (id, question) in &req.questions {
274            validate_question(id, question)?;
275            let (kind, keys) = contract_entry(question);
276            questions.insert(id.clone(), (kind, keys));
277        }
278        Ok(Self { questions })
279    }
280
281    /// Check that every answer matches the originating question and wire rules.
282    pub fn validate(&self, resp: &SystemResponse) -> ValidationResult<()> {
283        for (id, (expected, _)) in &self.questions {
284            let answer = resp
285                .answers
286                .get(id)
287                .ok_or_else(|| ValidationError::MissingAnswer(id.clone()))?;
288            let actual = answer_kind(answer);
289            if actual != *expected {
290                return Err(ValidationError::AnswerTypeMismatch {
291                    id: id.clone(),
292                    expected: expected.as_str(),
293                    actual: actual.as_str(),
294                });
295            }
296        }
297        if resp.answers.len() != self.questions.len() {
298            for id in resp.answers.keys() {
299                if !self.questions.contains_key(id) {
300                    return Err(ValidationError::UnexpectedAnswer(id.clone()));
301                }
302            }
303        }
304        // Reuse the retained contract directly. Rebuilding a second map here
305        // copied every question ID and Choice key on each response.
306        validate_response_inner(resp, |id| {
307            self.questions.get(id).map(|(_, keys)| keys.as_slice())
308        })
309    }
310}
311
312fn contract_entry(question: &Question) -> (AnswerKind, Vec<String>) {
313    match question {
314        Question::Noul(_) => (AnswerKind::Noul, Vec::new()),
315        Question::Choice(choice) => (
316            AnswerKind::Choice,
317            choice.criteria.keys().cloned().collect(),
318        ),
319        Question::Score(score) => (AnswerKind::Score, score.criteria.clone()),
320    }
321}
322
323fn answer_kind(answer: &Answer) -> AnswerKind {
324    match answer {
325        Answer::Noul(_) => AnswerKind::Noul,
326        Answer::Choice(_) => AnswerKind::Choice,
327        Answer::Score(_) => AnswerKind::Score,
328    }
329}
330
331/// Validate a response against its originating request.
332///
333/// The request supplies the question types, Choice option set, and Score rubric
334/// that cannot be established from a response alone. The older [`validate_response`]
335/// remains available for callers that only have a criteria map.
336pub fn validate_response_for_request(
337    resp: &SystemResponse,
338    req: &SystemRequest,
339) -> ValidationResult<()> {
340    ResponseContract::from_request(req)?.validate(resp)
341}
342
343fn check_confidence(id: &str, value: f64) -> ValidationResult<()> {
344    if value.is_nan() || !(0.0..=1.0).contains(&value) {
345        return Err(ValidationError::ConfidenceOutOfRange {
346            id: id.to_string(),
347            value,
348        });
349    }
350    Ok(())
351}
352
353fn check_probabilities(id: &str, probabilities: &HashMap<String, f64>) -> ValidationResult<()> {
354    for &p in probabilities.values() {
355        if p.is_nan() || !(0.0..=1.0).contains(&p) {
356            return Err(ValidationError::ProbabilityOutOfRange {
357                id: id.to_string(),
358                value: p,
359            });
360        }
361    }
362    let sum: f64 = probabilities.values().copied().sum();
363    check_sums_to_one(id, sum)
364}
365
366fn check_sums_to_one(id: &str, sum: f64) -> ValidationResult<()> {
367    if sum.is_nan() || (sum - 1.0).abs() > 1e-3 {
368        return Err(ValidationError::ProbabilitiesDontSum {
369            id: id.to_string(),
370            sum,
371        });
372    }
373    Ok(())
374}