Skip to main content

openkind_engine/
mock.rs

1//! `MockEngine` — Phase 1 placeholder for a real model.
2//!
3//! It returns deterministic but plausibly-shaped answers so we can test the
4//! full HTTP/gRPC/CLI path without loading any weights. Drop in the candle
5//! or GGUF engine in Phase 2 and the server is unchanged.
6
7use 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
18/// Hash the question id and instructions into a deterministic seed so
19/// repeated requests get the same answer. Determinism > realism here —
20/// we just want the server to be testable.
21///
22/// The instructions value is hashed structurally (variant-tagged fields)
23/// instead of being serialized to a JSON string first: same determinism,
24/// no per-question formatting or allocation on the request path.
25fn 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
34/// Fold a JSON value into a hasher with variant tags so distinct shapes
35/// (for example the string `"1"` and the number `1`) hash differently.
36fn 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
79/// Small deterministic SplitMix64 generator. Mock answers only need stable
80/// per-question determinism, not cryptographic quality, and seeding a
81/// ChaCha-backed `StdRng` per question dominated mock dispatch profiles.
82struct 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    /// Uniform `f64` in `[0, 1)` with 53 bits of resolution.
98    fn next_f64(&mut self) -> f64 {
99        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
100    }
101
102    /// Uniform integer in `[0, n)` via multiply-shift.
103    fn next_below(&mut self, n: usize) -> usize {
104        ((self.next_u64() as u128 * n as u128) >> 64) as usize
105    }
106}
107
108/// A mock that returns distributions seeded from `(id, instructions)`.
109/// Always satisfies sum-to-1 and confidence-in-range so the validator
110/// passes.
111pub struct MockEngine {
112    backend: String,
113}
114
115impl MockEngine {
116    /// Create a new `MockEngine` with the default backend identifier (`"mock"`).
117    pub fn new() -> Self {
118        Self {
119            backend: "mock".into(),
120        }
121    }
122
123    /// Create a new `MockEngine` with a custom backend identifier string.
124    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(), // EngineRegistry overrides with the alias.
146            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(); // HashMap iteration is non-deterministic
163                    if keys.is_empty() {
164                        // `dispatch` validates requests first, but the engine is
165                        // also a public library API — refuse instead of panicking.
166                        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                    // Tie-break on the keys' sorted order so we always
185                    // pick the same option for the same (id, instructions)
186                    // seed even with float noise.
187                    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                    // Bias the peak toward the middle of the range — feels
222                    // more realistic than uniform for a mock.
223                    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            // Filled in by `dispatch` via the estimator.
273            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        // One representative per `Value` variant, plus the integer/float
442        // number encodings: the variant tags must keep every seed distinct.
443        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        // Same input always hashes to the same seed.
460        for value in &shapes {
461            assert_eq!(seed_for_question("q", value), seed_for_question("q", value));
462        }
463        // Deeply nested values recurse without panicking and stay stable.
464        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        // The degenerate single-criterion score draw has exactly one
485        // valid index.
486        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        // The string `"1"` and the number `1` hash differently, so their
518        // answers must not alias.
519        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}