metrale_bench/benchmarks/serve_matrix/
probes.rs1use std::time::Duration;
14
15use anyhow::Result;
16use serde_json::{Value, json};
17
18use super::score::Signal;
19use crate::benchmarks::{one_line, stats};
20use crate::coherence;
21use crate::http;
22use crate::plugin::TargetEndpoint;
23
24pub const NEEDLE: &str = "PURPLE-DOLPHIN-42";
27
28fn base_body(model: &str, max_tokens: usize) -> Value {
31 json!({
32 "model": model,
33 "stream": true,
34 "temperature": 0.0,
35 "max_tokens": max_tokens,
36 })
37}
38
39fn with_user(mut body: Value, prompt: String) -> Value {
40 body["messages"] = json!([{"role": "user", "content": prompt}]);
41 body
42}
43
44pub struct Coherence {
46 pub passed: usize,
47 pub total: usize,
48 pub identity: Signal,
53}
54
55pub async fn coherence_probe(target: &TargetEndpoint, timeout: Duration) -> (Coherence, String) {
59 let report = coherence::probe(target, timeout).await;
60 let identity = match (&report.transport_error, &report.served_instead) {
61 (Some(e), _) => Signal::Fail(one_line(e)),
62 (None, Some(served)) if served.is_empty() => {
63 Signal::Fail("the endpoint has no model loaded".into())
64 }
65 (None, Some(served)) => Signal::Fail(format!(
66 "serving {} — not {}, which this round loaded",
67 served.join(", "),
68 target.model
69 )),
70 (None, None) => Signal::Pass,
71 };
72 let detail = report.concern(target).unwrap_or_default();
73 (
74 Coherence {
75 passed: report.answers.iter().filter(|a| a.passed).count(),
76 total: coherence::CHECKS.len(),
77 identity,
78 },
79 one_line(detail),
80 )
81}
82
83pub async fn codegen_probe(target: &TargetEndpoint, timeout: Duration, budget: usize) -> Signal {
86 let body = with_user(
87 base_body(&target.model, budget),
88 "Write a Python function `fib(n)` that returns the n-th Fibonacci number. \
89 Reply with the code only, no explanation."
90 .into(),
91 );
92 let outcome = match http::chat_stream(target, &body, timeout).await {
93 Ok(o) => o,
94 Err(e) => return Signal::Fail(one_line(format!("{e:#}"))),
95 };
96 match code_shape(&outcome.text) {
97 Ok(()) => Signal::Pass,
98 Err(why) => Signal::Fail(format!("{why}: {}", one_line(&outcome.text))),
99 }
100}
101
102pub fn code_shape(text: &str) -> Result<(), String> {
104 let lines: Vec<&str> = text.lines().collect();
105 let Some((start, header)) = lines.iter().enumerate().find(|(_, line)| {
106 line.trim_start()
107 .strip_prefix("def fib")
108 .is_some_and(|rest| rest.trim_start().starts_with('('))
109 }) else {
110 return Err("no `def fib` in the reply".into());
111 };
112 if !header.contains(':') {
113 return Err("`def fib` has no parameter list or colon".into());
114 }
115 let indented = lines
118 .iter()
119 .skip(start + 1)
120 .any(|l| !l.trim().is_empty() && (l.starts_with(' ') || l.starts_with('\t')));
121 if !indented {
122 return Err("the function has no indented body — the reply collapsed onto one line".into());
123 }
124 Ok(())
125}
126
127fn weather_tool() -> Value {
129 json!([{
130 "type": "function",
131 "function": {
132 "name": "get_weather",
133 "description": "Get the current weather for a location",
134 "parameters": {
135 "type": "object",
136 "properties": {"location": {"type": "string", "description": "City name"}},
137 "required": ["location"],
138 },
139 },
140 }])
141}
142
143pub async fn tool_call_probe(target: &TargetEndpoint, timeout: Duration, budget: usize) -> Signal {
147 let mut body = with_user(
148 base_body(&target.model, budget),
149 "What is the weather in Paris?".into(),
150 );
151 body["tools"] = weather_tool();
152 body["tool_choice"] = json!("auto");
153 let outcome = match http::chat_stream(target, &body, timeout).await {
154 Ok(o) => o,
155 Err(e) => {
156 let msg = one_line(format!("{e:#}"));
159 return if is_client_rejection(&msg) {
160 Signal::NotApplicable(msg)
161 } else {
162 Signal::Fail(msg)
163 };
164 }
165 };
166 score_tool_call(&outcome)
167}
168
169fn score_tool_call(outcome: &http::ChatOutcome) -> Signal {
170 let Some(call) = outcome.tool_calls.iter().find(|c| !c.name.is_empty()) else {
171 return Signal::Fail("no tool call in the reply".into());
172 };
173 if call.name != "get_weather" {
174 return Signal::Fail(format!("called {:?}, not get_weather", call.name));
175 }
176 match serde_json::from_str::<Value>(&call.arguments) {
177 Ok(args) => match args.get("location").and_then(Value::as_str) {
178 Some(location) if location.trim().eq_ignore_ascii_case("Paris") => Signal::Pass,
179 Some(location) => Signal::Fail(format!("called for {location:?}, not Paris")),
180 None => Signal::Fail(format!("arguments carry no location: {}", call.arguments)),
181 },
182 Err(e) => Signal::Fail(format!("arguments are not JSON ({e}): {}", call.arguments)),
183 }
184}
185
186fn is_client_rejection(msg: &str) -> bool {
191 let Some(rest) = msg.split_once("endpoint returned \"").map(|(_, r)| r) else {
192 return false;
193 };
194 let status = rest.split('"').next().unwrap_or_default();
195 status.split_whitespace().any(|word| {
196 word.len() == 3 && word.starts_with('4') && word.chars().all(|c| c.is_ascii_digit())
197 })
198}
199
200pub async fn long_context_probe(
202 target: &TargetEndpoint,
203 timeout: Duration,
204 tokens: usize,
205 tag: &str,
206) -> Signal {
207 let filler = stats::make_prompt(tokens, stats::PromptMode::Natural, tag);
208 let split = filler.len() / 2;
212 let cut = (0..=split)
213 .rev()
214 .find(|i| filler.is_char_boundary(*i))
215 .unwrap_or(0);
216 let prompt = format!(
217 "{}\nThe secret code is {NEEDLE}.\n{}\n\nWhat is the secret code? Reply with the code only.",
218 &filler[..cut],
219 &filler[cut..]
220 );
221 let body = with_user(base_body(&target.model, 32), prompt);
222 match http::chat_stream(target, &body, timeout).await {
223 Ok(o) if o.text.contains(NEEDLE) => Signal::Pass,
224 Ok(o) => Signal::Fail(format!("needle not recalled: {}", one_line(&o.text))),
225 Err(e) => Signal::Fail(one_line(format!("{e:#}"))),
226 }
227}
228
229pub async fn tps_probe(
234 target: &TargetEndpoint,
235 timeout: Duration,
236 budget: usize,
237 tag: &str,
238) -> (Option<f64>, Option<String>) {
239 let prompt = stats::make_prompt(128, stats::PromptMode::Count, tag);
240 let body = with_user(base_body(&target.model, budget), prompt);
241 match http::chat_stream(target, &body, timeout).await {
242 Ok(o) => (o.tpot_ms.filter(|v| *v > 0.0).map(|ms| 1000.0 / ms), None),
243 Err(e) => (Some(0.0), Some(one_line(format!("{e:#}")))),
244 }
245}
246
247#[cfg(test)]
248#[path = "probes_tests.rs"]
249mod tests;