Skip to main content

cerno_types/
lib.rs

1//! Wire types for the cerno HTTP API.
2//!
3//! This crate is deliberately dependency-light: it carries `serde` always and `utoipa` only
4//! behind the `schema` feature. That is what lets `cerno-sdk` share the exact types the server
5//! serialises without pulling the engine or the OpenAPI machinery along with them.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10#[cfg(feature = "schema")]
11use utoipa::ToSchema;
12
13/// Maximum number of answer options in a single question.
14///
15/// Bounded by Ollama's `top_logprobs` ceiling of 20: the engine reads the distribution over the
16/// first generated token, so an option whose label is not among those 20 entries has no
17/// observable probability. See [`Answer`]'s `truncated` flag for the partial case.
18pub const MAX_OPTIONS: usize = 20;
19
20/// Most questions in a single request.
21///
22/// Every question is a forward pass, and they share one concurrency limit with every other
23/// caller. Without a bound, one request could queue thousands of passes ahead of everyone else.
24pub const MAX_QUESTIONS: usize = 32;
25
26/// Fewest levels a [`ScoreSpec`] rubric may have. One level is not a judgement.
27pub const MIN_LEVELS: u8 = 2;
28
29/// Most levels a [`ScoreSpec`] rubric may have, matching JEV's rubric range of 2..=10.
30pub const MAX_LEVELS: u8 = 10;
31
32// ---------------------------------------------------------------------------------------------
33// Request
34// ---------------------------------------------------------------------------------------------
35
36/// A shared state plus the questions to ask about it.
37///
38/// Unknown fields are refused rather than ignored: a misspelt `calibraton` would otherwise
39/// be dropped without a word, and the answers would come back uncalibrated.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42#[cfg_attr(feature = "schema", derive(ToSchema))]
43pub struct SystemOneRequest {
44    /// The context every question is asked against.
45    pub state: String,
46
47    /// Model name or configured alias. Falls back to the server's default when absent.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub model: Option<String>,
50
51    /// Overrides the calibration applied to label logits.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub calibration: Option<Calibration>,
54
55    /// The questions. Ids must be unique within a request.
56    pub questions: Vec<Question>,
57}
58
59/// Temperature scaling applied to label logits before the softmax.
60///
61/// `1.0` leaves the model's raw distribution untouched. Values above 1 flatten it, which is the
62/// usual correction for instruct-tuned models that answer clear cases at a probability of 1.0.
63#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65#[cfg_attr(feature = "schema", derive(ToSchema))]
66pub struct Calibration {
67    pub temperature: f64,
68}
69
70impl Default for Calibration {
71    fn default() -> Self {
72        Self { temperature: 1.0 }
73    }
74}
75
76/// One question, identified so its answer can be found in the response map. It names exactly one
77/// primitive.
78//
79// Read through `RawQuestion` rather than derived: with `flatten`, serde would take the first
80// primitive key it met and silently drop a second one, and it cannot refuse unknown fields.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(try_from = "RawQuestion")]
83#[cfg_attr(feature = "schema", derive(ToSchema))]
84pub struct Question {
85    pub id: String,
86    #[serde(flatten)]
87    pub kind: QuestionKind,
88}
89
90/// The wire form of a [`Question`], before it is checked to name exactly one primitive.
91#[derive(Deserialize)]
92#[serde(deny_unknown_fields)]
93struct RawQuestion {
94    id: String,
95    #[serde(default)]
96    noul: Option<String>,
97    #[serde(default)]
98    choice: Option<ChoiceSpec>,
99    #[serde(default)]
100    score: Option<ScoreSpec>,
101}
102
103impl TryFrom<RawQuestion> for Question {
104    type Error = String;
105
106    fn try_from(raw: RawQuestion) -> Result<Self, Self::Error> {
107        let kind = match (raw.noul, raw.choice, raw.score) {
108            (Some(noul), None, None) => QuestionKind::Noul(noul),
109            (None, Some(choice), None) => QuestionKind::Choice(choice),
110            (None, None, Some(score)) => QuestionKind::Score(score),
111            _ => {
112                return Err(format!(
113                    "question {:?} must have exactly one of `noul`, `choice` or `score`",
114                    raw.id
115                ));
116            }
117        };
118        Ok(Self { id: raw.id, kind })
119    }
120}
121
122/// The three primitives. Externally tagged, so the wire form is `{"id": .., "noul": ..}`.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125#[cfg_attr(feature = "schema", derive(ToSchema))]
126pub enum QuestionKind {
127    /// How likely the answer is yes.
128    Noul(String),
129    /// One of up to [`MAX_OPTIONS`] options.
130    Choice(ChoiceSpec),
131    /// A position on a rubric of [`MIN_LEVELS`]..=[`MAX_LEVELS`] levels.
132    Score(ScoreSpec),
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137#[cfg_attr(feature = "schema", derive(ToSchema))]
138pub struct ChoiceSpec {
139    /// What is being asked. Omit when the options speak for themselves.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub question: Option<String>,
142    pub options: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147#[cfg_attr(feature = "schema", derive(ToSchema))]
148pub struct ScoreSpec {
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub question: Option<String>,
151    pub levels: LevelSpec,
152}
153
154/// A rubric given either as a plain count or as the text of each level.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(untagged)]
157#[cfg_attr(feature = "schema", derive(ToSchema))]
158pub enum LevelSpec {
159    /// `"levels": 5` — the engine generates the legend ("1" through "5").
160    ///
161    /// Wider than the range it may hold, so `"levels": 300` reaches validation and is refused as
162    /// `invalid_levels` naming the bounds, rather than failing to match either variant of this
163    /// untagged enum with a message that names neither.
164    Count(u32),
165    /// `"levels": ["gar nicht", "wenig", ...]` — the strings become the legend.
166    Labels(Vec<String>),
167}
168
169impl LevelSpec {
170    /// The legend, one entry per level, lowest first.
171    pub fn legend(&self) -> Vec<String> {
172        match self {
173            Self::Count(n) => (1..=*n).map(|i| i.to_string()).collect(),
174            Self::Labels(labels) => labels.clone(),
175        }
176    }
177
178    pub fn count(&self) -> usize {
179        match self {
180            Self::Count(n) => *n as usize,
181            Self::Labels(labels) => labels.len(),
182        }
183    }
184}
185
186// ---------------------------------------------------------------------------------------------
187// Response
188// ---------------------------------------------------------------------------------------------
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[cfg_attr(feature = "schema", derive(ToSchema))]
192pub struct SystemOneResponse {
193    /// Answers keyed by the question id they were asked under.
194    pub answers: BTreeMap<String, Answer>,
195    /// The model actually used, after alias resolution.
196    pub model: String,
197    pub usage: Usage,
198    pub timing_ms: Timing,
199}
200
201/// A typed answer.
202///
203/// Every variant carries `raw_logprobs`, `truncated` and `truncated_labels` so a caller can redo
204/// the normalisation itself — calibration is a convenience here, never a place where information
205/// is lost.
206///
207/// A label in `truncated_labels` fell outside the host's reporting window, so its entry in
208/// `raw_logprobs` is the weakest reported logprob: an upper bound, not an observation.
209/// `truncated` is true exactly when that list is not empty.
210///
211/// `label_mass` is how much of the model's first-token probability fell on the offered labels,
212/// in `0.0..=1.0`, counting only labels the host reported. The probabilities are normalised over
213/// the labels alone, so they look just as decisive when the model was about to write something
214/// else entirely and the letters were an afterthought at `-8`. Near 1 the model answered with a
215/// letter; well below it, the answer was read off tokens the model was not going to produce.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(tag = "type", rename_all = "snake_case")]
218#[cfg_attr(feature = "schema", derive(ToSchema))]
219pub enum Answer {
220    Noul {
221        /// Probability that the answer is yes, in `0.0..=1.0`.
222        noul: f64,
223        raw_logprobs: BTreeMap<String, f64>,
224        truncated: bool,
225        truncated_labels: Vec<String>,
226        label_mass: f64,
227    },
228    Choice {
229        /// The winning option, verbatim as it was supplied.
230        choice: String,
231        /// Its position in the request's `options`.
232        index: usize,
233        confidence: f64,
234        /// Every option with its probability, in request order.
235        probabilities: Vec<OptionProbability>,
236        raw_logprobs: BTreeMap<String, f64>,
237        truncated: bool,
238        truncated_labels: Vec<String>,
239        label_mass: f64,
240    },
241    Score {
242        /// The most likely level, 1-based.
243        score: u8,
244        /// The probability-weighted mean level — often more useful than the argmax.
245        expected_score: f64,
246        /// The legend entry for `score`.
247        legend: String,
248        confidence: f64,
249        probabilities: Vec<LevelProbability>,
250        raw_logprobs: BTreeMap<String, f64>,
251        truncated: bool,
252        truncated_labels: Vec<String>,
253        label_mass: f64,
254    },
255}
256
257impl Answer {
258    /// Whether some label fell outside the host's `top_logprobs` window, making its probability
259    /// an upper bound rather than an observation.
260    pub fn truncated(&self) -> bool {
261        match self {
262            Self::Noul { truncated, .. }
263            | Self::Choice { truncated, .. }
264            | Self::Score { truncated, .. } => *truncated,
265        }
266    }
267
268    /// The labels whose logprob is a bound rather than an observation. Empty unless
269    /// [`Answer::truncated`].
270    pub fn truncated_labels(&self) -> &[String] {
271        match self {
272            Self::Noul {
273                truncated_labels, ..
274            }
275            | Self::Choice {
276                truncated_labels, ..
277            }
278            | Self::Score {
279                truncated_labels, ..
280            } => truncated_labels,
281        }
282    }
283
284    /// How much of the model's first-token probability fell on the offered labels. See
285    /// [`Answer`].
286    pub fn label_mass(&self) -> f64 {
287        match self {
288            Self::Noul { label_mass, .. }
289            | Self::Choice { label_mass, .. }
290            | Self::Score { label_mass, .. } => *label_mass,
291        }
292    }
293
294    /// How peaked the distribution is, in `0.0..=1.0`. See `cerno_core::math::confidence`.
295    /// `None` for a noul: as in JEV, its probability is already the whole answer.
296    pub fn confidence(&self) -> Option<f64> {
297        match self {
298            Self::Noul { .. } => None,
299            Self::Choice { confidence, .. } | Self::Score { confidence, .. } => Some(*confidence),
300        }
301    }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[cfg_attr(feature = "schema", derive(ToSchema))]
306pub struct OptionProbability {
307    pub option: String,
308    pub probability: f64,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "schema", derive(ToSchema))]
313pub struct LevelProbability {
314    pub level: u8,
315    pub legend: String,
316    pub probability: f64,
317}
318
319#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
320#[cfg_attr(feature = "schema", derive(ToSchema))]
321pub struct Usage {
322    /// Prompt tokens summed over every question in the request.
323    pub input_tokens: u32,
324    pub questions: usize,
325}
326
327#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
328#[cfg_attr(feature = "schema", derive(ToSchema))]
329pub struct Timing {
330    pub total: u64,
331}
332
333// ---------------------------------------------------------------------------------------------
334// Models
335// ---------------------------------------------------------------------------------------------
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[cfg_attr(feature = "schema", derive(ToSchema))]
339pub struct ModelsResponse {
340    pub models: Vec<ModelInfo>,
341    /// Which entry answers a request that names no model.
342    pub default: String,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[cfg_attr(feature = "schema", derive(ToSchema))]
347pub struct ModelInfo {
348    /// The name callers pass as `model`.
349    pub alias: String,
350    /// The host-level model this resolves to.
351    pub model: String,
352    pub calibration: Calibration,
353}
354
355// ---------------------------------------------------------------------------------------------
356// Errors
357// ---------------------------------------------------------------------------------------------
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "schema", derive(ToSchema))]
361pub struct ErrorResponse {
362    pub code: ErrorCode,
363    pub message: String,
364    /// Set when exactly one question is at fault.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub question_id: Option<String>,
367}
368
369/// Machine-readable failure reasons. Callers branch on these, not on `message`.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "snake_case")]
372#[cfg_attr(feature = "schema", derive(ToSchema))]
373pub enum ErrorCode {
374    /// More than [`MAX_OPTIONS`] options. v1 does not split these across passes.
375    TooManyOptions,
376    /// More than [`MAX_QUESTIONS`] questions in one request.
377    TooManyQuestions,
378    /// The body is not a request at all: malformed JSON, a wrong type, an unknown field, or a
379    /// question naming more or fewer than one primitive.
380    InvalidRequest,
381    /// Option list empty, or a single option — there is nothing to decide.
382    TooFewOptions,
383    /// Level count outside [`MIN_LEVELS`]..=[`MAX_LEVELS`].
384    InvalidLevels,
385    EmptyState,
386    EmptyQuestion,
387    DuplicateQuestionId,
388    /// A question id that is empty or only whitespace. The id is how its answer is found.
389    EmptyQuestionId,
390    /// Two options, or two score levels, with the same text. The model is shown both, and the
391    /// probability splits between them.
392    DuplicateOption,
393    NoQuestions,
394    UnknownModel,
395    InvalidCalibration,
396    /// The model answered with no recognisable label — it is not following the instruction.
397    NoLabelMatched,
398    HostUnavailable,
399    HostTimeout,
400    /// A failure inside cerno itself. Not the caller's doing, and not the model's.
401    Internal,
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use serde_json::json;
408
409    fn question(value: serde_json::Value) -> Result<Question, serde_json::Error> {
410        serde_json::from_value(value)
411    }
412
413    #[test]
414    fn each_primitive_reads_back_from_its_wire_form() {
415        assert!(matches!(
416            question(json!({"id": "u", "noul": "Urgent?"}))
417                .unwrap()
418                .kind,
419            QuestionKind::Noul(_)
420        ));
421        assert!(matches!(
422            question(json!({"id": "t", "choice": {"options": ["a", "b"]}}))
423                .unwrap()
424                .kind,
425            QuestionKind::Choice(_)
426        ));
427        assert!(matches!(
428            question(json!({"id": "s", "score": {"levels": 5}}))
429                .unwrap()
430                .kind,
431            QuestionKind::Score(_)
432        ));
433    }
434
435    /// With a derived `flatten`, the first key would win and the second would vanish.
436    #[test]
437    fn a_question_naming_two_primitives_is_refused() {
438        let err = question(json!({
439            "id": "x",
440            "noul": "Urgent?",
441            "choice": {"options": ["a", "b"]}
442        }))
443        .unwrap_err();
444
445        assert!(err.to_string().contains("exactly one"), "{err}");
446    }
447
448    #[test]
449    fn a_question_naming_no_primitive_is_refused() {
450        assert!(question(json!({"id": "x"})).is_err());
451    }
452
453    /// A typo must fail loudly rather than drop the field it was meant to be.
454    #[test]
455    fn unknown_fields_are_refused_at_every_level() {
456        assert!(question(json!({"id": "x", "chioce": {"options": ["a", "b"]}})).is_err());
457        assert!(question(json!({"id": "x", "choice": {"options": ["a"], "opts": []}})).is_err());
458
459        let request = json!({
460            "state": "s",
461            "calibraton": {"temperature": 2.0},
462            "questions": [{"id": "u", "noul": "Urgent?"}]
463        });
464        assert!(serde_json::from_value::<SystemOneRequest>(request).is_err());
465    }
466
467    /// A question serialises to the same shape it is read from.
468    #[test]
469    fn a_question_round_trips() {
470        let wire = json!({"id": "t", "choice": {"question": "Which?", "options": ["a", "b"]}});
471
472        let back = serde_json::to_value(question(wire.clone()).unwrap()).unwrap();
473
474        assert_eq!(back, wire);
475    }
476}