metrale_bench/coherence.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: A known-answer probe of the endpoint, run by the executor before
4//! a benchmark loads.
5//!
6//! [`crate::http::probe`] only checks that `/v1/models` answers with a 2xx
7//! status line; it never parses the body. This probe also reads the model list
8//! (a wrong model name), checks the served model against the benchmark's
9//! [`crate::benchmark::ModelExpectation`] (a wrong family), and asks the two
10//! [`CHECKS`] questions (an endpoint that does not generate sense). It is not a
11//! quality measurement.
12//!
13//! Owner: bench.
14//! Invariants:
15//! - [`probe`] and [`probe_for`] never return an error: a transport failure is
16//! recorded in [`Report::transport_error`].
17
18use anyhow::Result;
19use serde_json::json;
20use std::time::Duration;
21
22use crate::http;
23use crate::plugin::TargetEndpoint;
24
25/// 2026-09-26: Whether a run probes the endpoint before it starts.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum CoherencePolicy {
28 /// 2026-09-26: Probe and report. A failed probe is a warning in the run
29 /// log, never a refusal (`executor::RunTask::probe_coherence`): a base
30 /// checkpoint, or a model that phrases answers unusually, is a legitimate
31 /// thing to benchmark on purpose.
32 #[default]
33 Probe,
34 /// 2026-09-26: Do not probe at all.
35 Skip,
36}
37
38/// 2026-09-26: A question whose answer is not a matter of opinion.
39#[derive(Clone, Copy, Debug)]
40pub struct Check {
41 pub label: &'static str,
42 pub prompt: &'static str,
43 /// 2026-09-26: Lower-cased terms; the answer passes when it contains one
44 /// of them as a whole word (see `judge`). Several entries are spellings of
45 /// the same fact.
46 pub accept: &'static [&'static str],
47}
48
49/// 2026-09-26: Two facts: one arithmetic, one recall.
50pub const CHECKS: &[Check] = &[
51 Check {
52 label: "arithmetic",
53 prompt: "What is 2+2? Reply with only the number.",
54 accept: &["4", "four"],
55 },
56 Check {
57 label: "recall",
58 prompt: "What is the capital of France? Reply with only the city name.",
59 accept: &["paris"],
60 },
61];
62
63/// 2026-09-26: What one check produced, kept so a failure can quote it back.
64#[derive(Clone, Debug)]
65pub struct Answer {
66 pub label: &'static str,
67 pub answer: String,
68 pub passed: bool,
69}
70
71/// 2026-09-26: The outcome of a probe: what was asked, what came back, and
72/// whether it fit.
73#[derive(Clone, Debug, Default)]
74pub struct Report {
75 pub answers: Vec<Answer>,
76 /// 2026-09-26: Set when a question's request failed (`http::chat_stream`
77 /// returned an error). Reported with its own wording, not as a wrong answer.
78 pub transport_error: Option<String>,
79 /// 2026-09-26: Set when the served model is not one the benchmark is
80 /// defined on. Distinct from `served_instead`: the name may be exactly
81 /// what was asked for and still be outside the benchmark's families.
82 pub wrong_family: Option<String>,
83 /// 2026-09-26: What `/v1/models` lists, when the requested model is not
84 /// among them. Only this catches a wrong model name: the server answers a
85 /// chat completion for a name it does not serve with its live model
86 /// (`api::chat`; with `--auto-swap` a catalogue name loads first), so the
87 /// questions cannot see the mistake.
88 pub served_instead: Option<Vec<String>>,
89}
90
91impl Report {
92 /// 2026-09-26: True when every check passed and no other field is set.
93 pub fn is_clean(&self) -> bool {
94 self.transport_error.is_none()
95 && self.wrong_family.is_none()
96 && self.served_instead.is_none()
97 && self.answers.iter().all(|a| a.passed)
98 }
99
100 /// 2026-09-26: One message naming the most important thing wrong, or
101 /// `None` when nothing is. Priority: transport error, wrong family, wrong
102 /// or missing model, then failed answers.
103 pub fn concern(&self, target: &TargetEndpoint) -> Option<String> {
104 if let Some(e) = &self.transport_error {
105 return Some(format!(
106 "{} did not answer a test request: {e}",
107 target.base_url
108 ));
109 }
110 if let Some(note) = &self.wrong_family {
111 return Some(note.clone());
112 }
113 // 2026-09-26: Before the answers: a wrong model name explains any odd
114 // answer, so it is the cause to report.
115 if let Some(served) = &self.served_instead {
116 // 2026-09-26: The server lists nothing only when no model is
117 // loaded (`api::models`), and its chat handler then answers 503
118 // (`api::chat`), so the run will produce no numbers; the
119 // wrong-model wording would be false.
120 if served.is_empty() {
121 return Some(format!(
122 "{} has no model loaded, so this run will produce no numbers — every \
123 request will be refused. Load a model first: in the dashboard open the \
124 Library (press 4), choose a model and a recipe, and start it.",
125 target.base_url
126 ));
127 }
128 return Some(format!(
129 "{} is serving {} — not {:?}, which this benchmark is set to request. \
130 Metrale Engine answers whatever model name it is sent, so the run WILL produce \
131 numbers; they will just be for a different model than the one named.",
132 target.base_url,
133 served.join(", "),
134 target.model
135 ));
136 }
137 let failed: Vec<&Answer> = self.answers.iter().filter(|a| !a.passed).collect();
138 if failed.is_empty() {
139 return None;
140 }
141 let detail = failed
142 .iter()
143 .map(|a| match a.answer.trim() {
144 "" => format!("{} answered nothing", a.label),
145 text => format!("{} answered {:?}", a.label, truncate(text, 60)),
146 })
147 .collect::<Vec<_>>()
148 .join("; ");
149 Some(format!(
150 "{} is serving {:?}, which did not answer as expected ({detail}). \
151 This benchmark may be aimed at a different model, or the checkpoint \
152 may be a base (non-instruct) one — the run is still valid, but read \
153 the numbers with that in mind.",
154 target.base_url, target.model
155 ))
156 }
157}
158
159/// 2026-09-26: [`probe_for`] without a model expectation.
160pub async fn probe(target: &TargetEndpoint, timeout: Duration) -> Report {
161 probe_for(target, None, timeout).await
162}
163
164/// 2026-09-26: Read the model list, check the served model against
165/// `expectation` when one is given, then ask every [`CHECKS`] question.
166///
167/// The first failed question request stops the questions and is stored as
168/// [`Report::transport_error`]; nothing here returns an error.
169pub async fn probe_for(
170 target: &TargetEndpoint,
171 expectation: Option<crate::benchmark::ModelExpectation>,
172 timeout: Duration,
173) -> Report {
174 let mut report = Report::default();
175
176 match http::list_models(target, timeout).await {
177 Ok(served) if !served.iter().any(|m| m == &target.model) => {
178 report.served_instead = Some(served);
179 }
180 Ok(_) => {}
181 // 2026-09-26: An unreadable list is only logged at debug level; the
182 // questions below still run.
183 Err(e) => tracing::debug!("could not read the model list: {e:#}"),
184 }
185
186 // 2026-09-26: The family is checked against the first listed model when
187 // the requested one is not listed, and against the requested name
188 // otherwise, including when the list could not be read.
189 if let Some(expect) = expectation {
190 let actual = report
191 .served_instead
192 .as_ref()
193 .and_then(|s| s.first().cloned())
194 .unwrap_or_else(|| target.model.clone());
195 if !expect.accepts(&actual) {
196 report.wrong_family = Some(format!(
197 "{} is serving {actual}, which this benchmark is not defined on. {}",
198 target.base_url, expect.note
199 ));
200 }
201 }
202
203 for check in CHECKS {
204 match ask(target, check, timeout).await {
205 Ok(answer) => report.answers.push(answer),
206 Err(e) => {
207 report.transport_error = Some(one_line(&format!("{e:#}")));
208 break;
209 }
210 }
211 }
212 report
213}
214
215/// 2026-09-26: Collapse an error chain to one line of at most 280 characters,
216/// a bound against an unbounded chain rather than a line width.
217fn one_line(s: &str) -> String {
218 let flat = s.lines().map(str::trim).collect::<Vec<_>>().join(" ");
219 truncate(&flat, 280)
220}
221
222/// 2026-09-26: One question. A transport or HTTP error is returned as `Err`
223/// rather than counted as a failed answer, so the two get different messages.
224async fn ask(target: &TargetEndpoint, check: &Check, timeout: Duration) -> Result<Answer> {
225 let body = json!({
226 "model": target.model,
227 "stream": true,
228 // 2026-09-26: Thinking off, and 96 tokens so that a server which
229 // ignores `enable_thinking` still has room to reach the answer.
230 "chat_template_kwargs": {"enable_thinking": false},
231 "max_tokens": 96,
232 "temperature": 0.0,
233 "messages": [{"role": "user", "content": check.prompt}],
234 });
235 let outcome = http::chat_stream(target, &body, timeout).await?;
236 let (passed, answer) = judge(&outcome.text, &outcome.reasoning, check.accept);
237 Ok(Answer {
238 label: check.label,
239 passed,
240 answer,
241 })
242}
243
244/// 2026-09-26: Did the reply contain the expected fact, and what should be
245/// quoted back?
246///
247/// Passes when either the text or the reasoning contains an accepted term as a
248/// whole word (no letter, digit or `_` on either side), so a server that
249/// ignores `enable_thinking` does not fail a healthy model. The quoted answer
250/// is the text, or the reasoning when the text is blank.
251fn judge(text: &str, reasoning: &str, accept: &[&str]) -> (bool, String) {
252 let matched = |s: &str| {
253 let lowered = s.to_lowercase();
254 accept.iter().any(|term| {
255 lowered.match_indices(term).any(|(at, _)| {
256 let before = lowered[..at].chars().next_back();
257 let after = lowered[at + term.len()..].chars().next();
258 let continues_word = |ch: char| ch.is_alphanumeric() || ch == '_';
259 !before.is_some_and(continues_word) && !after.is_some_and(continues_word)
260 })
261 })
262 };
263 let passed = matched(text) || matched(reasoning);
264 let answer = if text.trim().is_empty() {
265 reasoning.to_string()
266 } else {
267 text.to_string()
268 };
269 (passed, answer)
270}
271
272fn truncate(s: &str, max: usize) -> String {
273 let trimmed = s.trim();
274 if trimmed.chars().count() <= max {
275 return trimmed.to_string();
276 }
277 let head: String = trimmed.chars().take(max).collect();
278 format!("{head}…")
279}
280
281#[cfg(test)]
282#[path = "coherence_tests.rs"]
283mod tests;