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 fn router_with_trigger(classify_trigger: ClassifyTrigger) -> Result<Arc<CompositeRouter>> {
268 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
269 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
270 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
271 base_threshold: 0.5,
272 ..LlmCapabilityConfig::default()
273 }),
274 classify_trigger,
275 ..Default::default()
276 }),
277 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
278 })?))
279 }
280
281 #[test]
282 fn rejects_every_request_as_a_trigger() {
283 for judge in [
284 CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
285 CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
286 "Choose a tier",
287 serde_json::json!({"type": "object"}),
288 crate::CustomClassifierPolicy::target_selector("/decision/target"),
289 )),
290 ] {
291 let error = CompositeRouter::new(CompositeRouterConfig {
292 judge,
293 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
294 })
295 .err()
296 .expect("every_request must be rejected for both classifier modes");
297 assert!(matches!(error, LibsyError::AlgorithmError { message }
298 if message == "composite: classify_trigger must be user_turn or new_session"));
299 }
300 }
301
302 fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
303 let mut config = CustomClassifierConfig::new(
304 "Select a tier",
305 serde_json::json!({"type": "object"}),
306 crate::CustomClassifierPolicy::target_selector("/decision/target"),
307 );
308 config.classify_trigger = trigger;
309 CompositeJudgeConfig::Custom(config)
310 }
311
312 #[tokio::test]
313 async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
314 for custom in [false, true] {
315 for (mode, expected) in [
316 (PickerMode::EfficientFirst, "weak"),
317 (PickerMode::CapableFirst, "strong"),
318 ] {
319 let judge = if custom {
320 custom_judge(ClassifyTrigger::NewSession)
321 } else {
322 CompositeJudgeConfig::Capability(TaskClassifierConfig {
323 classify_trigger: ClassifyTrigger::NewSession,
324 ..Default::default()
325 })
326 };
327 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
328 judge,
329 stage: StageRouterConfig::new(mode, 0.5),
330 })?);
331 let calls = Arc::new(Mutex::new(0));
332 let serve = {
333 let calls = calls.clone();
334 move |target: ModelId, _request: Request| {
335 let completion = if target.as_str() == JUDGE {
336 *calls.lock() += 1;
337 "not JSON".to_string()
338 } else {
339 target.to_string()
340 };
341 async move { Ok(crate::core::testing::reply(completion)) }
342 }
343 };
344 for request in [
345 user_turn_request(),
346 turn_request(false),
347 user_turn_request(),
348 ] {
349 let (selected, _) = test_drive_with_models(
350 router.clone(),
351 request,
352 runtime_models(),
353 serve.clone(),
354 )
355 .await?;
356 assert_eq!(selected.as_str(), expected);
357 }
358 assert_eq!(
359 *calls.lock(),
360 1,
361 "new_session must retain a failed judgment"
362 );
363 }
364 }
365 Ok(())
366 }
367
368 #[tokio::test]
369 async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
370 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
371 judge: custom_judge(ClassifyTrigger::UserTurn),
372 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
373 })?);
374 let replies = Arc::new(Mutex::new(
375 [
376 Some(r#"{"decision":{"target":"capable"}}"#),
377 None,
378 Some("not JSON"),
379 Some(r#"{"decision":{"target":"efficient"}}"#),
380 ]
381 .into_iter(),
382 ));
383 let serve = {
384 let replies = replies.clone();
385 move |target: ModelId, _request: Request| {
386 let completion = if target.as_str() == JUDGE {
387 replies
388 .lock()
389 .next()
390 .expect("unexpected judge call")
391 .map(str::to_string)
392 } else {
393 Some(target.to_string())
394 };
395 async move {
396 completion
397 .map(crate::core::testing::reply)
398 .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
399 }
400 }
401 };
402 for (request, expected) in [
403 (user_turn_request(), "strong"),
404 (turn_request(false), "strong"),
405 (user_turn_request(), "strong"),
406 (user_turn_request(), "strong"),
407 (user_turn_request(), "weak"),
408 ] {
409 let (selected, _) =
410 test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
411 .await?;
412 assert_eq!(selected.as_str(), expected);
413 }
414 assert!(replies.lock().next().is_none());
415 Ok(())
416 }
417
418 #[tokio::test]
422 async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
423 let models: HashMap<Category, Vec<ModelId>> = [
424 (Category::Judge, vec![ModelId::from("strong")]),
425 (Category::Capable, vec![ModelId::from("strong")]),
426 (Category::Efficient, vec![ModelId::from("weak")]),
427 (
428 Category::Any,
429 vec![ModelId::from("strong"), ModelId::from("weak")],
430 ),
431 ]
432 .into();
433 let calls = Arc::new(Mutex::new(0u32));
435 let serve = {
436 let calls = Arc::clone(&calls);
437 move |target: ModelId, _request: Request| {
438 let calls = Arc::clone(&calls);
439 async move {
440 let mut calls = calls.lock();
441 *calls += 1;
442 let completion = if *calls == 1 {
443 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
446 } else {
447 target.to_string()
448 };
449 Ok(crate::core::testing::reply(completion))
450 }
451 }
452 };
453 let router = router()?;
454
455 test_drive_with_models(
456 router.clone(),
457 user_turn_request(),
458 models.clone(),
459 serve.clone(),
460 )
461 .await?;
462 let (selected, _) =
464 test_drive_with_models(router, turn_request(false), models, serve).await?;
465
466 assert_eq!(selected, ModelId::from("strong"));
467 Ok(())
468 }
469
470 #[tokio::test]
471 async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
472 let recorder = Arc::new(Recorder::default());
473 *recorder.judge_p_solve.lock() = 0.1;
474 let router = hash_keyed_router()?;
475
476 test_drive_with_models(
477 router.clone(),
478 unkeyed(user_turn_request()),
479 runtime_models(),
480 recorder.serve(),
481 )
482 .await?;
483 test_drive_with_models(
484 router.clone(),
485 unkeyed(turn_request(false)),
486 runtime_models(),
487 recorder.serve(),
488 )
489 .await?;
490
491 assert_eq!(
492 recorder.judge_calls(),
493 1,
494 "a tool step is not a user turn, session id or not"
495 );
496 assert_eq!(
497 recorder.routed()[1].target,
498 "strong",
499 "and the tier survives the tool step"
500 );
501 Ok(())
502 }
503
504 #[tokio::test]
505 async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
506 let recorder = Arc::new(Recorder::default());
507 *recorder.judge_p_solve.lock() = 0.1;
508 let router = router()?;
509
510 test_drive_with_models(
511 router.clone(),
512 user_turn_request(),
513 runtime_models(),
514 recorder.serve(),
515 )
516 .await?;
517 test_drive_with_models(
518 router.clone(),
519 turn_request(false),
520 runtime_models(),
521 recorder.serve(),
522 )
523 .await?;
524
525 let routed = recorder.routed();
526 assert_eq!(
527 routed[0].target, "strong",
528 "a quiet turn falls open to the verdict"
529 );
530 assert_eq!(
531 routed[1].target, "strong",
532 "which holds across the tool steps after it"
533 );
534 assert_eq!(
535 recorder.judge_calls(),
536 1,
537 "a tool step is not a new user turn"
538 );
539 Ok(())
540 }
541
542 #[tokio::test]
543 async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
544 for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
545 let recorder = Arc::new(Recorder::default());
546 *recorder.judge_p_solve.lock() = 0.1;
547 let router = router_with_trigger(trigger)?;
548 let run = |request, state| {
549 crate::core::testing::test_drive_with_state(
550 router.clone(),
551 request,
552 runtime_models(),
553 state,
554 recorder.serve(),
555 )
556 };
557
558 let first = run(unkeyed(user_turn_request()), State::default()).await?;
559 assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
560
561 let continued = run(unkeyed(turn_request(false)), first.state).await?;
562 assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
563 assert_eq!(
564 continued.metadata.and_then(|m| m.evidence),
565 Some(serde_json::json!({"source": "retained"}))
566 );
567 assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
568 }
569 Ok(())
570 }
571}