Skip to main content

cerno_core/
engine.rs

1//! The one path all three primitives take.
2//!
3//! Noul, Choice and Score differ only in what the options are and how the resulting
4//! distribution is named. Everything between — labelling, prompting, the single host call,
5//! folding token variants, substituting the floor, calibrating, normalising — is shared.
6
7use crate::{labels, math, prompt};
8use cerno_host::{FirstTokenDistribution, FirstTokenRequest, HostError, ModelHost, Offer, Shape};
9use cerno_types::{
10    Answer, Calibration, ErrorCode, LevelProbability, MAX_LEVELS, MAX_OPTIONS, MAX_QUESTIONS,
11    MIN_LEVELS, OptionProbability, Question, QuestionKind, SystemOneRequest,
12};
13use std::collections::BTreeMap;
14use std::sync::Arc;
15
16#[derive(Debug, thiserror::Error)]
17pub enum EngineError {
18    #[error("{message}")]
19    Invalid {
20        code: ErrorCode,
21        message: String,
22        question_id: Option<String>,
23    },
24
25    /// The model answered, but with nothing resembling one of the labels we offered.
26    ///
27    /// Distinct from a host failure: the runtime worked, the model simply did not follow the
28    /// instruction. Almost always this means the model is too small or too chatty for the job,
29    /// which is what the benchmark in `cerno-bench` exists to catch before deployment.
30    #[error(
31        "model answered question {question_id:?} with none of the labels {expected:?}; \
32         it offered {observed:?} instead"
33    )]
34    NoLabelMatched {
35        question_id: String,
36        expected: Vec<String>,
37        observed: Vec<String>,
38    },
39
40    /// The host answered 404 to a question naming `model`.
41    ///
42    /// Every runtime answers a model it does not have that way, and without `strict_models`
43    /// that is where a misspelt `model` surfaces — the caller's to fix, so not a 5xx inviting a
44    /// retry. But a host URL missing its `/v1` answers 404 to every model as well, so the
45    /// message says what to check when it is not the model.
46    #[error(
47        "model {model:?} is not known to {host} ({source}); if every model is refused this way, \
48         the host URL is probably wrong"
49    )]
50    UnknownModel {
51        model: String,
52        host: String,
53        source: HostError,
54    },
55
56    #[error(transparent)]
57    Host(#[from] HostError),
58}
59
60impl EngineError {
61    pub fn code(&self) -> ErrorCode {
62        match self {
63            Self::Invalid { code, .. } => *code,
64            Self::NoLabelMatched { .. } => ErrorCode::NoLabelMatched,
65            Self::UnknownModel { .. } => ErrorCode::UnknownModel,
66            Self::Host(HostError::Timeout(_)) => ErrorCode::HostTimeout,
67            Self::Host(_) => ErrorCode::HostUnavailable,
68        }
69    }
70
71    pub fn question_id(&self) -> Option<&str> {
72        match self {
73            Self::Invalid { question_id, .. } => question_id.as_deref(),
74            Self::NoLabelMatched { question_id, .. } => Some(question_id),
75            Self::UnknownModel { .. } | Self::Host(_) => None,
76        }
77    }
78
79    fn invalid(code: ErrorCode, question_id: Option<&str>, message: impl Into<String>) -> Self {
80        Self::Invalid {
81            code,
82            message: message.into(),
83            question_id: question_id.map(str::to_string),
84        }
85    }
86}
87
88/// What a question looks like once the primitive has been stripped away.
89struct Ballot<'a> {
90    question: Option<&'a str>,
91    /// The option texts shown to the model, in request order.
92    options: Vec<String>,
93    /// How the options relate, for a host that is asked questions rather than prompts.
94    shape: Shape,
95}
96
97/// One question's resolved distribution over its labels.
98struct Tally {
99    /// Probability per option, in request order.
100    probabilities: Vec<f64>,
101    /// The logprob actually fed into the softmax, per label. A floor substitution appears here
102    /// like any other value; `truncated` is what says one happened.
103    logprobs: BTreeMap<String, f64>,
104    confidence: f64,
105    /// The labels that fell outside the reported window and were given the floor.
106    truncated_labels: Vec<String>,
107    /// The probability the model itself put on the observed labels, before any normalising.
108    label_mass: f64,
109}
110
111pub struct Engine {
112    host: Arc<dyn ModelHost>,
113    keep_alive: Option<String>,
114}
115
116impl Engine {
117    pub fn new(host: Arc<dyn ModelHost>, keep_alive: Option<String>) -> Self {
118        Self { host, keep_alive }
119    }
120
121    /// The largest option count this engine can answer, given what its host reports.
122    pub fn max_options(&self) -> usize {
123        MAX_OPTIONS.min(self.host.capabilities().max_top_logprobs)
124    }
125
126    pub fn host_name(&self) -> &str {
127        self.host.name()
128    }
129
130    /// Reject a request that cannot be answered, before any model is loaded.
131    pub fn validate(&self, request: &SystemOneRequest) -> Result<(), EngineError> {
132        if request.state.trim().is_empty() {
133            return Err(EngineError::invalid(
134                ErrorCode::EmptyState,
135                None,
136                "state must not be empty",
137            ));
138        }
139
140        if request.questions.is_empty() {
141            return Err(EngineError::invalid(
142                ErrorCode::NoQuestions,
143                None,
144                "at least one question is required",
145            ));
146        }
147
148        if let Some(calibration) = request.calibration {
149            let t = calibration.temperature;
150            if !t.is_finite() || t <= 0.0 {
151                return Err(EngineError::invalid(
152                    ErrorCode::InvalidCalibration,
153                    None,
154                    format!("calibration temperature must be finite and above zero, got {t}"),
155                ));
156            }
157        }
158
159        if request.questions.len() > MAX_QUESTIONS {
160            return Err(EngineError::invalid(
161                ErrorCode::TooManyQuestions,
162                None,
163                format!(
164                    "a request may have at most {MAX_QUESTIONS} questions, got {}; send the rest \
165                     in a second request",
166                    request.questions.len()
167                ),
168            ));
169        }
170
171        let mut seen = BTreeMap::new();
172        for question in &request.questions {
173            if question.id.trim().is_empty() {
174                return Err(EngineError::invalid(
175                    ErrorCode::EmptyQuestionId,
176                    Some(&question.id),
177                    "question ids must not be empty; the answer is found by its id",
178                ));
179            }
180            if seen.insert(question.id.as_str(), ()).is_some() {
181                return Err(EngineError::invalid(
182                    ErrorCode::DuplicateQuestionId,
183                    Some(&question.id),
184                    format!("question id {:?} appears more than once", question.id),
185                ));
186            }
187            self.validate_question(question)?;
188        }
189
190        Ok(())
191    }
192
193    fn validate_question(&self, question: &Question) -> Result<(), EngineError> {
194        let id = Some(question.id.as_str());
195        let max = self.max_options();
196
197        match &question.kind {
198            QuestionKind::Noul(text) => {
199                if text.trim().is_empty() {
200                    return Err(EngineError::invalid(
201                        ErrorCode::EmptyQuestion,
202                        id,
203                        "a noul question must not be empty",
204                    ));
205                }
206            }
207            QuestionKind::Choice(spec) => {
208                if spec.options.len() < 2 {
209                    return Err(EngineError::invalid(
210                        ErrorCode::TooFewOptions,
211                        id,
212                        format!(
213                            "a choice needs at least 2 options, got {}",
214                            spec.options.len()
215                        ),
216                    ));
217                }
218                if spec.options.len() > max {
219                    return Err(EngineError::invalid(
220                        ErrorCode::TooManyOptions,
221                        id,
222                        format!(
223                            "a choice may have at most {max} options, got {}; the host reports \
224                             only {max} ranked tokens, so further options cannot be observed. \
225                             Split them across a first question that picks a group and a second \
226                             that picks within it.",
227                            spec.options.len()
228                        ),
229                    ));
230                }
231                if spec.options.iter().any(|o| o.trim().is_empty()) {
232                    return Err(EngineError::invalid(
233                        ErrorCode::EmptyQuestion,
234                        id,
235                        "choice options must not be empty",
236                    ));
237                }
238                if let Some(twice) = repeated(&spec.options) {
239                    return Err(EngineError::invalid(
240                        ErrorCode::DuplicateOption,
241                        id,
242                        format!(
243                            "option {twice:?} appears more than once; the model would see it \
244                             twice and split its probability between the copies"
245                        ),
246                    ));
247                }
248            }
249            QuestionKind::Score(spec) => {
250                let count = spec.levels.count();
251                if count < MIN_LEVELS as usize || count > MAX_LEVELS as usize {
252                    return Err(EngineError::invalid(
253                        ErrorCode::InvalidLevels,
254                        id,
255                        format!(
256                            "a score rubric needs between {MIN_LEVELS} and {MAX_LEVELS} levels, \
257                             got {count}"
258                        ),
259                    ));
260                }
261                let legend = spec.levels.legend();
262                if legend.iter().any(|l| l.trim().is_empty()) {
263                    return Err(EngineError::invalid(
264                        ErrorCode::EmptyQuestion,
265                        id,
266                        "score level labels must not be empty",
267                    ));
268                }
269                if let Some(twice) = repeated(&legend) {
270                    return Err(EngineError::invalid(
271                        ErrorCode::DuplicateOption,
272                        id,
273                        format!(
274                            "level {twice:?} appears more than once; the model could not tell \
275                             the two apart"
276                        ),
277                    ));
278                }
279            }
280        }
281
282        Ok(())
283    }
284
285    /// Ask one question and type its answer. The `u32` is the prompt's token count.
286    pub async fn answer(
287        &self,
288        state: &str,
289        question: &Question,
290        model: &str,
291        calibration: Calibration,
292    ) -> Result<(Answer, u32), EngineError> {
293        let (answer, distribution) = self
294            .answer_with_distribution(state, question, model, calibration)
295            .await?;
296        Ok((answer, distribution.input_tokens))
297    }
298
299    /// [`Engine::answer`], also handing back the distribution the answer was read from.
300    ///
301    /// The answer renormalises over the offered labels, so it cannot say whether the model's
302    /// most likely token was a label at all. The distribution can, which is what the benchmark
303    /// measures fidelity by.
304    pub async fn answer_with_distribution(
305        &self,
306        state: &str,
307        question: &Question,
308        model: &str,
309        calibration: Calibration,
310    ) -> Result<(Answer, FirstTokenDistribution), EngineError> {
311        self.validate_question(question)?;
312
313        let ballot = ballot_for(&question.kind);
314        let (tally, distribution) = self
315            .tally(state, &ballot, model, calibration, &question.id)
316            .await?;
317
318        let answer = match &question.kind {
319            QuestionKind::Noul(_) => Answer::Noul {
320                // Option 0 is "Yes" by construction; see `ballot_for`.
321                noul: tally.probabilities[0],
322                raw_logprobs: tally.logprobs,
323                truncated: !tally.truncated_labels.is_empty(),
324                truncated_labels: tally.truncated_labels,
325                label_mass: tally.label_mass,
326            },
327
328            QuestionKind::Choice(spec) => {
329                let winner = math::argmax(&tally.probabilities);
330                Answer::Choice {
331                    choice: spec.options[winner].clone(),
332                    index: winner,
333                    confidence: tally.confidence,
334                    probabilities: spec
335                        .options
336                        .iter()
337                        .zip(&tally.probabilities)
338                        .map(|(option, p)| OptionProbability {
339                            option: option.clone(),
340                            probability: *p,
341                        })
342                        .collect(),
343                    raw_logprobs: tally.logprobs,
344                    truncated: !tally.truncated_labels.is_empty(),
345                    truncated_labels: tally.truncated_labels,
346                    label_mass: tally.label_mass,
347                }
348            }
349
350            QuestionKind::Score(spec) => {
351                let legend = spec.levels.legend();
352                let winner = math::argmax(&tally.probabilities);
353                Answer::Score {
354                    score: (winner + 1) as u8,
355                    expected_score: math::expected_level(&tally.probabilities),
356                    legend: legend[winner].clone(),
357                    confidence: tally.confidence,
358                    probabilities: legend
359                        .iter()
360                        .zip(&tally.probabilities)
361                        .enumerate()
362                        .map(|(i, (text, p))| LevelProbability {
363                            level: (i + 1) as u8,
364                            legend: text.clone(),
365                            probability: *p,
366                        })
367                        .collect(),
368                    raw_logprobs: tally.logprobs,
369                    truncated: !tally.truncated_labels.is_empty(),
370                    truncated_labels: tally.truncated_labels,
371                    label_mass: tally.label_mass,
372                }
373            }
374        };
375
376        Ok((answer, distribution))
377    }
378
379    /// The shared middle: prompt, one host call, read the labels back out.
380    async fn tally(
381        &self,
382        state: &str,
383        ballot: &Ballot<'_>,
384        model: &str,
385        calibration: Calibration,
386        question_id: &str,
387    ) -> Result<(Tally, FirstTokenDistribution), EngineError> {
388        let label_set = labels::labels(ballot.options.len());
389        let lettered: Vec<(&str, &str)> = label_set
390            .iter()
391            .copied()
392            .zip(ballot.options.iter().map(String::as_str))
393            .collect();
394
395        let distribution = self
396            .host
397            .first_token(FirstTokenRequest {
398                model: model.to_string(),
399                system: Some(prompt::SYSTEM.to_string()),
400                user: prompt::user_turn(state, ballot.question, &lettered),
401                top_logprobs: self.max_options(),
402                keep_alive: self.keep_alive.clone(),
403                offer: Offer {
404                    question_id: question_id.to_string(),
405                    state: state.to_string(),
406                    question: ballot.question.map(str::to_string),
407                    shape: ballot.shape,
408                    options: lettered
409                        .iter()
410                        .map(|(l, t)| (l.to_string(), t.to_string()))
411                        .collect(),
412                },
413            })
414            .await
415            .map_err(|error| match error {
416                HostError::Status { status: 404, .. } => EngineError::UnknownModel {
417                    model: model.to_string(),
418                    host: self.host.name().to_string(),
419                    source: error,
420                },
421                other => EngineError::Host(other),
422            })?;
423
424        let tally = read_labels(&distribution, &label_set, calibration, question_id)?;
425        Ok((tally, distribution))
426    }
427}
428
429/// The first text that appears twice, compared the way the model sees it: trimmed.
430fn repeated(texts: &[String]) -> Option<&str> {
431    let mut seen = std::collections::BTreeSet::new();
432    texts.iter().map(|t| t.trim()).find(|t| !seen.insert(*t))
433}
434
435/// Reduce a question to its options. This is the only place the three primitives differ.
436fn ballot_for(kind: &QuestionKind) -> Ballot<'_> {
437    match kind {
438        // "Yes" first, so the noul probability is always option 0.
439        QuestionKind::Noul(question) => Ballot {
440            question: Some(question),
441            options: vec!["Yes".to_string(), "No".to_string()],
442            shape: Shape::YesNo,
443        },
444        QuestionKind::Choice(spec) => Ballot {
445            question: asked(&spec.question),
446            options: spec.options.clone(),
447            shape: Shape::Pick,
448        },
449        QuestionKind::Score(spec) => Ballot {
450            question: asked(&spec.question),
451            options: spec.levels.legend(),
452            shape: Shape::Scale,
453        },
454    }
455}
456
457/// The question text, if there is one worth asking. A blank question is the same as none: it
458/// would otherwise put an empty `QUESTION:` line in front of the options.
459fn asked(question: &Option<String>) -> Option<&str> {
460    question.as_deref().filter(|q| !q.trim().is_empty())
461}
462
463/// Map a token distribution onto the labels we offered.
464fn read_labels(
465    distribution: &FirstTokenDistribution,
466    label_set: &[&str],
467    calibration: Calibration,
468    question_id: &str,
469) -> Result<Tally, EngineError> {
470    let mut logprobs = Vec::with_capacity(label_set.len());
471    let mut observed_any = false;
472    let mut truncated_labels = Vec::new();
473    // Observed labels only: a floor is a bound, and adding bounds would overstate the mass.
474    let mut label_mass = 0.0;
475
476    for label in label_set {
477        // Every token spelling this label counts toward it; see `math::logsumexp`.
478        let variants: Vec<f64> = distribution
479            .tokens
480            .iter()
481            .filter(|(token, _)| labels::matches(token, label))
482            .map(|(_, lp)| *lp)
483            .collect();
484
485        if variants.is_empty() {
486            // Ranked below everything the host reported, so the floor is a strict upper bound.
487            truncated_labels.push(label.to_string());
488            logprobs.push(distribution.floor);
489        } else {
490            observed_any = true;
491            let logprob = math::logsumexp(&variants);
492            label_mass += logprob.exp();
493            logprobs.push(logprob);
494        }
495    }
496
497    if !observed_any {
498        return Err(EngineError::NoLabelMatched {
499            question_id: question_id.to_string(),
500            expected: label_set.iter().map(|l| l.to_string()).collect(),
501            observed: distribution
502                .tokens
503                .iter()
504                .take(5)
505                .map(|(t, _)| t.clone())
506                .collect(),
507        });
508    }
509
510    let probabilities = math::softmax(&logprobs, calibration.temperature);
511    let confidence = math::confidence(&probabilities);
512
513    Ok(Tally {
514        logprobs: label_set
515            .iter()
516            .map(|l| l.to_string())
517            .zip(logprobs)
518            .collect(),
519        probabilities,
520        confidence,
521        truncated_labels,
522        // Reported logprobs over-sum a little from rounding; the share cannot pass 1.
523        label_mass: label_mass.min(1.0),
524    })
525}