Skip to main content

omni_dev/jev/
protocol.rs

1//! Wire types for TypeSafe's Jev "System One" REST API.
2//!
3//! The API answers a **map** of named questions about one opaque `state` in
4//! a single request — `choice` (pick one of a labelled set), `score` (rate
5//! against an ordered scale) and `noul` (a yes/no probability) all share the
6//! one `/v1/systemone` endpoint, distinguished by an internally-tagged
7//! `type` field. See `docs/jev.md` for the operator-facing guide.
8//!
9//! Every map here is a [`BTreeMap`], never a `HashMap`: neither `indexmap`
10//! nor serde_json's `preserve_order` feature is a dependency, so a
11//! `HashMap` would serialise in random order on every run.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::jev::error::JevError;
18
19/// Default model identifier, used when no `--jev-model` / `TYPESAFE_MODEL`
20/// override is configured.
21pub const DEFAULT_MODEL: &str = "jev-latest";
22
23/// A `POST /v1/systemone` request body.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SystemOneRequest {
26    /// The opaque program state the questions are asked about. Accepted as
27    /// a JSON string, object, or array.
28    pub state: serde_json::Value,
29
30    /// The Jev model to use, e.g. [`DEFAULT_MODEL`].
31    pub model: String,
32
33    /// The questions to ask about `state`, keyed by caller-chosen name.
34    /// Answered in one parallel pass; the response's `answers` map uses the
35    /// same keys.
36    pub questions: BTreeMap<String, Question>,
37}
38
39/// A single question asked of the `state`.
40///
41/// Internally tagged on `type` (`choice` | `score` | `noul`) — each variant's
42/// shape, in particular whether `criteria` is a map, an ordered list, or
43/// optional, differs by primitive and is not expressible as one flat struct.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "lowercase")]
46pub enum Question {
47    /// Pick exactly one of a labelled set of options.
48    Choice {
49        /// Instructions describing what to choose and why.
50        instructions: String,
51        /// Option name to description, e.g. `{"billing": "Payment issues"}`.
52        /// A `BTreeMap` alphabetises option order in the outgoing request —
53        /// see `docs/jev.md` for the caveat this implies for `--option`.
54        criteria: BTreeMap<String, String>,
55    },
56
57    /// Rate the state against an ordered scale.
58    Score {
59        /// Instructions describing what to rate and how.
60        instructions: String,
61        /// The scale's levels, low to high. Order is significant and is
62        /// preserved (a `Vec`, not a map).
63        criteria: Vec<String>,
64    },
65
66    /// Estimate the probability of a yes/no condition.
67    Noul {
68        /// Instructions describing the yes/no condition to estimate.
69        instructions: String,
70        /// Optional descriptions of what "true" and "false" mean. Omitted
71        /// entirely from the request when `None`.
72        #[serde(skip_serializing_if = "Option::is_none")]
73        criteria: Option<NoulCriteria>,
74    },
75}
76
77/// Minimum number of options a [`Question::Choice`] must offer — picking
78/// "one of one" is not a judgment.
79pub const MIN_CHOICE_OPTIONS: usize = 2;
80
81/// Minimum number of levels a [`Question::Score`] scale must have.
82pub const MIN_SCORE_LEVELS: usize = 2;
83
84impl Question {
85    /// Rejects degenerate questions: a `choice` needs at least
86    /// [`MIN_CHOICE_OPTIONS`] options and a `score` at least
87    /// [`MIN_SCORE_LEVELS`] levels. `noul` has none.
88    ///
89    /// This is omni-dev policy, not an API constraint: the live API accepts a
90    /// one-option `choice` and a one-level `score` with `200`, but can only
91    /// ever answer them with `confidence: 1.0`, so the paid call carries no
92    /// information and almost always means a mistyped spec.
93    ///
94    /// Duplicate `choice` option names cannot be detected here — a
95    /// [`BTreeMap`] has already collapsed them — so the `--option` parser
96    /// checks those itself.
97    pub fn validate(&self) -> Result<(), JevError> {
98        match self {
99            Self::Choice { criteria, .. } if criteria.len() < MIN_CHOICE_OPTIONS => {
100                Err(JevError::InvalidQuestionSpec(format!(
101                    "a choice question needs at least {MIN_CHOICE_OPTIONS} options, got {}",
102                    criteria.len()
103                )))
104            }
105            Self::Score { criteria, .. } if criteria.len() < MIN_SCORE_LEVELS => {
106                Err(JevError::InvalidQuestionSpec(format!(
107                    "a score question needs at least {MIN_SCORE_LEVELS} levels, got {}",
108                    criteria.len()
109                )))
110            }
111            _ => Ok(()),
112        }
113    }
114}
115
116/// Optional descriptions of the two poles of a [`Question::Noul`] question.
117///
118/// Field names are the JSON string keys `"true"`/`"false"` (both Rust
119/// keywords, hence the renames) rather than a nested `{value, description}`
120/// shape, matching the verified wire format `{"true": "…", "false": "…"}`.
121#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
122pub struct NoulCriteria {
123    /// What a `true`-leaning answer means. Skipped when unset.
124    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
125    pub true_means: Option<String>,
126
127    /// What a `false`-leaning answer means. Skipped when unset.
128    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
129    pub false_means: Option<String>,
130}
131
132/// A `POST /v1/systemone` response body.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct SystemOneResponse {
135    /// The concrete model version that produced the answers, e.g.
136    /// `"jev-1.13.0"` (distinct from the request's `model`, which may be an
137    /// alias like [`DEFAULT_MODEL`]).
138    pub model: String,
139
140    /// One answer per requested question, keyed identically to the
141    /// request's `questions` map.
142    pub answers: BTreeMap<String, Answer>,
143
144    /// Token accounting for the request. Defaults to zero counts if the API
145    /// ever omits the field.
146    #[serde(default)]
147    pub usage: Usage,
148}
149
150/// The answer to a single [`Question`].
151///
152/// Internally tagged on `type`, mirroring [`Question`]. Kept strict — no
153/// catch-all variant — because `#[serde(other)]` only applies to unit
154/// variants in an internally-tagged enum, so a new field on an existing
155/// answer (which serde already ignores by default) is the free extension
156/// path; a genuinely new `type` is a hard-error case that should name the
157/// unrecognised value and suggest upgrading omni-dev, not silently swallow
158/// it into a catch-all.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "lowercase")]
161pub enum Answer {
162    /// The chosen option, with per-option probabilities.
163    Choice {
164        /// The selected option's name (one of the request's `criteria` keys).
165        choice: String,
166        /// Model confidence in `choice`, in `[0, 1]`.
167        confidence: f64,
168        /// Probability assigned to every option that was offered.
169        probabilities: BTreeMap<String, f64>,
170    },
171
172    /// The scored level, with a legend and per-level probabilities.
173    Score {
174        /// The estimated score, on the scale implied by the request's
175        /// `criteria` (index-like, not necessarily an integer).
176        score: f64,
177        /// Model confidence in `score`, in `[0, 1]`.
178        confidence: f64,
179        /// Scale index (as a string key, e.g. `"0"`) to its label.
180        legend: BTreeMap<String, String>,
181        /// Probability assigned to every scale level.
182        probabilities: BTreeMap<String, f64>,
183    },
184
185    /// The estimated yes/no probability.
186    ///
187    /// Deliberately has **no** `confidence` field — verified against a live
188    /// response; unlike `choice`/`score`, `noul`'s single probability value
189    /// already *is* its own confidence.
190    Noul {
191        /// Probability that the condition is true, in `[0, 1]`.
192        noul: f64,
193    },
194}
195
196/// Token accounting for one [`SystemOneRequest`]/[`SystemOneResponse`] pair.
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub struct Usage {
199    /// Number of input tokens consumed (state + questions).
200    pub input_tokens: u64,
201    /// Number of output tokens produced (the answers).
202    pub output_tokens: u64,
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::expect_used)]
207mod tests {
208    use super::*;
209
210    // ── Question → wire JSON ────────────────────────────────────────────
211
212    #[test]
213    fn choice_question_serialises_with_map_criteria() {
214        let question = Question::Choice {
215            instructions: "Route this".to_string(),
216            criteria: BTreeMap::from([
217                ("billing".to_string(), "Payments".to_string()),
218                ("technical".to_string(), "Bugs".to_string()),
219            ]),
220        };
221        let value = serde_json::to_value(&question).unwrap();
222        assert_eq!(
223            value,
224            serde_json::json!({
225                "type": "choice",
226                "instructions": "Route this",
227                "criteria": {"billing": "Payments", "technical": "Bugs"}
228            })
229        );
230    }
231
232    #[test]
233    fn score_question_serialises_with_ordered_array_criteria() {
234        let question = Question::Score {
235            instructions: "How urgent".to_string(),
236            criteria: vec!["Low".to_string(), "Medium".to_string(), "High".to_string()],
237        };
238        let value = serde_json::to_value(&question).unwrap();
239        assert_eq!(
240            value,
241            serde_json::json!({
242                "type": "score",
243                "instructions": "How urgent",
244                "criteria": ["Low", "Medium", "High"]
245            })
246        );
247    }
248
249    #[test]
250    fn noul_question_omits_criteria_when_none() {
251        let question = Question::Noul {
252            instructions: "Urgent?".to_string(),
253            criteria: None,
254        };
255        let value = serde_json::to_value(&question).unwrap();
256        assert_eq!(
257            value,
258            serde_json::json!({
259                "type": "noul",
260                "instructions": "Urgent?"
261            })
262        );
263    }
264
265    #[test]
266    fn noul_question_serialises_criteria_as_true_false_map() {
267        let question = Question::Noul {
268            instructions: "Urgent?".to_string(),
269            criteria: Some(NoulCriteria {
270                true_means: Some("Customer is frustrated".to_string()),
271                false_means: Some("Customer is calm".to_string()),
272            }),
273        };
274        let value = serde_json::to_value(&question).unwrap();
275        assert_eq!(
276            value,
277            serde_json::json!({
278                "type": "noul",
279                "instructions": "Urgent?",
280                "criteria": {
281                    "true": "Customer is frustrated",
282                    "false": "Customer is calm"
283                }
284            })
285        );
286    }
287
288    #[test]
289    fn noul_criteria_serialises_partial_sides() {
290        let criteria = NoulCriteria {
291            true_means: Some("yes".to_string()),
292            false_means: None,
293        };
294        let value = serde_json::to_value(&criteria).unwrap();
295        assert_eq!(value, serde_json::json!({"true": "yes"}));
296    }
297
298    // ── Real verified live response ─────────────────────────────────────
299
300    const LIVE_RESPONSE: &str = r#"{"model":"jev-1.13.0","answers":{"department":{"type":"choice","choice":"billing","confidence":0.97,"probabilities":{"sales":0.0,"technical":0.02,"billing":0.98}},"frustration":{"type":"score","score":1.01,"confidence":0.98,"legend":{"0":"Calm, just stating facts","1":"Frustrated but civil","2":"Very angry, strong language"},"probabilities":{"0":0.0,"1":0.99,"2":0.01}},"refund_requested":{"type":"noul","noul":0.98}},"usage":{"input_tokens":417,"output_tokens":71}}"#;
301
302    #[test]
303    fn live_response_deserialises() {
304        let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
305        assert_eq!(response.model, "jev-1.13.0");
306        assert_eq!(response.usage.input_tokens, 417);
307        assert_eq!(response.usage.output_tokens, 71);
308        assert_eq!(response.answers.len(), 3);
309
310        match &response.answers["department"] {
311            Answer::Choice {
312                choice,
313                confidence,
314                probabilities,
315            } => {
316                assert_eq!(choice, "billing");
317                assert!((confidence - 0.97).abs() < f64::EPSILON);
318                assert!((probabilities["billing"] - 0.98).abs() < f64::EPSILON);
319            }
320            other => panic!("expected Choice, got {other:?}"),
321        }
322
323        match &response.answers["frustration"] {
324            Answer::Score {
325                score,
326                confidence,
327                legend,
328                probabilities,
329            } => {
330                assert!((score - 1.01).abs() < f64::EPSILON);
331                assert!((confidence - 0.98).abs() < f64::EPSILON);
332                assert_eq!(legend["0"], "Calm, just stating facts");
333                assert!((probabilities["1"] - 0.99).abs() < f64::EPSILON);
334            }
335            other => panic!("expected Score, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn live_response_noul_answer_has_no_confidence_field() {
341        let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
342        match &response.answers["refund_requested"] {
343            Answer::Noul { noul } => assert!((noul - 0.98).abs() < f64::EPSILON),
344            other => panic!("expected Noul, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn unknown_extra_field_on_answer_is_ignored() {
350        let json = serde_json::json!({
351            "type": "noul",
352            "noul": 0.5,
353            "some_future_field": "unexpected"
354        });
355        let answer: Answer = serde_json::from_value(json).unwrap();
356        assert_eq!(answer, Answer::Noul { noul: 0.5 });
357    }
358
359    #[test]
360    fn usage_defaults_when_missing() {
361        let json = serde_json::json!({
362            "model": "jev-1.13.0",
363            "answers": {}
364        });
365        let response: SystemOneResponse = serde_json::from_value(json).unwrap();
366        assert_eq!(response.usage, Usage::default());
367    }
368
369    // ── Question::validate ──────────────────────────────────────────────
370
371    fn choice_with(n: usize) -> Question {
372        Question::Choice {
373            instructions: "Route this".to_string(),
374            criteria: (0..n)
375                .map(|i| (format!("o{i}"), "desc".to_string()))
376                .collect(),
377        }
378    }
379
380    fn score_with(n: usize) -> Question {
381        Question::Score {
382            instructions: "How urgent".to_string(),
383            criteria: (0..n).map(|i| format!("l{i}")).collect(),
384        }
385    }
386
387    #[test]
388    fn validate_rejects_choice_with_fewer_than_two_options() {
389        for n in [0, 1] {
390            let err = choice_with(n).validate().unwrap_err();
391            assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
392            assert!(err.to_string().contains(&format!("got {n}")));
393        }
394        choice_with(2).validate().unwrap();
395    }
396
397    #[test]
398    fn validate_rejects_score_with_fewer_than_two_levels() {
399        for n in [0, 1] {
400            let err = score_with(n).validate().unwrap_err();
401            assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
402            assert!(err.to_string().contains(&format!("got {n}")));
403        }
404        score_with(2).validate().unwrap();
405    }
406
407    #[test]
408    fn validate_accepts_noul_without_criteria() {
409        Question::Noul {
410            instructions: "Urgent?".to_string(),
411            criteria: None,
412        }
413        .validate()
414        .unwrap();
415    }
416
417    #[test]
418    fn default_model_constant() {
419        assert_eq!(DEFAULT_MODEL, "jev-latest");
420    }
421}