Skip to main content

cerno_host/
systemone.rs

1//! Adapter for Ollama's `POST /v1/systemone`, which its decision models (`nimble`, `tev1`) were
2//! built for.
3//!
4//! Ollama writes the prompt itself and answers with a probability per offered option, so this
5//! host reads the [`Offer`] and ignores the rendered prompt. It hands the answer back as a
6//! distribution whose tokens are exactly the offered labels at `ln p`, which lets `cerno-core`
7//! fold, calibrate and normalise it on the same path as every other host.
8
9use crate::{
10    FirstTokenDistribution, FirstTokenRequest, HostCapabilities, HostError, ModelHost, Offer, Shape,
11};
12use async_trait::async_trait;
13use serde::ser::SerializeMap;
14use serde::{Deserialize, Serialize, Serializer};
15use std::collections::BTreeMap;
16use std::time::{Duration, Instant};
17
18/// The endpoint takes 2–26 options per choice and returns a probability for every one, so no
19/// option is ever outside a window. `Engine::max_options` still caps a choice at `MAX_OPTIONS`.
20const SYSTEMONE_MAX_OPTIONS: usize = 26;
21
22#[derive(Serialize)]
23struct Request<'a> {
24    model: &'a str,
25    state: &'a str,
26    /// Always exactly one entry, keyed by the question id: the engine asks one question per
27    /// host call.
28    questions: BTreeMap<&'a str, Ask<'a>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    keep_alive: Option<&'a str>,
31}
32
33#[derive(Serialize)]
34#[serde(tag = "type", rename_all = "snake_case")]
35enum Ask<'a> {
36    Noul {
37        instructions: &'a str,
38    },
39    Choice {
40        instructions: &'a str,
41        criteria: NullCriteria<'a>,
42    },
43    Score {
44        instructions: &'a str,
45        /// Level descriptions, lowest first. The endpoint numbers them from 0.
46        criteria: Vec<&'a str>,
47    },
48}
49
50/// Options as a JSON object in the order given, each mapped to `null` so the name describes
51/// itself. Hand-written because a map type would alphabetise the options and change what the
52/// model sees, and serde_json's `preserve_order` would reorder JSON across the whole workspace.
53struct NullCriteria<'a>(&'a [(String, String)]);
54
55impl Serialize for NullCriteria<'_> {
56    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
57        let mut map = serializer.serialize_map(Some(self.0.len()))?;
58        for (_, text) in self.0 {
59            map.serialize_entry(text, &())?;
60        }
61        map.end()
62    }
63}
64
65/// The request for one question. The endpoint refuses empty instructions, so a choice or score
66/// asked without a question gets a neutral one.
67fn body(req: &FirstTokenRequest) -> Request<'_> {
68    let offer = &req.offer;
69    let instructions = |default| offer.question.as_deref().unwrap_or(default);
70    let ask = match offer.shape {
71        Shape::YesNo => Ask::Noul {
72            instructions: instructions("Is this true?"),
73        },
74        Shape::Pick => Ask::Choice {
75            instructions: instructions("Which option applies?"),
76            criteria: NullCriteria(&offer.options),
77        },
78        Shape::Scale => Ask::Score {
79            instructions: instructions("Which level applies?"),
80            criteria: offer.options.iter().map(|(_, t)| t.as_str()).collect(),
81        },
82    };
83    Request {
84        model: &req.model,
85        state: &offer.state,
86        questions: BTreeMap::from([(offer.question_id.as_str(), ask)]),
87        keep_alive: req.keep_alive.as_deref(),
88    }
89}
90
91#[derive(Deserialize)]
92struct Response {
93    answers: BTreeMap<String, Reply>,
94    #[serde(default)]
95    usage: Usage,
96}
97
98#[derive(Default, Deserialize)]
99struct Usage {
100    #[serde(default)]
101    input_tokens: u32,
102}
103
104/// One answer. `choice`, `score`, `legend` and `confidence` are not read: cerno picks by argmax
105/// and computes its own confidence, and a score's `score` is a probability-weighted level, not
106/// the pick.
107#[derive(Deserialize)]
108#[serde(tag = "type", rename_all = "snake_case")]
109enum Reply {
110    Noul {
111        noul: f64,
112    },
113    Choice {
114        probabilities: BTreeMap<String, f64>,
115    },
116    Score {
117        probabilities: BTreeMap<String, f64>,
118    },
119}
120
121pub struct SystemOneHost {
122    client: reqwest::Client,
123    base_url: String,
124    timeout: Duration,
125}
126
127impl SystemOneHost {
128    pub fn new(base_url: impl Into<String>, timeout: Duration) -> Result<Self, HostError> {
129        let client = reqwest::Client::builder()
130            .timeout(timeout)
131            .build()
132            .map_err(|e| HostError::Unavailable(e.to_string()))?;
133        Ok(Self {
134            client,
135            base_url: crate::base_url(&base_url.into())?,
136            timeout,
137        })
138    }
139}
140
141#[async_trait]
142impl ModelHost for SystemOneHost {
143    fn capabilities(&self) -> HostCapabilities {
144        HostCapabilities {
145            max_top_logprobs: SYSTEMONE_MAX_OPTIONS,
146        }
147    }
148
149    fn name(&self) -> &str {
150        "systemone"
151    }
152
153    async fn first_token(
154        &self,
155        req: FirstTokenRequest,
156    ) -> Result<FirstTokenDistribution, HostError> {
157        let started = Instant::now();
158
159        let response = self
160            .client
161            .post(format!("{}/v1/systemone", self.base_url))
162            .json(&body(&req))
163            .send()
164            .await
165            .map_err(|e| HostError::transport(&e, self.timeout))?;
166
167        let status = response.status().as_u16();
168        let text = response
169            .text()
170            .await
171            .map_err(|e| HostError::transport(&e, self.timeout))?;
172
173        if status >= 400 {
174            return Err(HostError::status(status, text));
175        }
176
177        parse_answer(&text, &req.offer, started.elapsed())
178    }
179}
180
181/// `ln p`, clamped so a reported 0 stays finite: `raw_logprobs` is serialised to JSON, which has
182/// no `-inf`. A softmax of these at T=1 gives back `p`.
183fn ln(p: f64) -> f64 {
184    p.max(1e-12).ln()
185}
186
187fn wire_type(shape: Shape) -> &'static str {
188    match shape {
189        Shape::YesNo => "noul",
190        Shape::Pick => "choice",
191        Shape::Scale => "score",
192    }
193}
194
195/// Read the answer back into the offered labels, one token per label.
196fn parse_answer(
197    text: &str,
198    offer: &Offer,
199    latency: Duration,
200) -> Result<FirstTokenDistribution, HostError> {
201    let mut response: Response =
202        serde_json::from_str(text).map_err(|e| HostError::Protocol(e.to_string()))?;
203    let reply = response.answers.remove(&offer.question_id).ok_or_else(|| {
204        HostError::Protocol(format!("no answer for question {:?}", offer.question_id))
205    })?;
206
207    let mut tokens: Vec<(String, f64)> = match (offer.shape, reply) {
208        (Shape::YesNo, Reply::Noul { noul }) => vec![
209            (offer.options[0].0.clone(), ln(noul)),
210            (offer.options[1].0.clone(), ln(1.0 - noul)),
211        ],
212        (Shape::Pick, Reply::Choice { probabilities }) => offer
213            .options
214            .iter()
215            .map(|(label, text)| {
216                probabilities
217                    .get(text)
218                    .map(|&p| (label.clone(), ln(p)))
219                    .ok_or_else(|| {
220                        HostError::Protocol(format!("no probability for option {text:?}"))
221                    })
222            })
223            .collect::<Result<_, _>>()?,
224        (Shape::Scale, Reply::Score { probabilities }) => offer
225            .options
226            .iter()
227            .enumerate()
228            .map(|(k, (label, _))| {
229                probabilities
230                    .get(&k.to_string())
231                    .map(|&p| (label.clone(), ln(p)))
232                    .ok_or_else(|| HostError::Protocol(format!("no probability for level {k}")))
233            })
234            .collect::<Result<_, _>>()?,
235        (shape, reply) => {
236            let got = match reply {
237                Reply::Noul { .. } => "noul",
238                Reply::Choice { .. } => "choice",
239                Reply::Score { .. } => "score",
240            };
241            return Err(HostError::Protocol(format!(
242                "asked a {} question, got a {got} answer",
243                wire_type(shape)
244            )));
245        }
246    };
247
248    tokens.sort_by(|a, b| b.1.total_cmp(&a.1));
249    let floor = tokens.last().map(|(_, lp)| *lp).unwrap_or(ln(0.0));
250
251    Ok(FirstTokenDistribution {
252        tokens,
253        floor,
254        input_tokens: response.usage.input_tokens,
255        latency,
256    })
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use serde_json::json;
263
264    fn offer(shape: Shape, question: Option<&str>, texts: &[&str]) -> Offer {
265        Offer {
266            question_id: "q".into(),
267            state: "s".into(),
268            question: question.map(str::to_string),
269            shape,
270            options: texts
271                .iter()
272                .enumerate()
273                .map(|(i, t)| (((b'A' + i as u8) as char).to_string(), t.to_string()))
274                .collect(),
275        }
276    }
277
278    fn request(offer: Offer, keep_alive: Option<&str>) -> FirstTokenRequest {
279        FirstTokenRequest {
280            model: "nimble".into(),
281            system: Some("ignored".into()),
282            user: "ignored".into(),
283            top_logprobs: 20,
284            keep_alive: keep_alive.map(str::to_string),
285            offer,
286        }
287    }
288
289    fn parse(
290        offer: &Offer,
291        response: serde_json::Value,
292    ) -> Result<FirstTokenDistribution, HostError> {
293        parse_answer(&response.to_string(), offer, Duration::ZERO)
294    }
295
296    #[test]
297    fn a_choice_keeps_the_option_order() {
298        let req = request(
299            offer(
300                Shape::Pick,
301                Some("Wer?"),
302                &["IT", "Personal", "Vertrieb", "Buchhaltung"],
303            ),
304            Some("5m"),
305        );
306
307        let body = serde_json::to_string(&body(&req)).unwrap();
308
309        assert!(
310            body.contains(
311                r#""criteria":{"IT":null,"Personal":null,"Vertrieb":null,"Buchhaltung":null}"#
312            ),
313            "{body}"
314        );
315    }
316
317    #[test]
318    fn each_shape_is_sent_as_its_primitive() {
319        let noul = request(
320            offer(Shape::YesNo, Some("Urgent?"), &["Yes", "No"]),
321            Some("5m"),
322        );
323        let json = serde_json::to_value(body(&noul)).unwrap();
324        assert_eq!(
325            json,
326            json!({
327                "model": "nimble",
328                "state": "s",
329                "questions": {"q": {"type": "noul", "instructions": "Urgent?"}},
330                "keep_alive": "5m"
331            })
332        );
333
334        let scale = request(
335            offer(Shape::Scale, Some("How bad?"), &["1", "2", "3", "4", "5"]),
336            None,
337        );
338        let json = serde_json::to_value(body(&scale)).unwrap();
339        assert_eq!(
340            json["questions"]["q"],
341            json!({"type": "score", "instructions": "How bad?", "criteria": ["1", "2", "3", "4", "5"]})
342        );
343        assert!(json.get("keep_alive").is_none(), "{json}");
344
345        let unasked = request(offer(Shape::Pick, None, &["x", "y"]), None);
346        let json = serde_json::to_value(body(&unasked)).unwrap();
347        assert_eq!(
348            json["questions"]["q"]["instructions"],
349            "Which option applies?"
350        );
351    }
352
353    #[test]
354    fn a_score_is_read_from_zero_based_levels() {
355        let o = offer(Shape::Scale, None, &["1", "2", "3", "4", "5"]);
356        let probabilities = [0.1, 0.2, 0.6, 0.05, 0.05];
357
358        let dist = parse(
359            &o,
360            json!({
361                "answers": {"q": {
362                    "type": "score",
363                    "score": 1.75,
364                    "legend": {"0": "1", "1": "2", "2": "3", "3": "4", "4": "5"},
365                    "probabilities": {"0": 0.1, "1": 0.2, "2": 0.6, "3": 0.05, "4": 0.05},
366                    "confidence": 0.3
367                }},
368                "usage": {"input_tokens": 145, "output_tokens": 1}
369            }),
370        )
371        .unwrap();
372
373        assert_eq!(dist.tokens[0].0, "C");
374        for (label, p) in ["A", "B", "C", "D", "E"].iter().zip(probabilities) {
375            assert!(
376                (dist.logprob(label).unwrap().exp() - p).abs() < 1e-9,
377                "{label}"
378            );
379        }
380        assert_eq!(dist.input_tokens, 145);
381    }
382
383    #[test]
384    fn a_reported_zero_stays_finite() {
385        let o = offer(Shape::YesNo, Some("?"), &["Yes", "No"]);
386
387        let dist = parse(&o, json!({"answers": {"q": {"type": "noul", "noul": 1.0}}})).unwrap();
388
389        assert_eq!(dist.tokens[0], ("A".to_string(), 0.0));
390        assert_eq!(dist.logprob("B"), Some(1e-12_f64.ln()));
391        assert!(dist.floor.is_finite());
392    }
393
394    #[test]
395    fn an_answer_outside_the_offer_is_a_protocol_error() {
396        let pick = offer(Shape::Pick, None, &["IT", "Personal", "Vertrieb"]);
397        let scale = offer(Shape::Scale, None, &["low", "mid", "high"]);
398
399        let cases = [
400            (
401                &pick,
402                json!({"answers": {"q": {"type": "choice", "choice": "IT",
403                    "probabilities": {"IT": 0.4, "Personal": 0.6}}}}),
404                "Vertrieb",
405            ),
406            (
407                &scale,
408                json!({"answers": {"q": {"type": "score",
409                    "probabilities": {"0": 0.4, "1": 0.6}}}}),
410                "level 2",
411            ),
412            (
413                &pick,
414                json!({"answers": {"q": {"type": "noul", "noul": 0.9}}}),
415                "asked a choice question, got a noul answer",
416            ),
417            (
418                &pick,
419                json!({"answers": {"other": {"type": "noul", "noul": 0.9}}}),
420                "no answer for question \"q\"",
421            ),
422        ];
423
424        for (offer, response, says) in cases {
425            match parse(offer, response) {
426                Err(HostError::Protocol(message)) => {
427                    assert!(message.contains(says), "{message}")
428                }
429                other => panic!("{says}: {:?}", other.map(|d| d.tokens)),
430            }
431        }
432    }
433
434    #[tokio::test]
435    async fn an_unknown_model_is_a_404() {
436        let mut server = mockito::Server::new_async().await;
437        let mock = server
438            .mock("POST", "/v1/systemone")
439            .with_status(404)
440            .with_body(r#"{"error":"model \"does-not-exist\" not found, try pulling it first"}"#)
441            .expect(1)
442            .create_async()
443            .await;
444        let host = SystemOneHost::new(server.url(), Duration::from_secs(5)).unwrap();
445
446        let err = host
447            .first_token(request(
448                offer(Shape::YesNo, Some("?"), &["Yes", "No"]),
449                None,
450            ))
451            .await
452            .unwrap_err();
453
454        assert!(
455            matches!(err, HostError::Status { status: 404, ref body } if body.contains("not found")),
456            "{err}"
457        );
458        mock.assert_async().await;
459    }
460}