1use crate::client::{Client, Endpoint};
12use crate::i18n::Lang;
13use crate::probes::{self, Cancel, Ctx, Depth, Event, Pace, Selection};
14use crate::report::Report;
15use crate::verdict;
16use anyhow::Result;
17
18#[derive(Clone)]
20pub struct RunConfig {
21 pub endpoint: Endpoint,
22 pub claimed_model: Option<String>,
25 pub depth: Depth,
26 pub lang: Lang,
27 pub selection: Selection,
28 pub seed: Option<u64>,
31 pub http: Option<reqwest::Client>,
33 pub pace: Option<Pace>,
35 pub concurrency: usize,
38 pub max_in_flight: usize,
41}
42
43impl RunConfig {
44 pub fn new(endpoint: Endpoint) -> Self {
45 RunConfig {
46 endpoint,
47 claimed_model: None,
48 depth: Depth::Balanced,
49 lang: Lang::En,
50 selection: Selection::all(),
51 seed: None,
52 http: None,
53 pace: None,
54 concurrency: 1,
55 max_in_flight: 0,
56 }
57 }
58
59 pub fn model_only(mut self) -> Self {
61 self.selection = Selection::model_only();
62 self
63 }
64
65 pub fn turbo(mut self, in_flight: usize) -> Self {
79 self.selection = Selection::turbo();
80 self.depth = Depth::Fast;
81 self.concurrency = in_flight.max(1);
82 self.max_in_flight = in_flight;
83 self
84 }
85
86 pub fn concurrency(mut self, n: usize) -> Self {
99 self.concurrency = n.max(1);
100 self
101 }
102
103 pub fn max_in_flight(mut self, n: usize) -> Self {
109 self.max_in_flight = n;
110 self
111 }
112
113 pub fn depth(mut self, d: Depth) -> Self {
114 self.depth = d;
115 self
116 }
117
118 pub fn lang(mut self, l: Lang) -> Self {
119 self.lang = l;
120 self
121 }
122
123 pub fn seed(mut self, s: u64) -> Self {
124 self.seed = Some(s);
125 self
126 }
127
128 pub fn claimed_model(mut self, m: impl Into<String>) -> Self {
129 self.claimed_model = Some(m.into());
130 self
131 }
132
133 pub fn http(mut self, c: reqwest::Client) -> Self {
134 self.http = Some(c);
135 self
136 }
137
138 pub fn pace(mut self, min: std::time::Duration, max: std::time::Duration) -> Self {
140 self.pace = Some(Pace { min, max });
141 self
142 }
143}
144
145pub async fn run(
150 cfg: RunConfig,
151 cancel: &Cancel,
152 on_event: &mut (dyn FnMut(Event<'_>) + Send),
153) -> Result<Report> {
154 let started_at = crate::util::iso8601_utc();
155 let t0 = crate::util::now_ms();
156
157 let seed = cfg.seed.unwrap_or_else(|| {
158 (crate::util::now_ms() as u64) ^ 0x9E37_79B9_7F4A_7C15
160 });
161 let claimed_model = cfg
162 .claimed_model
163 .clone()
164 .unwrap_or_else(|| cfg.endpoint.model.clone());
165 let protocol = cfg.endpoint.protocol;
166 let model = cfg.endpoint.model.clone();
167 let base_url = cfg.endpoint.base_url.clone();
168 let host = cfg.endpoint.host();
169
170 let client = match cfg.http.clone() {
171 Some(http) => Client::with_http(cfg.endpoint.clone(), http),
172 None => Client::new(cfg.endpoint.clone())?,
173 };
174 let client = client.with_limit(cfg.max_in_flight);
175 let ctx = Ctx::with_seed(client, cfg.depth, cfg.lang, claimed_model.clone(), seed);
176
177 let specs = cfg.selection.resolve();
178 let steps: Vec<String> = specs
183 .iter()
184 .map(|s| s.id.to_string())
185 .chain(
186 cfg.selection
187 .resolve_extra()
188 .iter()
189 .map(|p| p.id().to_string()),
190 )
191 .collect();
192 let extra = cfg.selection.resolve_extra();
193 let schedule = probes::Schedule {
194 pace: cfg.pace,
195 concurrency: cfg.concurrency,
196 };
197 let results = probes::run_with_extra(&ctx, &specs, &extra, cancel, schedule, on_event).await;
198
199 let l = cfg.lang;
200 let identity = verdict::build_identity(&results, &claimed_model, l);
201 let billing = verdict::build_billing(&results, &model, l);
202 let channel = verdict::build_channel(&results, l);
203 let v = verdict::decide(&results, &identity, &billing, &channel, protocol, l);
204 let perf = probes::perf::summarize(&ctx.perf.lock().unwrap());
205
206 let skipped = results
207 .iter()
208 .filter(|r| {
209 matches!(
210 r.status,
211 crate::report::Status::Skip | crate::report::Status::Error
212 )
213 })
214 .map(|r| t!(l, "{} ({}) — {}", "{}({}):{}", r.label, r.id, r.summary))
215 .collect();
216
217 Ok(Report {
218 schema_version: crate::report::schema_version(),
219 tool_version: env!("CARGO_PKG_VERSION").to_string(),
220 lang: l,
221 started_at,
222 finished_at: crate::util::iso8601_utc(),
223 duration_ms: (crate::util::now_ms() - t0) as u64,
224 host,
225 base_url,
226 protocol,
227 model,
228 claimed_model,
229 depth: cfg.depth.as_str().to_string(),
230 seed,
231 steps,
232 request_count: ctx.client.requests(),
233 results,
234 verdict: v,
235 identity,
236 billing,
237 channel,
238 perf,
239 skipped,
240 })
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
252 fn run_future_is_send() {
253 fn assert_send<T: Send>(_: T) {}
254 let cfg = RunConfig::new(Endpoint {
255 base_url: "https://example.invalid".into(),
256 model: "m".into(),
257 ..Default::default()
258 });
259 let cancel = Cancel::new();
260 let mut sink = |_: Event<'_>| {};
261 assert_send(run(cfg, &cancel, &mut sink));
262 }
263
264 fn ids(sel: Selection) -> Vec<&'static str> {
265 sel.resolve().iter().map(|s| s.id).collect()
266 }
267
268 #[test]
269 fn model_only_drops_endpoint_steps_but_keeps_preflight() {
270 let ids = ids(Selection::model_only());
271 assert!(
272 ids.contains(&"preflight"),
273 "the run is meaningless without it"
274 );
275 assert!(ids.contains(&"self_id"));
276 assert!(ids.contains(&"capability"));
277 assert!(ids.contains(&"perf"));
278 assert!(!ids.contains(&"billing"));
281 assert!(!ids.contains(&"channel"));
282 assert!(!ids.contains(&"missing_auth"));
283 }
284
285 #[test]
286 fn skip_wins_over_an_explicit_include() {
287 let sel = Selection {
288 only: vec!["identity".into(), "perf".into()],
289 skip: vec!["perf".into()],
290 ..Default::default()
291 };
292 let ids = ids(sel);
293 assert!(ids.contains(&"self_id"));
294 assert!(!ids.contains(&"perf"));
295 }
296
297 #[test]
303 fn a_group_key_still_addresses_the_whole_family() {
304 let without = ids(Selection {
305 skip: vec!["identity".into()],
306 ..Default::default()
307 });
308 for gone in ["self_id", "meta_creator", "world_knowledge", "capability"] {
309 assert!(!without.contains(&gone), "{gone} survived skip: [identity]");
310 }
311 assert!(without.contains(&"preflight"));
312
313 let only_identity = ids(Selection {
314 only: vec!["identity".into()],
315 ..Default::default()
316 });
317 assert!(only_identity.contains(&"self_id") && only_identity.contains(&"verbosity"));
318 assert!(!only_identity.contains(&"cache_replay"));
319 }
320
321 #[test]
322 fn turbo_keeps_every_probe_a_verdict_rests_on() {
323 let kept_ids = ids(Selection::turbo());
324 for kept in [
327 "preflight",
328 "self_id",
329 "capability",
330 "verbosity",
331 "perf",
332 "cache_replay",
333 ] {
334 assert!(kept_ids.contains(&kept), "turbo dropped {kept}");
335 }
336 for dropped in [
339 "meta_creator",
340 "context_claim",
341 "cutoff_claim",
342 "world_knowledge",
343 "signature_drift",
344 ] {
345 assert!(!kept_ids.contains(&dropped), "turbo kept {dropped}");
346 }
347 let relayed = ids(Selection::model_only());
350 for id in &kept_ids {
351 assert!(relayed.contains(id), "{id} is not in model_only");
352 }
353 }
354
355 #[test]
356 fn plus_puts_the_endpoint_contract_checks_back() {
357 let ids = ids(Selection::turbo().plus(["stop_sequence", "system_adherence"]));
358 assert!(ids.contains(&"stop_sequence"));
359 assert!(ids.contains(&"system_adherence"));
360 assert!(ids.contains(&"self_id"), "and keeps what turbo had");
361 }
362
363 #[test]
367 fn plus_on_an_unfiltered_selection_is_a_no_op() {
368 let before = ids(Selection::all()).len();
369 let after = ids(Selection::all().plus(["self_id"])).len();
370 assert_eq!(before, after);
371 }
372}