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;