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, 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<State>>,
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>, request: &Request) -> bool {
56 match self.trigger {
57 ClassifyTrigger::UserTurn => has_new_user_turn(&request.llm_request.messages),
58 ClassifyTrigger::NewSession => {
60 identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
61 }
62 ClassifyTrigger::EveryRequest => true,
64 }
65 }
66
67 fn retain(&self, identity: RoutingIdentity, tier: Tier) {
68 let mut tiers = self.tiers.lock();
69 let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
72 if writable {
73 evict_if_full(&mut tiers);
74 tiers.insert(identity, tier);
75 }
76 }
77}
78
79#[async_trait]
80impl Processor<State> for TierSetter {
81 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
82 let Event::Request { request, driver } = event else {
83 return Ok(());
84 };
85 let identity = retention_key(request, self.message_hash_fallback);
86 if self.is_due(identity.as_ref(), request) {
87 let (classification, _) = self.judge.score(state, request, driver).await?;
88 if let Some(winner) = classification.argmax(false)?
89 && let Some(tier) = Self::tier_for(winner.category)
90 {
91 set_fall_open(state, tier);
92 if let Some(identity) = identity {
93 self.retain(identity, tier);
94 }
95 return Ok(());
96 }
97 if self.trigger == ClassifyTrigger::NewSession
98 && let Some(identity) = identity.as_ref()
99 {
100 self.retain(identity.clone(), self.default_tier);
102 }
103 }
104 if let Some(tier) = identity.and_then(|identity| self.tiers.lock().get(&identity).copied())
107 {
108 set_fall_open(state, tier);
109 driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
110 }
111 Ok(())
112 }
113}
114
115pub enum CompositeJudgeConfig {
117 Capability(TaskClassifierConfig),
119 Custom(CustomClassifierConfig),
121}
122
123pub struct CompositeRouterConfig {
125 pub judge: CompositeJudgeConfig,
127 pub stage: StageRouterConfig,
129}
130
131pub struct CompositeRouter {
133 route: FallThrough<State>,
134}
135
136impl CompositeRouter {
137 pub fn new(config: CompositeRouterConfig) -> Result<Self> {
144 let (trigger, message_hash_fallback) = match &config.judge {
145 CompositeJudgeConfig::Capability(judge) => {
146 (judge.classify_trigger, judge.message_hash_fallback)
147 }
148 CompositeJudgeConfig::Custom(config) => {
149 (config.classify_trigger, config.message_hash_fallback)
150 }
151 };
152 if trigger == ClassifyTrigger::EveryRequest {
153 return Err(LibsyError::AlgorithmError {
154 message: "composite: classify_trigger must be user_turn or new_session".to_string(),
155 });
156 }
157 let judge: Arc<dyn Classifier<State>> = match config.judge {
158 CompositeJudgeConfig::Capability(judge) => {
159 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
161 config: TaskClassifierConfig {
162 classify_trigger: ClassifyTrigger::EveryRequest,
163 message_hash_fallback: false,
164 ..judge
165 },
166 })?)
167 }
168 CompositeJudgeConfig::Custom(config) => {
169 LlmTaskClassifier::custom_classifier(&config, true)?
170 }
171 };
172 let setter = TierSetter {
173 judge,
174 trigger,
175 message_hash_fallback,
176 default_tier: config.stage.mode.default_tier(),
177 tiers: Mutex::new(HashMap::new()),
178 };
179 let route = build_stage_route(config.stage)?
180 .with_name(COMPOSITE)
181 .with_processor(Arc::new(setter));
182 Ok(Self { route })
183 }
184}
185
186#[async_trait]
187impl Algorithm for CompositeRouter {
188 fn name(&self) -> &str {
189 COMPOSITE
190 }
191
192 async fn route(
193 self: Arc<Self>,
194 driver: Driver,
195 request: Request,
196 ) -> Result<crate::RoutingOutcome> {
197 self.route.execute(driver, request).await
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
204 use std::collections::HashMap;
205 use std::sync::Arc;
206
207 use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
208
209 use super::*;
210 use crate::algorithms::util::stage::PickerMode;
211 use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
212 use crate::core::testing::test_drive_with_models;
213
214 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
215 [
216 (Category::Judge, vec![ModelId::from(JUDGE)]),
217 (Category::Efficient, vec![ModelId::from("weak")]),
218 (Category::Capable, vec![ModelId::from("strong")]),
219 (
220 Category::Any,
221 vec![ModelId::from("strong"), ModelId::from("weak")],
222 ),
223 ]
224 .into()
225 }
226
227 fn user_turn_request() -> Request {
228 let mut request = turn_request(false);
229 request
230 .llm_request
231 .messages
232 .push(Message::text(Role::User, "now rewrite the parser"));
233 request
234 }
235
236 fn unkeyed(mut request: Request) -> Request {
238 if let Some(metadata) = request.metadata.as_mut() {
239 metadata.session_id = None;
240 }
241 request
242 }
243
244 fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
245 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
246 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
247 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
248 base_threshold: 0.5,
249 ..LlmCapabilityConfig::default()
250 }),
251 classify_trigger: ClassifyTrigger::UserTurn,
252 message_hash_fallback: true,
253 ..Default::default()
254 }),
255 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
256 })?))
257 }
258
259 fn router() -> Result<Arc<CompositeRouter>> {
260 Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
261 judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
262 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
263 base_threshold: 0.5,
264 ..LlmCapabilityConfig::default()
265 }),
266 classify_trigger: ClassifyTrigger::UserTurn,
267 ..Default::default()
268 }),
269 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
270 })?))
271 }
272
273 #[test]
274 fn rejects_every_request_as_a_trigger() {
275 for judge in [
276 CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
277 CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
278 "Choose a tier",
279 serde_json::json!({"type": "object"}),
280 crate::CustomClassifierPolicy::target_selector("/decision/target"),
281 )),
282 ] {
283 let error = CompositeRouter::new(CompositeRouterConfig {
284 judge,
285 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
286 })
287 .err()
288 .expect("every_request must be rejected for both classifier modes");
289 assert!(matches!(error, LibsyError::AlgorithmError { message }
290 if message == "composite: classify_trigger must be user_turn or new_session"));
291 }
292 }
293
294 fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
295 let mut config = CustomClassifierConfig::new(
296 "Select a tier",
297 serde_json::json!({"type": "object"}),
298 crate::CustomClassifierPolicy::target_selector("/decision/target"),
299 );
300 config.classify_trigger = trigger;
301 CompositeJudgeConfig::Custom(config)
302 }
303
304 #[tokio::test]
305 async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
306 for custom in [false, true] {
307 for (mode, expected) in [
308 (PickerMode::EfficientFirst, "weak"),
309 (PickerMode::CapableFirst, "strong"),
310 ] {
311 let judge = if custom {
312 custom_judge(ClassifyTrigger::NewSession)
313 } else {
314 CompositeJudgeConfig::Capability(TaskClassifierConfig {
315 classify_trigger: ClassifyTrigger::NewSession,
316 ..Default::default()
317 })
318 };
319 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
320 judge,
321 stage: StageRouterConfig::new(mode, 0.5),
322 })?);
323 let calls = Arc::new(Mutex::new(0));
324 let serve = {
325 let calls = calls.clone();
326 move |target: ModelId, _request: Request| {
327 let completion = if target.as_str() == JUDGE {
328 *calls.lock() += 1;
329 "not JSON".to_string()
330 } else {
331 target.to_string()
332 };
333 async move { Ok(crate::core::testing::reply(completion)) }
334 }
335 };
336 for request in [
337 user_turn_request(),
338 turn_request(false),
339 user_turn_request(),
340 ] {
341 let (selected, _) = test_drive_with_models(
342 router.clone(),
343 request,
344 runtime_models(),
345 serve.clone(),
346 )
347 .await?;
348 assert_eq!(selected.as_str(), expected);
349 }
350 assert_eq!(
351 *calls.lock(),
352 1,
353 "new_session must retain a failed judgment"
354 );
355 }
356 }
357 Ok(())
358 }
359
360 #[tokio::test]
361 async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
362 let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
363 judge: custom_judge(ClassifyTrigger::UserTurn),
364 stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
365 })?);
366 let replies = Arc::new(Mutex::new(
367 [
368 Some(r#"{"decision":{"target":"capable"}}"#),
369 None,
370 Some("not JSON"),
371 Some(r#"{"decision":{"target":"efficient"}}"#),
372 ]
373 .into_iter(),
374 ));
375 let serve = {
376 let replies = replies.clone();
377 move |target: ModelId, _request: Request| {
378 let completion = if target.as_str() == JUDGE {
379 replies
380 .lock()
381 .next()
382 .expect("unexpected judge call")
383 .map(str::to_string)
384 } else {
385 Some(target.to_string())
386 };
387 async move {
388 completion
389 .map(crate::core::testing::reply)
390 .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
391 }
392 }
393 };
394 for (request, expected) in [
395 (user_turn_request(), "strong"),
396 (turn_request(false), "strong"),
397 (user_turn_request(), "strong"),
398 (user_turn_request(), "strong"),
399 (user_turn_request(), "weak"),
400 ] {
401 let (selected, _) =
402 test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
403 .await?;
404 assert_eq!(selected.as_str(), expected);
405 }
406 assert!(replies.lock().next().is_none());
407 Ok(())
408 }
409
410 #[tokio::test]
414 async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
415 let models: HashMap<Category, Vec<ModelId>> = [
416 (Category::Judge, vec![ModelId::from("strong")]),
417 (Category::Capable, vec![ModelId::from("strong")]),
418 (Category::Efficient, vec![ModelId::from("weak")]),
419 (
420 Category::Any,
421 vec![ModelId::from("strong"), ModelId::from("weak")],
422 ),
423 ]
424 .into();
425 let calls = Arc::new(Mutex::new(0u32));
427 let serve = {
428 let calls = Arc::clone(&calls);
429 move |target: ModelId, _request: Request| {
430 let calls = Arc::clone(&calls);
431 async move {
432 let mut calls = calls.lock();
433 *calls += 1;
434 let completion = if *calls == 1 {
435 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
438 } else {
439 target.to_string()
440 };
441 Ok(crate::core::testing::reply(completion))
442 }
443 }
444 };
445 let router = router()?;
446
447 test_drive_with_models(
448 router.clone(),
449 user_turn_request(),
450 models.clone(),
451 serve.clone(),
452 )
453 .await?;
454 let (selected, _) =
456 test_drive_with_models(router, turn_request(false), models, serve).await?;
457
458 assert_eq!(selected, ModelId::from("strong"));
459 Ok(())
460 }
461
462 #[tokio::test]
463 async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
464 let recorder = Arc::new(Recorder::default());
465 *recorder.judge_p_solve.lock() = 0.1;
466 let router = hash_keyed_router()?;
467
468 test_drive_with_models(
469 router.clone(),
470 unkeyed(user_turn_request()),
471 runtime_models(),
472 recorder.serve(),
473 )
474 .await?;
475 test_drive_with_models(
476 router.clone(),
477 unkeyed(turn_request(false)),
478 runtime_models(),
479 recorder.serve(),
480 )
481 .await?;
482
483 assert_eq!(
484 recorder.judge_calls(),
485 1,
486 "a tool step is not a user turn, session id or not"
487 );
488 assert_eq!(
489 recorder.routed()[1].target,
490 "strong",
491 "and the tier survives the tool step"
492 );
493 Ok(())
494 }
495
496 #[tokio::test]
497 async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
498 let recorder = Arc::new(Recorder::default());
499 *recorder.judge_p_solve.lock() = 0.1;
500 let router = router()?;
501
502 test_drive_with_models(
503 router.clone(),
504 user_turn_request(),
505 runtime_models(),
506 recorder.serve(),
507 )
508 .await?;
509 test_drive_with_models(
510 router.clone(),
511 turn_request(false),
512 runtime_models(),
513 recorder.serve(),
514 )
515 .await?;
516
517 let routed = recorder.routed();
518 assert_eq!(
519 routed[0].target, "strong",
520 "a quiet turn falls open to the verdict"
521 );
522 assert_eq!(
523 routed[1].target, "strong",
524 "which holds across the tool steps after it"
525 );
526 assert_eq!(
527 recorder.judge_calls(),
528 1,
529 "a tool step is not a new user turn"
530 );
531 Ok(())
532 }
533}