1use 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 #[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 #[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
88struct Ballot<'a> {
90 question: Option<&'a str>,
91 options: Vec<String>,
93 shape: Shape,
95}
96
97struct Tally {
99 probabilities: Vec<f64>,
101 logprobs: BTreeMap<String, f64>,
104 confidence: f64,
105 truncated_labels: Vec<String>,
107 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 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 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 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 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 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 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
429fn 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
435fn ballot_for(kind: &QuestionKind) -> Ballot<'_> {
437 match kind {
438 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
457fn asked(question: &Option<String>) -> Option<&str> {
460 question.as_deref().filter(|q| !q.trim().is_empty())
461}
462
463fn 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 let mut label_mass = 0.0;
475
476 for label in label_set {
477 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 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 label_mass: label_mass.min(1.0),
524 })
525}