Skip to main content

openkind_core/
request.rs

1//! Request body. Spec: <https://docs.typesafe.ai/api#request-body>
2
3use std::collections::HashMap;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::question::Question;
9use crate::state::State;
10
11/// Hash builder for wire maps keyed by caller-chosen ids.
12///
13/// These maps are rebuilt on every request/response deserialization, where
14/// short string keys make SipHash a measurable share of parse time. foldhash
15/// keeps the same `HashMap` semantics (and randomized-per-instance keys)
16/// with a much cheaper hash. Wire bytes are unaffected: JSON object order
17/// is not part of the contract.
18pub type WireHashState = foldhash::fast::RandomState;
19
20/// Wire API version. Bumped on any breaking schema change.
21pub const API_VERSION: &str = "jev-compatible-0.1";
22
23/// Evaluation request payload representing a Jev-compatible System 1 judgment query.
24///
25/// See <https://docs.typesafe.ai/api#request-body> for the canonical wire specification.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
27pub struct SystemRequest {
28    /// Required. The content to evaluate.
29    pub state: State,
30
31    /// Required. `"jev-latest"` or a registered model alias.
32    /// We keep this required even though Phase 0 doesn't dispatch — the
33    /// engine layer in Phase 2 will use it to pick a backend.
34    pub model: String,
35
36    /// Required. Map of user-chosen id → typed question. Keys are NOT sent
37    /// to the model and are NOT used in inference (per spec).
38    #[serde(deserialize_with = "deserialize_questions")]
39    pub questions: HashMap<String, Question, WireHashState>,
40}
41
42/// Deserialize the question map pre-sized for typical batches.
43///
44/// JSON maps cannot advertise their length, so serde's generic
45/// deserialization starts at capacity zero and rehashes through the small
46/// growth ladder on every request. Pre-allocating for a typical batch, upgrading in one step
47/// once a batch proves large
48/// removes those intermediate reallocations; oversized batches simply grow
49/// as before, and the accepted wire format is unchanged.
50fn deserialize_questions<'de, D>(
51    deserializer: D,
52) -> Result<HashMap<String, Question, WireHashState>, D::Error>
53where
54    D: serde::Deserializer<'de>,
55{
56    struct QuestionsVisitor;
57
58    impl<'de> serde::de::Visitor<'de> for QuestionsVisitor {
59        type Value = HashMap<String, Question, WireHashState>;
60
61        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62            formatter.write_str("a map of questions")
63        }
64
65        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
66        where
67            A: serde::de::MapAccess<'de>,
68        {
69            let mut questions = HashMap::with_capacity_and_hasher(8, Default::default());
70            while let Some(id) = map.next_key::<String>()? {
71                questions.insert(id, map.next_value()?);
72                if questions.len() == 12 {
73                    // Larger batches jump straight to the big table instead
74                    // of climbing the small growth ladder.
75                    questions.reserve(20);
76                }
77            }
78            Ok(questions)
79        }
80    }
81
82    deserializer.deserialize_map(QuestionsVisitor)
83}