1use std::collections::HashMap;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use parking_lot::Mutex;
14
15use super::fall_through::FallThrough;
16use super::llm_class::{
17 CustomClassifierConfig, LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig,
18};
19use super::stage::{StageRouterConfig, build_stage_route};
20use super::util::affinity::{ClassifyTrigger, evict_if_full, has_new_user_turn, retention_key};
21use super::util::stage::{PickerMode, Tier, fall_open_tier, set_fall_open};
22use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
23use crate::core::classifier::{Classification, Classifier};
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26use crate::{LibsyError, Result};
27use switchyard_protocol::{Category, Request, Response};
28
29const COMPOSITE: &str = "composite";
30
31struct TierSetter {
37 judge: Arc<dyn Classifier>,
38 trigger: ClassifyTrigger,
39 message_hash_fallback: bool,
40 default_tier: Tier,
41 tiers: Mutex<HashMap<RoutingIdentity, Tier>>,
42}
43
44impl TierSetter {
45 fn tier_for(category: Option<Category>) -> Option<Tier> {
46 match category {
47 Some(Category::Capable) => Some(Tier::Capable),
48 Some(Category::Efficient) => Some(Tier::Efficient),
49 _ => None,
50 }
51 }
52
53 fn is_due(&self, identity: Option<&RoutingIdentity>, state: &State, request: &Request) -> bool {
56 match self.trigger {
57 ClassifyTrigger::UserTurn => {
58 !request.llm_request.is_compaction
59 && has_new_user_turn(&request.llm_request.messages)
60 }
61 ClassifyTrigger::NewSession => {
64 identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
65 && fall_open_tier(state).is_none()
66 }
67 ClassifyTrigger::EveryRequest => true,
69 }
70 }
71
72 fn retain(&self, identity: RoutingIdentity, tier: Tier) {
73 let mut tiers = self.tiers.lock();
74 let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
77 if writable {
78 evict_if_full(&mut tiers);
79 tiers.insert(identity, tier);
80 }
81 }
82}
83
84#[async_trait]
85impl Processor for TierSetter {
86 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
87 let Event::Request { request, driver } = event else {
88 return Ok(());
89 };
90 let identity = retention_key(request, self.message_hash_fallback);
91 if self.is_due(identity.as_ref(), state, request) {
92 let (classification, _) = self.judge.score(state, request, driver).await?;
93 if let Some(winner) = classification.argmax(false)?
94 && let Some(tier) = Self::tier_for(winner.category)
95 {
96 set_fall_open(state, tier);
97 if let Some(identity) = identity {
98 self.retain(identity, tier);
99 }
100 return Ok(());
101 }
102 if self.trigger == ClassifyTrigger::NewSession
103 && let Some(identity) = identity.as_ref()
104 {
105 self.retain(identity.clone(), self.default_tier);
107 }
108 }
109 if let Some(tier) = identity
112 .and_then(|identity| self.tiers.lock().get(&identity).copied())
113 .or_else(|| fall_open_tier(state))
114 {
115 set_fall_open(state, tier);
116 driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
117 }
118 Ok(())
119 }
120}
121
122pub enum CompositeJudgeConfig {
124 Capability(TaskClassifierConfig),
126 Custom(CustomClassifierConfig),
128}
129
130pub struct CompositeRouterConfig {
132 pub judge: CompositeJudgeConfig,
134 pub stage: StageRouterConfig,
136}
137
138pub struct CompositeRouter {
140 route: FallThrough,
141}
142
143impl CompositeRouter {
144 pub fn new(config: CompositeRouterConfig) -> Result<Self> {
151 let setter = Self::tier_setter(config.judge, config.stage.mode.default_tier())?;
152 let route = build_stage_route(config.stage, setter.trigger == ClassifyTrigger::UserTurn)?
153 .with_name(COMPOSITE)
154 .with_processor(Arc::new(setter));
155 Ok(Self { route })
156 }
157
158 pub fn new_with_profiles(
160 judge: CompositeJudgeConfig,
161 mut profile_0: StageRouterConfig,
162 mut profile_1: StageRouterConfig,
163 ) -> Result<Self> {
164 for profile in [&mut profile_0, &mut profile_1] {
165 if profile.mode == PickerMode::CapableFirst {
166 profile.efficient_hold_turns.get_or_insert(-1);
167 }
168 }
169 let setter = Self::tier_setter(judge, Tier::Efficient)?;
170 let reset_on_user_turn = setter.trigger == ClassifyTrigger::UserTurn;
171 let profiles = StageProfiles([
172 build_stage_route(profile_0, reset_on_user_turn)?,
173 build_stage_route(profile_1, reset_on_user_turn)?,
174 ]);
175 let route = FallThrough::new_with_state()
176 .with_name(COMPOSITE)
177 .with_processor(Arc::new(setter))
178 .with_classifier(Arc::new(profiles));
179 Ok(Self { route })
180 }
181
182 fn tier_setter(judge: CompositeJudgeConfig, default_tier: Tier) -> Result<TierSetter> {
183 let (trigger, message_hash_fallback) = match &judge {
184 CompositeJudgeConfig::Capability(judge) => {
185 (judge.classify_trigger, judge.message_hash_fallback)
186 }
187 CompositeJudgeConfig::Custom(config) => {
188 (config.classify_trigger, config.message_hash_fallback)
189 }
190 };
191 if trigger == ClassifyTrigger::EveryRequest {
192 return Err(LibsyError::AlgorithmError {
193 message: "composite: classify_trigger must be user_turn or new_session".to_string(),
194 });
195 }
196 let judge: Arc<dyn Classifier> = match judge {
197 CompositeJudgeConfig::Capability(judge) => {
198 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
200 config: TaskClassifierConfig {
201 classify_trigger: ClassifyTrigger::EveryRequest,
202 message_hash_fallback: false,
203 ..judge
204 },
205 })?)
206 }
207 CompositeJudgeConfig::Custom(config) => {
208 LlmTaskClassifier::custom_classifier(&config, true)?
209 }
210 };
211 Ok(TierSetter {
212 judge,
213 trigger,
214 message_hash_fallback,
215 default_tier,
216 tiers: Mutex::new(HashMap::new()),
217 })
218 }
219}
220
221struct StageProfiles([FallThrough; 2]);
222
223#[async_trait]
224impl Classifier for StageProfiles {
225 async fn score(
226 &self,
227 state: &mut State,
228 request: &mut Request,
229 driver: &Driver,
230 ) -> Result<(Classification, Option<Response>)> {
231 let (profile, prefix) = match fall_open_tier(state) {
232 Some(Tier::Capable) => (&self.0[1], "composite.profile_1."),
233 _ => (&self.0[0], "composite.profile_0."),
234 };
235 let mut profile_state = State {
237 extra: state
238 .extra
239 .iter()
240 .filter_map(|(key, value)| {
241 key.strip_prefix(prefix)
242 .map(|key| (key.to_string(), value.clone()))
243 })
244 .collect(),
245 ..State::default()
246 };
247 let result = profile.route(&mut profile_state, driver, request).await;
248 state.extra.retain(|key, _| !key.starts_with(prefix));
249 state.extra.extend(
250 profile_state
251 .extra
252 .into_iter()
253 .map(|(key, value)| (format!("{prefix}{key}"), value)),
254 );
255 state.tool_signals = profile_state.tool_signals;
256 state.target_prompts = profile_state.target_prompts;
257 let (score, response) = result?;
258 Ok((Classification::Scores(vec![score]), response))
259 }
260}
261
262#[async_trait]
263impl Algorithm for CompositeRouter {
264 fn name(&self) -> &str {
265 COMPOSITE
266 }
267
268 async fn route(
269 self: Arc<Self>,
270 driver: Driver,
271 request: Request,
272 ) -> Result<crate::RoutingOutcome> {
273 self.route.execute(driver, request).await
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
280 use std::collections::HashMap;
281 use std::sync::Arc;
282
283 use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
284
285 use super::*;
286 use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
287 use crate::core::testing::test_drive_with_models;
288
289 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
290 [
291 (Category::Judge, vec![ModelId::from(JUDGE)]),
292 (Category::Efficient, vec![ModelId::from("weak")]),
293 (Category::Capable, vec![ModelId::from("strong")]),
294 (
295 Category::Any,
296 vec![ModelId::from("strong"), ModelId::from("weak")],
297 ),
298 ]
299 .into()
300 }
301
302 fn user_turn_request() -> Request {
303 let mut request = turn_request(false);
304 request
305 .llm_request
306 .messages
307 .push(Message::text(Role::User, "now rewrite the parser"));
308 request
309 }
310
311 fn unkeyed(mut request: Request) -> Request {
313 if let Some(metadata) = request.metadata.as_mut() {
314 metadata.session_id = None;
315 }
316 request
317 }
318
319 fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
320 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
321 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
322 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
323 base_threshold: 0.5,
324 ..LlmCapabilityConfig::default()
325 }),
326 classify_trigger: ClassifyTrigger::UserTurn,
327 message_hash_fallback: true,
328 ..Default::default()
329 }),
330 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
331 })?))
332 }
333
334 fn router() -> Result<Arc<CompositeRouter>> {
335 router_with_trigger(ClassifyTrigger::UserTurn)
336 }
337
338 #[tokio::test]
339 async fn compaction_retains_the_profile_until_a_real_user_turn() -> Result<()> {
340 let router = router()?;
341 let recorder = Arc::new(Recorder::default());
342 let mut compacted = user_turn_request();
343 compacted.llm_request.is_compaction = true;
344 for (request, verdict, calls, target) in [
345 (user_turn_request(), 1.0, 1, "weak"),
346 (compacted, 0.0, 1, "weak"),
347 (user_turn_request(), 0.0, 2, "strong"),
348 ] {
349 *recorder.judge_p_solve.lock() = verdict;
350 let (model, _) =
351 test_drive_with_models(router.clone(), request, runtime_models(), recorder.serve())
352 .await?;
353 assert_eq!(recorder.judge_calls(), calls);
354 assert_eq!(model.as_str(), target);
355 }
356 Ok(())
357 }
358
359 #[tokio::test]
360 async fn capable_first_defaults_to_pinning_efficient_after_a_switch() -> Result<()> {
361 for capable_first in [false, true] {
362 for turns in [None, Some(0), Some(1), Some(-1)] {
363 let mut stage = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
364 stage.efficient_hold_turns = turns;
365 let router = router_with_stage(ClassifyTrigger::UserTurn, stage)?;
366 let recorder = Arc::new(Recorder::default());
367 *recorder.judge_p_solve.lock() = if capable_first { 0.0 } else { 1.0 };
368 let mut initial = turn_request(false);
369 initial.llm_request.messages.truncate(1);
370 let mut write = turn_request(false);
371 if let switchyard_protocol::ContentBlock::ToolCall(call) =
372 &mut write.llm_request.messages[1].content[0]
373 {
374 call.name = "Write".into();
375 }
376 for request in [
377 initial,
378 turn_request(true),
379 write.clone(),
380 write.clone(),
381 write,
382 turn_request(true),
383 turn_request(true),
384 turn_request(true),
385 ] {
386 test_drive_with_models(
387 router.clone(),
388 request,
389 runtime_models(),
390 recorder.serve(),
391 )
392 .await?;
393 }
394 let actual: Vec<_> = recorder
395 .routed()
396 .into_iter()
397 .map(|call| call.target)
398 .collect();
399 let initial = if capable_first { "strong" } else { "weak" };
400 let mut expected = vec![initial, "strong", "strong", "strong", "weak"];
401 let count = turns.unwrap_or(if capable_first { -1 } else { 0 });
402 expected.extend((0..3).map(|i| {
403 if count == -1 || i < count {
404 "weak"
405 } else {
406 "strong"
407 }
408 }));
409 assert_eq!(
410 actual, expected,
411 "capable_first={capable_first}, turns={turns:?}"
412 );
413 assert_eq!(recorder.judge_calls(), 1);
414 }
415 }
416 Ok(())
417 }
418
419 fn router_with_trigger(classify_trigger: ClassifyTrigger) -> Result<Arc<CompositeRouter>> {
420 router_with_stage(
421 classify_trigger,
422 StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
423 )
424 }
425
426 fn router_with_stage(
427 classify_trigger: ClassifyTrigger,
428 stage: StageRouterConfig,
429 ) -> Result<Arc<CompositeRouter>> {
430 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
431 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
432 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
433 base_threshold: 0.5,
434 ..LlmCapabilityConfig::default()
435 }),
436 classify_trigger,
437 ..Default::default()
438 }),
439 stage,
440 })?))
441 }
442
443 #[tokio::test]
444 async fn profiles_reset_holds_and_keep_prompts_when_the_judge_switches() -> Result<()> {
445 for (has_session_id, picker, first_target, first_prompt) in [
446 (false, PickerMode::EfficientFirst, "weak", "EF efficient"),
447 (true, PickerMode::EfficientFirst, "weak", "EF efficient"),
448 (true, PickerMode::CapableFirst, "strong", "EF capable"),
449 ] {
450 let mut efficient = StageRouterConfig::new(picker, 0.5);
451 efficient.capable_hold_turns = -1;
452 efficient.capable_system_prompt = Some("EF capable".into());
453 efficient.efficient_system_prompt = Some("EF efficient".into());
454 let mut capable = StageRouterConfig::new(PickerMode::CapableFirst, 0.0);
455 capable.capable_hold_turns = 0;
456 capable.capable_system_prompt = Some("CF capable".into());
457 capable.efficient_system_prompt = Some("CF efficient".into());
458 for stage in [&mut efficient, &mut capable] {
459 stage.llm_fallback = Some(crate::algorithms::stage::LlmFallback {
460 target: None,
461 config: Default::default(),
462 });
463 }
464 let router = Arc::new(CompositeRouter::new_with_profiles(
465 CompositeJudgeConfig::Capability(TaskClassifierConfig {
466 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
467 base_threshold: 0.5,
468 ..Default::default()
469 }),
470 classify_trigger: ClassifyTrigger::UserTurn,
471 ..Default::default()
472 }),
473 efficient,
474 capable,
475 )?);
476 let recorder = Arc::new(Recorder::default());
477 let mut write = turn_request(false);
478 if let switchyard_protocol::ContentBlock::ToolCall(call) =
479 &mut write.llm_request.messages[1].content[0]
480 {
481 call.name = "Write".into();
482 }
483 let mut context_update = write.clone();
484 context_update.llm_request.messages.push(Message::text(
485 Role::User,
486 "<environment_context><current_date>2026-10-31</current_date></environment_context>",
487 ));
488 let mut state = State::default();
489 for (p_solve, request, target, prompt) in [
490 (1.0, user_turn_request(), first_target, first_prompt),
491 (1.0, turn_request(true), "strong", "EF capable"),
492 (0.0, user_turn_request(), "strong", "CF capable"),
493 (0.0, write, "weak", "CF efficient"),
494 (1.0, context_update, "weak", "CF efficient"),
495 (1.0, user_turn_request(), first_target, first_prompt),
496 (0.0, user_turn_request(), "strong", "CF capable"),
497 ] {
498 *recorder.judge_p_solve.lock() = p_solve;
499 let request = if has_session_id {
500 request
501 } else {
502 unkeyed(request)
503 };
504 let outcome = crate::core::testing::test_drive_with_state(
505 router.clone(),
506 request,
507 runtime_models(),
508 state,
509 recorder.serve(),
510 )
511 .await?;
512 assert_eq!(outcome.selected_model_id()?.as_str(), target);
513 assert_eq!(outcome.request.llm_request.instructions.len(), 1);
514 assert_eq!(
515 outcome.request.llm_request.instructions[0].content,
516 vec![switchyard_protocol::ContentBlock::Text {
517 responses: None,
518 text: prompt.into()
519 }]
520 );
521 state = outcome.state;
522 }
523 assert_eq!(recorder.judge_calls(), 4);
524 }
525 Ok(())
526 }
527
528 #[test]
529 fn rejects_every_request_as_a_trigger() {
530 for judge in [
531 CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
532 CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
533 "Choose a tier",
534 serde_json::json!({"type": "object"}),
535 crate::CustomClassifierPolicy::target_selector("/decision/target"),
536 )),
537 ] {
538 let error = CompositeRouter::new(CompositeRouterConfig {
539 judge,
540 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
541 })
542 .err()
543 .expect("every_request must be rejected for both classifier modes");
544 assert!(matches!(error, LibsyError::AlgorithmError { message }
545 if message == "composite: classify_trigger must be user_turn or new_session"));
546 }
547 }
548
549 fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
550 let mut config = CustomClassifierConfig::new(
551 "Select a tier",
552 serde_json::json!({"type": "object"}),
553 crate::CustomClassifierPolicy::target_selector("/decision/target"),
554 );
555 config.classify_trigger = trigger;
556 CompositeJudgeConfig::Custom(config)
557 }
558
559 #[tokio::test]
560 async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
561 for custom in [false, true] {
562 for (mode, expected) in [
563 (PickerMode::EfficientFirst, "weak"),
564 (PickerMode::CapableFirst, "strong"),
565 ] {
566 let judge = if custom {
567 custom_judge(ClassifyTrigger::NewSession)
568 } else {
569 CompositeJudgeConfig::Capability(TaskClassifierConfig {
570 classify_trigger: ClassifyTrigger::NewSession,
571 ..Default::default()
572 })
573 };
574 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
575 judge,
576 stage: StageRouterConfig::new(mode, 0.5),
577 })?);
578 let calls = Arc::new(Mutex::new(0));
579 let serve = {
580 let calls = calls.clone();
581 move |target: ModelId, _request: Request| {
582 let completion = if target.as_str() == JUDGE {
583 *calls.lock() += 1;
584 "not JSON".to_string()
585 } else {
586 target.to_string()
587 };
588 async move { Ok(crate::core::testing::reply(completion)) }
589 }
590 };
591 for request in [
592 user_turn_request(),
593 turn_request(false),
594 user_turn_request(),
595 ] {
596 let (selected, _) = test_drive_with_models(
597 router.clone(),
598 request,
599 runtime_models(),
600 serve.clone(),
601 )
602 .await?;
603 assert_eq!(selected.as_str(), expected);
604 }
605 assert_eq!(
606 *calls.lock(),
607 1,
608 "new_session must retain a failed judgment"
609 );
610 }
611 }
612 Ok(())
613 }
614
615 #[tokio::test]
616 async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
617 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
618 judge: custom_judge(ClassifyTrigger::UserTurn),
619 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
620 })?);
621 let replies = Arc::new(Mutex::new(
622 [
623 Some(r#"{"decision":{"target":"capable"}}"#),
624 None,
625 Some("not JSON"),
626 Some(r#"{"decision":{"target":"efficient"}}"#),
627 ]
628 .into_iter(),
629 ));
630 let serve = {
631 let replies = replies.clone();
632 move |target: ModelId, _request: Request| {
633 let completion = if target.as_str() == JUDGE {
634 replies
635 .lock()
636 .next()
637 .expect("unexpected judge call")
638 .map(str::to_string)
639 } else {
640 Some(target.to_string())
641 };
642 async move {
643 completion
644 .map(crate::core::testing::reply)
645 .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
646 }
647 }
648 };
649 for (request, expected) in [
650 (user_turn_request(), "strong"),
651 (turn_request(false), "strong"),
652 (user_turn_request(), "strong"),
653 (user_turn_request(), "strong"),
654 (user_turn_request(), "weak"),
655 ] {
656 let (selected, _) =
657 test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
658 .await?;
659 assert_eq!(selected.as_str(), expected);
660 }
661 assert!(replies.lock().next().is_none());
662 Ok(())
663 }
664
665 #[tokio::test]
669 async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
670 let models: HashMap<Category, Vec<ModelId>> = [
671 (Category::Judge, vec![ModelId::from("strong")]),
672 (Category::Capable, vec![ModelId::from("strong")]),
673 (Category::Efficient, vec![ModelId::from("weak")]),
674 (
675 Category::Any,
676 vec![ModelId::from("strong"), ModelId::from("weak")],
677 ),
678 ]
679 .into();
680 let calls = Arc::new(Mutex::new(0u32));
682 let serve = {
683 let calls = Arc::clone(&calls);
684 move |target: ModelId, _request: Request| {
685 let calls = Arc::clone(&calls);
686 async move {
687 let mut calls = calls.lock();
688 *calls += 1;
689 let completion = if *calls == 1 {
690 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
693 } else {
694 target.to_string()
695 };
696 Ok(crate::core::testing::reply(completion))
697 }
698 }
699 };
700 let router = router()?;
701
702 test_drive_with_models(
703 router.clone(),
704 user_turn_request(),
705 models.clone(),
706 serve.clone(),
707 )
708 .await?;
709 let (selected, _) =
711 test_drive_with_models(router, turn_request(false), models, serve).await?;
712
713 assert_eq!(selected, ModelId::from("strong"));
714 Ok(())
715 }
716
717 #[tokio::test]
718 async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
719 let recorder = Arc::new(Recorder::default());
720 *recorder.judge_p_solve.lock() = 0.1;
721 let router = hash_keyed_router()?;
722
723 test_drive_with_models(
724 router.clone(),
725 unkeyed(user_turn_request()),
726 runtime_models(),
727 recorder.serve(),
728 )
729 .await?;
730 test_drive_with_models(
731 router.clone(),
732 unkeyed(turn_request(false)),
733 runtime_models(),
734 recorder.serve(),
735 )
736 .await?;
737
738 assert_eq!(
739 recorder.judge_calls(),
740 1,
741 "a tool step is not a user turn, session id or not"
742 );
743 assert_eq!(
744 recorder.routed()[1].target,
745 "strong",
746 "and the tier survives the tool step"
747 );
748 Ok(())
749 }
750
751 #[tokio::test]
752 async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
753 let recorder = Arc::new(Recorder::default());
754 *recorder.judge_p_solve.lock() = 0.1;
755 let router = router()?;
756
757 test_drive_with_models(
758 router.clone(),
759 user_turn_request(),
760 runtime_models(),
761 recorder.serve(),
762 )
763 .await?;
764 test_drive_with_models(
765 router.clone(),
766 turn_request(false),
767 runtime_models(),
768 recorder.serve(),
769 )
770 .await?;
771
772 let routed = recorder.routed();
773 assert_eq!(
774 routed[0].target, "strong",
775 "a quiet turn falls open to the verdict"
776 );
777 assert_eq!(
778 routed[1].target, "strong",
779 "which holds across the tool steps after it"
780 );
781 assert_eq!(
782 recorder.judge_calls(),
783 1,
784 "a tool step is not a new user turn"
785 );
786 Ok(())
787 }
788
789 #[tokio::test]
790 async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
791 for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
792 let recorder = Arc::new(Recorder::default());
793 *recorder.judge_p_solve.lock() = 0.1;
794 let router = router_with_trigger(trigger)?;
795 let run = |request, state| {
796 crate::core::testing::test_drive_with_state(
797 router.clone(),
798 request,
799 runtime_models(),
800 state,
801 recorder.serve(),
802 )
803 };
804
805 let first = run(unkeyed(user_turn_request()), State::default()).await?;
806 assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
807
808 let continued = run(unkeyed(turn_request(false)), first.state).await?;
809 assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
810 assert_eq!(
811 continued.metadata.and_then(|m| m.evidence),
812 Some(serde_json::json!({"source": "retained"}))
813 );
814 assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
815 }
816 Ok(())
817 }
818}