Skip to content

Commit d8de083

Browse files
committed
feat(libsy): let stage's fall-open tier be set at runtime
Signed-off-by: Ryan Lempka <rlempka@nvidia.com>
1 parent 053a61e commit d8de083

3 files changed

Lines changed: 126 additions & 15 deletions

File tree

crates/libsy/src/algorithms/stage.rs

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,23 @@
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
//!
1616
use std::sync::Arc;
1717

1818
use async_trait::async_trait;
1919

20-
use super::fall_through::{DefaultTarget, FallThrough};
20+
use super::fall_through::FallThrough;
2121
use super::llm_class::{LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig};
2222
use super::util::prompts::{SystemPromptProcessor, TargetPrompts};
2323
use 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
};
2727
use super::util::tool_signals::{DEFAULT_RECENT_WINDOW, ToolSignalProcessor};
2828
use crate::core::algorithm::{Algorithm, Driver};
29-
use crate::core::classifier::{Classification, Classifier};
29+
use crate::core::classifier::{Classification, Classifier, Score};
3030
use crate::core::state::State;
3131
use crate::{LibsyError, Result};
3232
use 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.
6995
pub 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());

crates/libsy/src/algorithms/util/stage.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,22 @@ impl Tier {
8181
/// Stable label for stats and the [`routing_tier`](Classifier::routing_tier)
8282
/// hook, independent of what the tiers' targets are called. These are the
8383
/// strings the capability route reports too, so a deployment running both
84-
/// sees one tier vocabulary.
84+
/// sees one tier vocabulary. Also the encoding [`hold_tier`] stores, so
85+
/// changing these strings invalidates a held tier.
8586
fn label(self) -> &'static str {
8687
match self {
8788
Self::Capable => "strong",
8889
Self::Efficient => "weak",
8990
}
9091
}
92+
93+
fn from_label(label: &str) -> Option<Self> {
94+
match label {
95+
"strong" => Some(Self::Capable),
96+
"weak" => Some(Self::Efficient),
97+
_ => None,
98+
}
99+
}
91100
}
92101

93102
/// The targets a stage router's two tiers route to.
@@ -153,6 +162,30 @@ impl PickerMode {
153162
/// `State.extra` key under which the turn's [`DecisionSource`] is recorded.
154163
pub const DECISION_SOURCE_KEY: &str = "decision_source";
155164

165+
/// `State.extra` key under which a held fall-open tier is recorded.
166+
const HELD_TIER_KEY: &str = "held_tier";
167+
168+
/// Sets the tier undecided turns fall open to, until [`release_tier`] drops it.
169+
pub fn hold_tier(state: &mut State, tier: Tier) {
170+
state.extra.insert(
171+
HELD_TIER_KEY.to_string(),
172+
StateValue::String(tier.label().to_string()),
173+
);
174+
}
175+
176+
/// Restores the picker's default tier.
177+
pub fn release_tier(state: &mut State) {
178+
state.extra.remove(HELD_TIER_KEY);
179+
}
180+
181+
/// The tier held for this session, if any.
182+
pub(crate) fn held_tier(state: &State) -> Option<Tier> {
183+
match state.extra.get(HELD_TIER_KEY) {
184+
Some(StateValue::String(label)) => Tier::from_label(label),
185+
_ => None,
186+
}
187+
}
188+
156189
/// Record which component decided the turn.
157190
pub(crate) fn record_decision_source(state: &mut State, source: DecisionSource) {
158191
state.extra.insert(

crates/libsy/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub use algorithms::util::tool_signals::{DEFAULT_RECENT_WINDOW, ToolSignals};
3838
pub use algorithms::util::stage::{
3939
CodingAgentDimensions, DECISION_SOURCE_KEY, DecisionSource, HandoffNoteConfig, PickOutcome,
4040
PickerMode, ScoreResult, StageClassifier, StageTargets, Tier, dimensions_from_signal,
41-
pick_tier, score_signal,
41+
hold_tier, pick_tier, release_tier, score_signal,
4242
};
4343

4444
mod observability;

0 commit comments

Comments
 (0)