switchyard_libsy/algorithms/llm_class/
decision.rs1use std::collections::{BTreeMap, HashSet};
7
8use async_trait::async_trait;
9use serde_json::{Value, json};
10use switchyard_protocol::{
11 Category, ChoiceOption, DecisionKind, DecisionQuestion, DecisionRequest, DecisionValue,
12 ModelId, Request, Response,
13};
14
15use crate::algorithms::llm_class::TaskInput;
16use crate::algorithms::util::llm_judge::{libsy_error_reason, report_fail_open};
17use crate::algorithms::util::robustness::safe_error_summary;
18use crate::{Classification, Classifier, Driver, LibsyError, Result, Score, State};
19
20#[derive(Clone, Debug)]
26pub struct DecisionJudgeConfig {
27 pub cutoff: f64,
30 pub instructions: Option<Value>,
33 pub candidates: BTreeMap<String, ModelId>,
36 pub evidence: Value,
40}
41
42impl DecisionJudgeConfig {
43 pub(super) fn validate(&self) -> Result<()> {
44 if !(0.0..=1.0).contains(&self.cutoff) {
45 return Err(LibsyError::AlgorithmError {
46 message: "decision cutoff must be between 0 and 1".into(),
47 });
48 }
49 if self.candidates.values().collect::<HashSet<_>>().len() != self.candidates.len() {
50 return Err(LibsyError::AlgorithmError {
51 message: "decision candidates must map to distinct targets".into(),
52 });
53 }
54 Ok(())
55 }
56
57 fn candidate(&self, model: &ModelId) -> Result<&str> {
58 self.candidates
59 .iter()
60 .find(|(_, target)| *target == model)
61 .map(|(id, _)| id.as_str())
62 .ok_or_else(|| LibsyError::AlgorithmError {
63 message: format!("decision candidate is missing for target {model}"),
64 })
65 }
66}
67
68pub(super) struct DecisionClassifier {
69 config: DecisionJudgeConfig,
70 input: TaskInput,
71 fail_open: bool,
72 question: DecisionQuestion,
73}
74
75impl DecisionClassifier {
76 pub(super) fn new(
77 mut config: DecisionJudgeConfig,
78 mut input: TaskInput,
79 fail_open: bool,
80 ) -> Result<Self> {
81 input.recent_turn_window.get_or_insert(10);
82 let instructions = match config.instructions.take() {
83 Some(instructions) => instructions,
84 None => serde_json::from_str(include_str!(
85 "../../prompts/capability-classifier/relative_advantage.json"
86 ))
87 .map_err(|error| LibsyError::external("loading decision judge instructions", error))?,
88 };
89 let question = DecisionQuestion {
90 instructions,
91 kind: DecisionKind::Choice {
92 options: [
93 ("advantage", "The capable candidate succeeds AND the efficient candidate fails."),
94 ("no_advantage", "The efficient candidate succeeds OR the capable candidate fails, including shared failure."),
95 ]
96 .into_iter()
97 .map(|(id, description)| ChoiceOption {
98 id: id.into(),
99 description: Some(json!(description)),
100 })
101 .collect(),
102 },
103 };
104 Ok(Self {
105 config,
106 input,
107 fail_open,
108 question,
109 })
110 }
111}
112
113#[async_trait]
114impl Classifier for DecisionClassifier {
115 async fn score(
116 &self,
117 _state: &mut State,
118 request: &mut Request,
119 driver: &Driver,
120 ) -> Result<(Classification, Option<Response>)> {
121 let judge = driver.first_model_for(&Category::Judge)?;
122 let capable = driver.first_model_for(&Category::Capable)?;
123 let efficient = driver.first_model_for(&Category::Efficient)?;
124 let decision = DecisionRequest {
125 model: None,
126 context: json!({
127 "task": self.input.messages(request),
128 "session": request.metadata.as_ref().and_then(|m| m.session_id.as_deref()),
129 "candidates": self.config.candidates.keys().collect::<Vec<_>>(),
130 "comparison": {
131 "capable": self.config.candidate(capable)?,
132 "efficient": self.config.candidate(efficient)?,
133 },
134 "evidence": self.config.evidence,
135 }),
136 questions: BTreeMap::from([("route".into(), self.question.clone())]),
137 };
138 let response = match driver.call_decision(decision, judge.clone()).await {
139 Ok(response) => response,
140 Err(error) if self.fail_open => {
141 return Ok(unavailable(
142 driver,
143 judge,
144 safe_error_summary(&error),
145 libsy_error_reason(&error),
146 ));
147 }
148 Err(error) => return Err(error),
149 };
150 let advantage = response
153 .answers
154 .get("route")
155 .and_then(|answer| match &answer.value {
156 DecisionValue::Choice {
157 probabilities: Some(probabilities),
158 ..
159 } => probabilities.get("advantage").map(|p| p.0),
160 _ => None,
161 })
162 .filter(|p| (0.0..=1.0).contains(p));
163 let Some(advantage) = advantage else {
164 return Ok(unavailable(
165 driver,
166 judge,
167 "missing or invalid advantage score".into(),
168 "invalid_verdict",
169 ));
170 };
171 let (target, category) = if advantage > self.config.cutoff {
172 (capable, Category::Capable)
173 } else {
174 (efficient, Category::Efficient)
175 };
176 driver.set_evidence(json!({
177 "source": "decision_classifier",
178 "verdict": "relative_advantage",
179 "score": advantage,
180 "threshold": self.config.cutoff,
181 }));
182 Ok((
183 Classification::Scores(vec![Score {
184 target: target.clone(),
185 confidence: 1.0,
186 category: Some(category),
187 }]),
188 None,
189 ))
190 }
191}
192
193fn unavailable(
194 driver: &Driver,
195 judge: &ModelId,
196 error: String,
197 reason: &'static str,
198) -> (Classification, Option<Response>) {
199 report_fail_open(judge.as_str(), error, reason);
200 driver.set_evidence_if_empty(json!({"source": "fail_open", "reason_code": reason}));
201 (Classification::Ambiguous(vec![]), None)
202}