Skip to main content

openkind_engine/
engine.rs

1//! Decision engine trait definition and token estimation helpers.
2
3use async_trait::async_trait;
4use openkind_core::{ModelInfo, SystemRequest, SystemResponse};
5
6use crate::error::EngineResult;
7
8/// The trait every backend implements. The engine is **runtime-agnostic**:
9/// it doesn't know it's being served over HTTP or gRPC — that's deliberate.
10/// Callers pass `SystemRequest` in and get `SystemResponse` back.
11#[async_trait]
12pub trait DecisionEngine: Send + Sync {
13    /// Identifier of this backend, e.g. `"mock"`, `"qwen-3b-candle"`.
14    fn backend_id(&self) -> &str;
15
16    /// Public metadata shown by `GET /v1/models`. The default is a
17    /// minimal mock entry; real backends should override with a real
18    /// description and release date.
19    fn model_metadata(&self) -> ModelInfo {
20        ModelInfo {
21            name: self.backend_id().to_string(),
22            description: format!("{} backend", self.backend_id()),
23            release_date: "1970-01-01".to_string(),
24        }
25    }
26
27    /// Evaluate a single request. Returns one answer per question id.
28    async fn evaluate(&self, req: SystemRequest) -> EngineResult<SystemResponse>;
29
30    /// Estimate input tokens (cheap, before evaluation). The mock uses
31    /// a rough `bytes / 4` heuristic for state, instructions, and criteria;
32    /// real backends will use a tokenizer.
33    fn estimate_input_tokens(&self, req: &SystemRequest) -> u32 {
34        let state_chars = match &req.state {
35            openkind_core::State::Text(s) => s.len(),
36            openkind_core::State::Object(m) => count_json_bytes(m),
37            openkind_core::State::Array(a) => count_json_bytes(a),
38        };
39        let question_chars =
40            req.questions
41                .values()
42                .map(|q| match q {
43                    openkind_core::Question::Noul(n) => count_json_bytes(&n.instructions)
44                        .saturating_add(n.criteria.as_ref().map(count_json_bytes).unwrap_or(0)),
45                    openkind_core::Question::Choice(c) => count_json_bytes(&c.instructions)
46                        .saturating_add(count_json_bytes(&c.criteria)),
47                    openkind_core::Question::Score(s) => count_json_bytes(&s.instructions)
48                        .saturating_add(count_json_bytes(&s.criteria)),
49                })
50                .fold(0usize, usize::saturating_add);
51        let total_chars = state_chars.saturating_add(question_chars);
52        if total_chars == 0 {
53            0
54        } else {
55            u32::try_from(total_chars.div_ceil(4)).unwrap_or(u32::MAX)
56        }
57    }
58}
59
60/// Zero-allocation byte counter implementing std::io::Write.
61struct ByteCounter(usize);
62
63impl std::io::Write for ByteCounter {
64    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
65        self.0 = self.0.saturating_add(buf.len());
66        Ok(buf.len())
67    }
68
69    fn flush(&mut self) -> std::io::Result<()> {
70        Ok(())
71    }
72}
73
74fn count_json_bytes<T: serde::Serialize + ?Sized>(val: &T) -> usize {
75    let mut counter = ByteCounter(0);
76    serde_json::to_writer(&mut counter, val)
77        .map(|()| counter.0)
78        .unwrap_or(0)
79}