1use 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
11pub const MAX_QUESTIONS_PER_REQUEST: usize = 10_000;
13
14pub const MAX_CRITERIA_OPTIONS: usize = 10_000;
16
17#[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
109pub 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 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 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 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
237pub 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 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 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 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
331pub 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}