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}