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::{Tier, fall_open_tier, set_fall_open};
22use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
23use crate::core::classifier::Classifier;
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26use crate::{LibsyError, Result};
27use switchyard_protocol::{Category, Request};
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 => has_new_user_turn(&request.llm_request.messages),
58 ClassifyTrigger::NewSession => {
61 identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
62 && fall_open_tier(state).is_none()
63 }
64 ClassifyTrigger::EveryRequest => true,
66 }
67 }
68
69 fn retain(&self, identity: RoutingIdentity, tier: Tier) {
70 let mut tiers = self.tiers.lock();
71 let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
74 if writable {
75 evict_if_full(&mut tiers);
76 tiers.insert(identity, tier);
77 }
78 }
79}
80
81#[async_trait]
82impl Processor for TierSetter {
83 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
84 let Event::Request { request, driver } = event else {
85 return Ok(());
86 };
87 let identity = retention_key(request, self.message_hash_fallback);
88 if self.is_due(identity.as_ref(), state, request) {
89 let (classification, _) = self.judge.score(state, request, driver).await?;
90 if let Some(winner) = classification.argmax(false)?
91 && let Some(tier) = Self::tier_for(winner.category)
92 {
93 set_fall_open(state, tier);
94 if let Some(identity) = identity {
95 self.retain(identity, tier);
96 }
97 return Ok(());
98 }
99 if self.trigger == ClassifyTrigger::NewSession
100 && let Some(identity) = identity.as_ref()
101 {
102 self.retain(identity.clone(), self.default_tier);
104 }
105 }
106 if let Some(tier) = identity
109 .and_then(|identity| self.tiers.lock().get(&identity).copied())
110 .or_else(|| fall_open_tier(state))
111 {
112 set_fall_open(state, tier);
113 driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
114 }
115 Ok(())
116 }
117}
118
119pub enum CompositeJudgeConfig {
121 Capability(TaskClassifierConfig),
123 Custom(CustomClassifierConfig),
125}
126
127pub struct CompositeRouterConfig {
129 pub judge: CompositeJudgeConfig,
131 pub stage: StageRouterConfig,
133}
134
135pub struct CompositeRouter {
137 route: FallThrough,
138}
139
140impl CompositeRouter {
141 pub fn new(config: CompositeRouterConfig) -> Result<Self> {
148 let (trigger, message_hash_fallback) = match &config.judge {
149 CompositeJudgeConfig::Capability(judge) => {
150 (judge.classify_trigger, judge.message_hash_fallback)
151 }
152 CompositeJudgeConfig::Custom(config) => {
153 (config.classify_trigger, config.message_hash_fallback)
154 }
155 };
156 if trigger == ClassifyTrigger::EveryRequest {
157 return Err(LibsyError::AlgorithmError {
158 message: "composite: classify_trigger must be user_turn or new_session".to_string(),
159 });
160 }
161 let judge: Arc<dyn Classifier> = match config.judge {
162 CompositeJudgeConfig::Capability(judge) => {
163 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
165 config: TaskClassifierConfig {
166 classify_trigger: ClassifyTrigger::EveryRequest,
167 message_hash_fallback: false,
168 ..judge
169 },
170 })?)
171 }
172 CompositeJudgeConfig::Custom(config) => {
173 LlmTaskClassifier::custom_classifier(&config, true)?
174 }
175 };
176 let setter = TierSetter {
177 judge,
178 trigger,
179 message_hash_fallback,
180 default_tier: config.stage.mode.default_tier(),
181 tiers: Mutex::new(HashMap::new()),
182 };
183 let route = build_stage_route(config.stage)?
184 .with_name(COMPOSITE)
185 .with_processor(Arc::new(setter));
186 Ok(Self { route })
187 }
188}
189
190#[async_trait]
191impl Algorithm for CompositeRouter {
192 fn name(&self) -> &str {
193 COMPOSITE
194 }
195
196 async fn route(
197 self: Arc<Self>,
198 driver: Driver,
199 request: Request,
200 ) -> Result<crate::RoutingOutcome> {
201 self.route.execute(driver, request).await
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
208 use std::collections::HashMap;
209 use std::sync::Arc;
210
211 use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
212
213 use super::*;
214 use crate::algorithms::util::stage::PickerMode;
215 use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
216 use crate::core::testing::test_drive_with_models;
217
218 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
219 [
220 (Category::Judge, vec![ModelId::from(JUDGE)]),
221 (Category::Efficient, vec![ModelId::from("weak")]),
222 (Category::Capable, vec![ModelId::from("strong")]),
223 (
224 Category::Any,
225 vec![ModelId::from("strong"), ModelId::from("weak")],
226 ),
227 ]
228 .into()
229 }
230
231 fn user_turn_request() -> Request {
232 let mut request = turn_request(false);
233 request
234 .llm_request
235 .messages
236 .push(Message::text(Role::User, "now rewrite the parser"));
237 request
238 }
239
240 fn unkeyed(mut request: Request) -> Request {
242 if let Some(metadata) = request.metadata.as_mut() {
243 metadata.session_id = None;
244 }
245 request
246 }
247
248 fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
249 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
250 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
251 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
252 base_threshold: 0.5,
253 ..LlmCapabilityConfig::default()
254 }),
255 classify_trigger: ClassifyTrigger::UserTurn,
256 message_hash_fallback: true,
257 ..Default::default()
258 }),
259 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
260 })?))
261 }
262
263 fn router() -> Result<Arc<CompositeRouter>> {
264 router_with_trigger(ClassifyTrigger::UserTurn)
265 }
266
267 #[tokio::test]
268 async fn capable_first_defaults_to_pinning_efficient_after_a_switch() -> Result<()> {
269 for capable_first in [false, true] {
270 for turns in [None, Some(0), Some(1), Some(-1)] {
271 let mut stage = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
272 stage.efficient_hold_turns = turns;
273 let router = router_with_stage(ClassifyTrigger::UserTurn, stage)?;
274 let recorder = Arc::new(Recorder::default());
275 *recorder.judge_p_solve.lock() = if capable_first { 0.0 } else { 1.0 };
276 let mut initial = turn_request(false);
277 initial.llm_request.messages.truncate(1);
278 let mut write = turn_request(false);
279 if let switchyard_protocol::ContentBlock::ToolCall(call) =
280 &mut write.llm_request.messages[1].content[0]
281 {
282 call.name = "Write".into();
283 }
284 for request in [
285 initial,
286 turn_request(true),
287 write.clone(),
288 write.clone(),
289 write,
290 turn_request(true),
291 turn_request(true),
292 turn_request(true),
293 ] {
294 test_drive_with_models(
295 router.clone(),
296 request,
297 runtime_models(),
298 recorder.serve(),
299 )
300 .await?;
301 }
302 let actual: Vec<_> = recorder
303 .routed()
304 .into_iter()
305 .map(|call| call.target)
306 .collect();
307 let initial = if capable_first { "strong" } else { "weak" };
308 let mut expected = vec![initial, "strong", "strong", "strong", "weak"];
309 let count = turns.unwrap_or(if capable_first { -1 } else { 0 });
310 expected.extend((0..3).map(|i| {
311 if count == -1 || i < count {
312 "weak"
313 } else {
314 "strong"
315 }
316 }));
317 assert_eq!(
318 actual, expected,
319 "capable_first={capable_first}, turns={turns:?}"
320 );
321 assert_eq!(recorder.judge_calls(), 1);
322 }
323 }
324 Ok(())
325 }
326
327 fn router_with_trigger(classify_trigger: ClassifyTrigger) -> Result<Arc<CompositeRouter>> {
328 router_with_stage(
329 classify_trigger,
330 StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
331 )
332 }
333
334 fn router_with_stage(
335 classify_trigger: ClassifyTrigger,
336 stage: StageRouterConfig,
337 ) -> Result<Arc<CompositeRouter>> {
338 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
339 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
340 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
341 base_threshold: 0.5,
342 ..LlmCapabilityConfig::default()
343 }),
344 classify_trigger,
345 ..Default::default()
346 }),
347 stage,
348 })?))
349 }
350
351 #[test]
352 fn rejects_every_request_as_a_trigger() {
353 for judge in [
354 CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
355 CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
356 "Choose a tier",
357 serde_json::json!({"type": "object"}),
358 crate::CustomClassifierPolicy::target_selector("/decision/target"),
359 )),
360 ] {
361 let error = CompositeRouter::new(CompositeRouterConfig {
362 judge,
363 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
364 })
365 .err()
366 .expect("every_request must be rejected for both classifier modes");
367 assert!(matches!(error, LibsyError::AlgorithmError { message }
368 if message == "composite: classify_trigger must be user_turn or new_session"));
369 }
370 }
371
372 fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
373 let mut config = CustomClassifierConfig::new(
374 "Select a tier",
375 serde_json::json!({"type": "object"}),
376 crate::CustomClassifierPolicy::target_selector("/decision/target"),
377 );
378 config.classify_trigger = trigger;
379 CompositeJudgeConfig::Custom(config)
380 }
381
382 #[tokio::test]
383 async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
384 for custom in [false, true] {
385 for (mode, expected) in [
386 (PickerMode::EfficientFirst, "weak"),
387 (PickerMode::CapableFirst, "strong"),
388 ] {
389 let judge = if custom {
390 custom_judge(ClassifyTrigger::NewSession)
391 } else {
392 CompositeJudgeConfig::Capability(TaskClassifierConfig {
393 classify_trigger: ClassifyTrigger::NewSession,
394 ..Default::default()
395 })
396 };
397 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
398 judge,
399 stage: StageRouterConfig::new(mode, 0.5),
400 })?);
401 let calls = Arc::new(Mutex::new(0));
402 let serve = {
403 let calls = calls.clone();
404 move |target: ModelId, _request: Request| {
405 let completion = if target.as_str() == JUDGE {
406 *calls.lock() += 1;
407 "not JSON".to_string()
408 } else {
409 target.to_string()
410 };
411 async move { Ok(crate::core::testing::reply(completion)) }
412 }
413 };
414 for request in [
415 user_turn_request(),
416 turn_request(false),
417 user_turn_request(),
418 ] {
419 let (selected, _) = test_drive_with_models(
420 router.clone(),
421 request,
422 runtime_models(),
423 serve.clone(),
424 )
425 .await?;
426 assert_eq!(selected.as_str(), expected);
427 }
428 assert_eq!(
429 *calls.lock(),
430 1,
431 "new_session must retain a failed judgment"
432 );
433 }
434 }
435 Ok(())
436 }
437
438 #[tokio::test]
439 async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
440 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
441 judge: custom_judge(ClassifyTrigger::UserTurn),
442 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
443 })?);
444 let replies = Arc::new(Mutex::new(
445 [
446 Some(r#"{"decision":{"target":"capable"}}"#),
447 None,
448 Some("not JSON"),
449 Some(r#"{"decision":{"target":"efficient"}}"#),
450 ]
451 .into_iter(),
452 ));
453 let serve = {
454 let replies = replies.clone();
455 move |target: ModelId, _request: Request| {
456 let completion = if target.as_str() == JUDGE {
457 replies
458 .lock()
459 .next()
460 .expect("unexpected judge call")
461 .map(str::to_string)
462 } else {
463 Some(target.to_string())
464 };
465 async move {
466 completion
467 .map(crate::core::testing::reply)
468 .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
469 }
470 }
471 };
472 for (request, expected) in [
473 (user_turn_request(), "strong"),
474 (turn_request(false), "strong"),
475 (user_turn_request(), "strong"),
476 (user_turn_request(), "strong"),
477 (user_turn_request(), "weak"),
478 ] {
479 let (selected, _) =
480 test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
481 .await?;
482 assert_eq!(selected.as_str(), expected);
483 }
484 assert!(replies.lock().next().is_none());
485 Ok(())
486 }
487
488 #[tokio::test]
492 async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
493 let models: HashMap<Category, Vec<ModelId>> = [
494 (Category::Judge, vec![ModelId::from("strong")]),
495 (Category::Capable, vec![ModelId::from("strong")]),
496 (Category::Efficient, vec![ModelId::from("weak")]),
497 (
498 Category::Any,
499 vec![ModelId::from("strong"), ModelId::from("weak")],
500 ),
501 ]
502 .into();
503 let calls = Arc::new(Mutex::new(0u32));
505 let serve = {
506 let calls = Arc::clone(&calls);
507 move |target: ModelId, _request: Request| {
508 let calls = Arc::clone(&calls);
509 async move {
510 let mut calls = calls.lock();
511 *calls += 1;
512 let completion = if *calls == 1 {
513 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
516 } else {
517 target.to_string()
518 };
519 Ok(crate::core::testing::reply(completion))
520 }
521 }
522 };
523 let router = router()?;
524
525 test_drive_with_models(
526 router.clone(),
527 user_turn_request(),
528 models.clone(),
529 serve.clone(),
530 )
531 .await?;
532 let (selected, _) =
534 test_drive_with_models(router, turn_request(false), models, serve).await?;
535
536 assert_eq!(selected, ModelId::from("strong"));
537 Ok(())
538 }
539
540 #[tokio::test]
541 async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
542 let recorder = Arc::new(Recorder::default());
543 *recorder.judge_p_solve.lock() = 0.1;
544 let router = hash_keyed_router()?;
545
546 test_drive_with_models(
547 router.clone(),
548 unkeyed(user_turn_request()),
549 runtime_models(),
550 recorder.serve(),
551 )
552 .await?;
553 test_drive_with_models(
554 router.clone(),
555 unkeyed(turn_request(false)),
556 runtime_models(),
557 recorder.serve(),
558 )
559 .await?;
560
561 assert_eq!(
562 recorder.judge_calls(),
563 1,
564 "a tool step is not a user turn, session id or not"
565 );
566 assert_eq!(
567 recorder.routed()[1].target,
568 "strong",
569 "and the tier survives the tool step"
570 );
571 Ok(())
572 }
573
574 #[tokio::test]
575 async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
576 let recorder = Arc::new(Recorder::default());
577 *recorder.judge_p_solve.lock() = 0.1;
578 let router = router()?;
579
580 test_drive_with_models(
581 router.clone(),
582 user_turn_request(),
583 runtime_models(),
584 recorder.serve(),
585 )
586 .await?;
587 test_drive_with_models(
588 router.clone(),
589 turn_request(false),
590 runtime_models(),
591 recorder.serve(),
592 )
593 .await?;
594
595 let routed = recorder.routed();
596 assert_eq!(
597 routed[0].target, "strong",
598 "a quiet turn falls open to the verdict"
599 );
600 assert_eq!(
601 routed[1].target, "strong",
602 "which holds across the tool steps after it"
603 );
604 assert_eq!(
605 recorder.judge_calls(),
606 1,
607 "a tool step is not a new user turn"
608 );
609 Ok(())
610 }
611
612 #[tokio::test]
613 async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
614 for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
615 let recorder = Arc::new(Recorder::default());
616 *recorder.judge_p_solve.lock() = 0.1;
617 let router = router_with_trigger(trigger)?;
618 let run = |request, state| {
619 crate::core::testing::test_drive_with_state(
620 router.clone(),
621 request,
622 runtime_models(),
623 state,
624 recorder.serve(),
625 )
626 };
627
628 let first = run(unkeyed(user_turn_request()), State::default()).await?;
629 assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
630
631 let continued = run(unkeyed(turn_request(false)), first.state).await?;
632 assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
633 assert_eq!(
634 continued.metadata.and_then(|m| m.evidence),
635 Some(serde_json::json!({"source": "retained"}))
636 );
637 assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
638 }
639 Ok(())
640 }
641}