1use std::collections::HashMap;
8use std::hash::Hasher;
9
10use async_trait::async_trait;
11use openkind_core::ModelInfo;
12use openkind_core::{
13 Answer, ChoiceAnswer, NoulAnswer, Question, ScoreAnswer, SystemRequest, SystemResponse, Usage,
14};
15
16use crate::{DecisionEngine, EngineResult};
17
18fn seed_for_question(id: &str, instructions: &serde_json::Value) -> u64 {
26 use std::collections::hash_map::DefaultHasher;
27 use std::hash::Hash;
28 let mut h = DefaultHasher::new();
29 id.hash(&mut h);
30 hash_value(&mut h, instructions);
31 h.finish()
32}
33
34fn hash_value<H: Hasher>(h: &mut H, value: &serde_json::Value) {
37 use std::hash::Hash;
38 match value {
39 serde_json::Value::Null => h.write_u8(0),
40 serde_json::Value::Bool(b) => {
41 h.write_u8(1);
42 b.hash(h);
43 }
44 serde_json::Value::Number(n) => {
45 h.write_u8(2);
46 if let Some(i) = n.as_i64() {
47 h.write_u8(0);
48 i.hash(h);
49 } else if let Some(u) = n.as_u64() {
50 h.write_u8(1);
51 u.hash(h);
52 } else if let Some(f) = n.as_f64() {
53 h.write_u8(2);
54 f.to_bits().hash(h);
55 }
56 }
57 serde_json::Value::String(s) => {
58 h.write_u8(3);
59 s.hash(h);
60 }
61 serde_json::Value::Array(a) => {
62 h.write_u8(4);
63 a.len().hash(h);
64 for item in a {
65 hash_value(h, item);
66 }
67 }
68 serde_json::Value::Object(m) => {
69 h.write_u8(5);
70 m.len().hash(h);
71 for (key, item) in m {
72 key.hash(h);
73 hash_value(h, item);
74 }
75 }
76 }
77}
78
79struct SplitMix64(u64);
83
84impl SplitMix64 {
85 fn new(seed: u64) -> Self {
86 Self(seed)
87 }
88
89 fn next_u64(&mut self) -> u64 {
90 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
91 let mut z = self.0;
92 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
93 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
94 z ^ (z >> 31)
95 }
96
97 fn next_f64(&mut self) -> f64 {
99 (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
100 }
101
102 fn next_below(&mut self, n: usize) -> usize {
104 ((self.next_u64() as u128 * n as u128) >> 64) as usize
105 }
106}
107
108pub struct MockEngine {
112 backend: String,
113}
114
115impl MockEngine {
116 pub fn new() -> Self {
118 Self {
119 backend: "mock".into(),
120 }
121 }
122
123 pub fn with_backend(backend: impl Into<String>) -> Self {
125 Self {
126 backend: backend.into(),
127 }
128 }
129}
130
131impl Default for MockEngine {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137#[async_trait]
138impl DecisionEngine for MockEngine {
139 fn backend_id(&self) -> &str {
140 &self.backend
141 }
142
143 fn model_metadata(&self) -> ModelInfo {
144 ModelInfo {
145 name: String::new(), description: "Deterministic fake answers for testing the wire protocol.".into(),
147 release_date: "2026-01-01".into(),
148 }
149 }
150
151 async fn evaluate(&self, req: SystemRequest) -> EngineResult<SystemResponse> {
152 let mut answers: HashMap<String, Answer, _> = HashMap::default();
153
154 for (id, q) in &req.questions {
155 let mut rng = SplitMix64::new(seed_for_question(id, instructions_of(q)));
156 let answer = match q {
157 Question::Noul(_) => Answer::Noul(NoulAnswer {
158 noul: 0.05 + rng.next_f64() * 0.90,
159 }),
160 Question::Choice(cq) => {
161 let mut keys: Vec<&String> = cq.criteria.keys().collect();
162 keys.sort(); if keys.is_empty() {
164 return Err(crate::EngineError::Backend {
167 backend: self.backend.clone(),
168 message: format!("choice question `{id}` has empty criteria"),
169 });
170 }
171 let n = keys.len() as f64;
172 let raw: Vec<f64> = (0..keys.len()).map(|_| rng.next_f64()).collect();
173 let sum: f64 = raw.iter().sum();
174 let probs: HashMap<String, f64> = if sum <= 0.0 || !sum.is_finite() {
175 let uniform = 1.0 / n;
176 keys.iter().map(|k| ((*k).clone(), uniform)).collect()
177 } else {
178 keys.iter()
179 .zip(raw.iter())
180 .map(|(k, v)| ((*k).clone(), v / sum))
181 .collect()
182 };
183 let max_p = probs.values().cloned().fold(f64::NEG_INFINITY, f64::max);
184 let mut best_key: Option<String> = None;
188 for k in keys.iter() {
189 let p = probs[*k];
190 match &best_key {
191 None => best_key = Some((*k).clone()),
192 Some(bk) => {
193 if p > probs[bk] || (p == probs[bk] && *k < bk) {
194 best_key = Some((*k).clone());
195 }
196 }
197 }
198 }
199 let choice = best_key.unwrap_or_else(|| keys[0].clone());
200 let raw_conf = max_p - (1.0 - max_p) / (n - 1.0).max(1.0);
201 let confidence = if raw_conf.is_nan() {
202 0.0
203 } else {
204 raw_conf.clamp(0.0, 1.0)
205 };
206 Answer::Choice(ChoiceAnswer {
207 choice,
208 probabilities: probs,
209 confidence,
210 })
211 }
212 Question::Score(sq) => {
213 let n = sq.criteria.len();
214 if n == 0 {
215 return Err(crate::EngineError::Backend {
216 backend: self.backend.clone(),
217 message: format!("score question `{id}` has empty criteria"),
218 });
219 }
220 let mut probs = vec![0.0f64; n];
221 let peak = rng.next_below(n);
224 for (i, p) in probs.iter_mut().enumerate() {
225 let d = (i as f64 - peak as f64).abs();
226 *p = (-d * 1.5).exp();
227 }
228 let sum: f64 = probs.iter().sum();
229 let probs: Vec<f64> = if sum <= 0.0 || !sum.is_finite() {
230 let uniform = 1.0 / (n as f64);
231 vec![uniform; n]
232 } else {
233 probs.iter().map(|p| p / sum).collect()
234 };
235 let score: f64 = probs
236 .iter()
237 .enumerate()
238 .map(|(i, p)| i as f64 * p)
239 .sum::<f64>();
240 let legend: HashMap<String, String> = sq
241 .criteria
242 .iter()
243 .enumerate()
244 .map(|(i, l)| (i.to_string(), l.clone()))
245 .collect();
246 let probs_map: HashMap<String, f64> = probs
247 .iter()
248 .enumerate()
249 .map(|(i, &p)| (i.to_string(), p))
250 .collect();
251 let max_p = probs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
252 let raw_conf = max_p - (1.0 - max_p) / (n as f64 - 1.0).max(1.0);
253 let confidence = if raw_conf.is_nan() {
254 0.0
255 } else {
256 raw_conf.clamp(0.0, 1.0)
257 };
258 Answer::Score(ScoreAnswer {
259 score,
260 legend,
261 probabilities: probs_map,
262 confidence,
263 })
264 }
265 };
266 answers.insert(id.clone(), answer);
267 }
268
269 Ok(SystemResponse {
270 model: req.model,
271 answers,
272 usage: Usage {
274 input_tokens: 0,
275 output_tokens: 0,
276 },
277 })
278 }
279}
280
281fn instructions_of(q: &Question) -> &serde_json::Value {
282 match q {
283 Question::Noul(n) => &n.instructions,
284 Question::Choice(c) => &c.instructions,
285 Question::Score(s) => &s.instructions,
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use openkind_core::{State, SystemRequest};
293
294 fn req(model: &str) -> SystemRequest {
295 let mut questions = HashMap::default();
296 questions.insert(
297 "is_urgent".into(),
298 Question::Noul(openkind_core::NoulQuestion {
299 instructions: serde_json::json!("Does this convey urgency?"),
300 criteria: None,
301 }),
302 );
303 questions.insert(
304 "dept".into(),
305 Question::Choice(openkind_core::ChoiceQuestion {
306 instructions: serde_json::json!("Which team?"),
307 criteria: [
308 ("billing".to_string(), Some("pay".to_string())),
309 ("technical".to_string(), Some("bugs".to_string())),
310 ]
311 .into_iter()
312 .collect(),
313 }),
314 );
315 questions.insert(
316 "frust".into(),
317 Question::Score(openkind_core::ScoreQuestion {
318 instructions: serde_json::json!("How frustrated?"),
319 criteria: vec!["Calm".into(), "Angry".into()],
320 }),
321 );
322 SystemRequest {
323 state: State::Text("Help!".into()),
324 model: model.into(),
325 questions,
326 }
327 }
328
329 #[tokio::test]
330 async fn mock_returns_one_answer_per_question() {
331 let engine = MockEngine::new();
332 let resp = engine.evaluate(req("mock")).await.unwrap();
333 assert_eq!(resp.answers.len(), 3);
334 assert!(matches!(resp.answers["is_urgent"], Answer::Noul(_)));
335 assert!(matches!(resp.answers["dept"], Answer::Choice(_)));
336 assert!(matches!(resp.answers["frust"], Answer::Score(_)));
337 }
338
339 #[tokio::test]
340 async fn mock_is_deterministic_for_same_inputs() {
341 let engine = MockEngine::new();
342 let a = engine.evaluate(req("mock")).await.unwrap();
343 let b = engine.evaluate(req("mock")).await.unwrap();
344 match (&a.answers["dept"], &b.answers["dept"]) {
345 (Answer::Choice(x), Answer::Choice(y)) => {
346 assert_eq!(x.choice, y.choice);
347 assert_eq!(x.probabilities, y.probabilities);
348 }
349 _ => panic!("expected choice answers"),
350 }
351 }
352
353 #[tokio::test]
354 async fn choice_probabilities_sum_to_one() {
355 let engine = MockEngine::new();
356 let resp = engine.evaluate(req("mock")).await.unwrap();
357 if let Answer::Choice(c) = &resp.answers["dept"] {
358 let sum: f64 = c.probabilities.values().sum();
359 assert!((sum - 1.0).abs() < 1e-4);
360 assert!((0.0..=1.0).contains(&c.confidence));
361 } else {
362 panic!("expected choice");
363 }
364 }
365
366 #[tokio::test]
367 async fn score_probabilities_sum_to_one_and_fields_valid() {
368 let engine = MockEngine::new();
369 let resp = engine.evaluate(req("mock")).await.unwrap();
370 if let Answer::Score(s) = &resp.answers["frust"] {
371 let sum: f64 = s.probabilities.values().sum();
372 assert!((sum - 1.0).abs() < 1e-4);
373 assert!((0.0..=1.0).contains(&s.confidence));
374 assert!(s.score >= 0.0 && s.score <= 1.0);
375 let expected_score: f64 = s
376 .probabilities
377 .iter()
378 .map(|(k, &p)| k.parse::<f64>().unwrap() * p)
379 .sum();
380 assert!((expected_score - s.score).abs() < 1e-6);
381 assert_eq!(s.legend.len(), 2);
382 assert_eq!(s.legend.get("0").unwrap(), "Calm");
383 assert_eq!(s.legend.get("1").unwrap(), "Angry");
384 } else {
385 panic!("expected score");
386 }
387 }
388
389 #[tokio::test]
390 async fn noul_in_range() {
391 let engine = MockEngine::new();
392 let resp = engine.evaluate(req("mock")).await.unwrap();
393 if let Answer::Noul(n) = &resp.answers["is_urgent"] {
394 assert!(n.noul >= 0.05 && n.noul <= 0.95);
395 } else {
396 panic!("expected noul");
397 }
398 }
399
400 #[tokio::test]
401 async fn empty_criteria_returns_error_not_panic() {
402 let engine = MockEngine::new();
403 let mut questions = HashMap::default();
404 questions.insert(
405 "bad_choice".into(),
406 Question::Choice(openkind_core::ChoiceQuestion {
407 instructions: serde_json::json!("pick"),
408 criteria: HashMap::default(),
409 }),
410 );
411 questions.insert(
412 "bad_score".into(),
413 Question::Score(openkind_core::ScoreQuestion {
414 instructions: serde_json::json!("rate"),
415 criteria: Vec::new(),
416 }),
417 );
418 let request = SystemRequest {
419 state: openkind_core::State::Text("x".into()),
420 model: "mock".into(),
421 questions,
422 };
423 let err = engine.evaluate(request).await.unwrap_err();
424 assert!(
425 matches!(err, crate::EngineError::Backend { .. }),
426 "expected Backend error, got {err:?}"
427 );
428 }
429
430 #[test]
431 fn mock_backend_custom_id_and_metadata() {
432 let engine = MockEngine::with_backend("my-custom-engine");
433 assert_eq!(engine.backend_id(), "my-custom-engine");
434 let meta = engine.model_metadata();
435 assert_eq!(meta.release_date, "2026-01-01");
436 assert!(meta.description.contains("wire protocol"));
437 }
438
439 #[test]
440 fn seed_for_question_distinguishes_json_shapes() {
441 let shapes = [
444 serde_json::json!("1"),
445 serde_json::json!(1),
446 serde_json::json!(1.0),
447 serde_json::json!(true),
448 serde_json::Value::Null,
449 serde_json::json!([1]),
450 serde_json::json!({"a": 1}),
451 ];
452 let seeds: Vec<u64> = shapes.iter().map(|v| seed_for_question("q", v)).collect();
453 let unique: std::collections::HashSet<u64> = seeds.iter().copied().collect();
454 assert_eq!(
455 unique.len(),
456 seeds.len(),
457 "variant tags must keep seeds distinct, got {seeds:?}"
458 );
459 for value in &shapes {
461 assert_eq!(seed_for_question("q", value), seed_for_question("q", value));
462 }
463 let nested = serde_json::json!([[1, {"b": [2.5]}], {"a": null, "c": [true, "x"]}]);
465 assert_eq!(
466 seed_for_question("deep", &nested),
467 seed_for_question("deep", &nested)
468 );
469 }
470
471 #[test]
472 fn splitmix64_values_stay_in_range() {
473 let mut rng = SplitMix64::new(0xDEAD_BEEF);
474 for _ in 0..10_000 {
475 let x = rng.next_f64();
476 assert!((0.0..1.0).contains(&x), "next_f64 escaped [0, 1): {x}");
477 }
478 for n in [1usize, 2, 7, 100] {
479 for _ in 0..10_000 {
480 let i = rng.next_below(n);
481 assert!(i < n, "next_below({n}) escaped range: {i}");
482 }
483 }
484 assert_eq!(rng.next_below(1), 0);
487 assert_eq!(rng.next_below(1), 0);
488 }
489
490 #[tokio::test]
491 async fn mock_is_deterministic_for_non_string_instructions() {
492 let request = |instructions: serde_json::Value| {
493 let mut questions = HashMap::default();
494 questions.insert(
495 "structured".into(),
496 Question::Noul(openkind_core::NoulQuestion {
497 instructions,
498 criteria: None,
499 }),
500 );
501 SystemRequest {
502 state: State::Text("x".into()),
503 model: "mock".into(),
504 questions,
505 }
506 };
507 let engine = MockEngine::new();
508
509 let structured = serde_json::json!([{"role": "user", "text": "urgent?"}]);
510 let a = engine.evaluate(request(structured.clone())).await.unwrap();
511 let b = engine.evaluate(request(structured)).await.unwrap();
512 match (&a.answers["structured"], &b.answers["structured"]) {
513 (Answer::Noul(x), Answer::Noul(y)) => assert_eq!(x.noul, y.noul),
514 _ => panic!("expected noul answers"),
515 }
516
517 let number = engine
520 .evaluate(request(serde_json::json!(1)))
521 .await
522 .unwrap();
523 let string = engine
524 .evaluate(request(serde_json::json!("1")))
525 .await
526 .unwrap();
527 match (&number.answers["structured"], &string.answers["structured"]) {
528 (Answer::Noul(x), Answer::Noul(y)) => assert_ne!(x.noul, y.noul),
529 _ => panic!("expected noul answers"),
530 }
531 }
532}