1010//!
1111//! Signals do not decide every turn. An under-threshold turn abstains and falls
1212//! through to the optional [`LlmTaskClassifier`] — the capability route's judge,
13- //! joined in unchanged — and then to the picker's default tier. The judge is
14- //! asked per turn and its verdict is never pinned to the session .
13+ //! joined in unchanged — and then to the picker's default tier, or to another
14+ //! floor a decider ahead of stage holds in its place .
1515//!
1616use std:: sync:: Arc ;
1717
1818use async_trait:: async_trait;
1919
20- use super :: fall_through:: { DefaultTarget , FallThrough } ;
20+ use super :: fall_through:: FallThrough ;
2121use super :: llm_class:: { LlmClassifierConfig , LlmTaskClassifier , TaskClassifierConfig } ;
2222use super :: util:: prompts:: { SystemPromptProcessor , TargetPrompts } ;
2323use super :: util:: stage:: {
24- DecisionSource , HandoffNoteConfig , PickerMode , StageClassifier , StageTargets ,
24+ DecisionSource , HandoffNoteConfig , PickerMode , StageClassifier , StageTargets , Tier , held_tier ,
2525 record_decision_source, record_routing_decision,
2626} ;
2727use super :: util:: tool_signals:: { DEFAULT_RECENT_WINDOW , ToolSignalProcessor } ;
2828use crate :: core:: algorithm:: { Algorithm , Driver } ;
29- use crate :: core:: classifier:: { Classification , Classifier } ;
29+ use crate :: core:: classifier:: { Classification , Classifier , Score } ;
3030use crate :: core:: state:: State ;
3131use crate :: { LibsyError , Result } ;
3232use switchyard_protocol:: { ModelId , Request , Response } ;
@@ -65,6 +65,32 @@ impl Classifier<State> for SourceStamp {
6565 }
6666}
6767
68+ /// Closes the cascade at zero confidence: a floor, not a judgement.
69+ struct HeldFloor {
70+ targets : StageTargets ,
71+ default_tier : Tier ,
72+ }
73+
74+ #[ async_trait]
75+ impl Classifier < State > for HeldFloor {
76+ async fn score (
77+ & self ,
78+ state : & mut State ,
79+ _request : & mut Request ,
80+ _driver : Option < & Driver > ,
81+ ) -> Result < ( Classification , Option < Response > ) > {
82+ let tier = held_tier ( state) . unwrap_or ( self . default_tier ) ;
83+ let target = self . targets . name ( tier) . clone ( ) ;
84+ Ok ( (
85+ Classification :: Scores ( vec ! [ Score {
86+ target,
87+ confidence: 0.0 ,
88+ } ] ) ,
89+ None ,
90+ ) )
91+ }
92+ }
93+
6894/// The capability judge a stage router falls through to.
6995pub struct LlmFallback {
7096 /// Target the judge model is called through. It is not a routing
@@ -165,9 +191,11 @@ fn build_route(
165191 // The tiers are a fixed pair; their targets are whatever the deployment calls
166192 // them, and the classifier scores onto those names.
167193 let targets = StageTargets :: new ( capable. clone ( ) , efficient. clone ( ) ) ;
168- // The picker's mode fixes the fallback tier up front, so the terminal
169- // classifier is a constant rather than a per-turn lookup.
170- let fall_open = targets. name ( config. mode . default_tier ( ) ) . to_string ( ) ;
194+ let default_tier = config. mode . default_tier ( ) ;
195+ let floor = HeldFloor {
196+ targets : targets. clone ( ) ,
197+ default_tier,
198+ } ;
171199
172200 let mut classifier = StageClassifier :: new ( targets, config. mode , config. confidence_threshold ) ;
173201 if let Some ( notes) = config. handoff_notes {
@@ -194,10 +222,9 @@ fn build_route(
194222 source : DecisionSource :: LlmClassifier ,
195223 } ) ) ;
196224 }
197- // Nothing behind this, so the turn lands on the picker's default tier —
198- // including when the judge could not tell.
225+ // Nothing behind this, so no turn is left unrouted.
199226 router = router. with_classifier ( Arc :: new ( SourceStamp {
200- inner : Arc :: new ( DefaultTarget :: new ( fall_open ) ) ,
227+ inner : Arc :: new ( floor ) ,
201228 source : DecisionSource :: FallOpen ,
202229 } ) ) ;
203230 // Runs on the post-decision hook, so it applies to the target the cascade
@@ -219,8 +246,8 @@ mod tests {
219246 } ;
220247
221248 use super :: * ;
222- use crate :: algorithms:: util:: stage:: DECISION_SOURCE_KEY ;
223- use crate :: core:: classifier :: Score ;
249+ use crate :: algorithms:: util:: stage:: { DECISION_SOURCE_KEY , hold_tier , release_tier } ;
250+ use crate :: core:: processor :: { Event , Processor } ;
224251 use crate :: core:: state:: StateValue ;
225252 use crate :: core:: testing:: { Serve , reply, test_drive} ;
226253 use switchyard_protocol:: { Metadata , Response } ;
@@ -465,6 +492,57 @@ mod tests {
465492 }
466493 }
467494
495+ /// Stands in for a decider ahead of stage.
496+ #[ derive( Default ) ]
497+ struct FloorDecider {
498+ requests : Mutex < u32 > ,
499+ }
500+
501+ #[ async_trait]
502+ impl Processor < State > for FloorDecider {
503+ async fn process ( & self , state : & mut State , event : Event < ' _ > ) -> Result < ( ) > {
504+ if matches ! ( event, Event :: Request ( _) ) {
505+ let mut requests = self . requests . lock ( ) ;
506+ * requests += 1 ;
507+ match * requests {
508+ 3 => release_tier ( state) ,
509+ _ => hold_tier ( state, Tier :: Efficient ) ,
510+ }
511+ }
512+ Ok ( ( ) )
513+ }
514+ }
515+
516+ #[ tokio:: test]
517+ async fn a_held_floor_replaces_the_picker_default_and_leaves_the_signals_alone ( ) -> Result < ( ) > {
518+ let recorder = Arc :: new ( Recorder :: default ( ) ) ;
519+ // The picker would fall open to "strong"; the held floor says "weak".
520+ let config = StageRouterConfig :: new ( PickerMode :: CapableFirst , 0.5 ) ;
521+ let route: Arc < dyn Algorithm > = Arc :: new (
522+ build_route ( ModelId :: from ( "strong" ) , ModelId :: from ( "weak" ) , config) ?
523+ . with_processor ( Arc :: new ( FloorDecider :: default ( ) ) ) ,
524+ ) ;
525+
526+ test_drive ( route. clone ( ) , turn_request ( false ) , recorder. serve ( ) ) . await ?;
527+ test_drive ( route. clone ( ) , turn_request ( true ) , recorder. serve ( ) ) . await ?;
528+ test_drive ( route. clone ( ) , turn_request ( false ) , recorder. serve ( ) ) . await ?;
529+
530+ let routed = recorder. routed ( ) ;
531+ assert_eq ! (
532+ routed[ 0 ] . target, "weak" ,
533+ "an undecided turn takes the floor"
534+ ) ;
535+ assert_eq ! (
536+ routed[ 1 ] . target, "strong" ,
537+ "a critical failure still reaches the signals"
538+ ) ;
539+ assert_eq ! (
540+ routed[ 2 ] . target, "strong" ,
541+ "releasing restores the picker default"
542+ ) ;
543+ Ok ( ( ) )
544+ }
545+
468546 #[ tokio:: test]
469547 async fn a_signal_driven_escalation_hands_the_note_to_the_model ( ) -> Result < ( ) > {
470548 let recorder = Arc :: new ( Recorder :: default ( ) ) ;
0 commit comments