@@ -13,7 +13,7 @@ use switchyard_protocol::{Category, ContentBlock, Message, Role};
1313
1414mod decision;
1515use decision:: DecisionClassifier ;
16- pub use decision:: DecisionJudgeConfig ;
16+ pub use decision:: { DecisionJudgeConfig , DecisionPolicy , DecisionRule } ;
1717
1818use super :: escalation;
1919use super :: fall_through:: FallThrough ;
@@ -1294,6 +1294,7 @@ mod tests {
12941294
12951295 let settings = DecisionJudgeConfig {
12961296 cutoff : 0.4 ,
1297+ policy : None ,
12971298 instructions : None ,
12981299 candidates : BTreeMap :: from ( [
12991300 ( "a" . into ( ) , "capable" . into ( ) ) ,
@@ -1306,6 +1307,21 @@ mod tests {
13061307 "summaries" : [ ] ,
13071308 } ) ,
13081309 } ;
1310+ let ordered_policy = DecisionPolicy {
1311+ rules : vec ! [
1312+ DecisionRule {
1313+ score: "a" . into( ) ,
1314+ target: "a" . into( ) ,
1315+ cutoff: 0.6 ,
1316+ } ,
1317+ DecisionRule {
1318+ score: "c" . into( ) ,
1319+ target: "c" . into( ) ,
1320+ cutoff: 0.2 ,
1321+ } ,
1322+ ] ,
1323+ fallback : "b" . into ( ) ,
1324+ } ;
13091325 let mut request = classify_session_request ( ) ;
13101326 request. llm_request . messages . push ( Message {
13111327 role : Role :: Assistant ,
@@ -1344,8 +1360,19 @@ mod tests {
13441360 ( "provider error" , 0.0 , false , None ) ,
13451361 ( "dropped reply" , 0.0 , true , Some ( "capable" ) ) ,
13461362 ( "missing candidate" , 0.0 , true , None ) ,
1363+ ( "ordered first" , 0.65 , true , Some ( "capable" ) ) ,
1364+ ( "ordered second" , 0.6 , false , Some ( "third" ) ) ,
1365+ ( "ordered fallback" , 0.6 , false , Some ( "efficient" ) ) ,
1366+ ( "ordered no distribution" , 0.0 , true , Some ( "efficient" ) ) ,
1367+ ( "ordered provider error" , 0.0 , true , Some ( "efficient" ) ) ,
1368+ ( "ordered provider error" , 0.0 , false , None ) ,
1369+ ( "ordered invalid" , f64:: NAN , true , Some ( "efficient" ) ) ,
13471370 ] {
13481371 let mut config = settings. clone ( ) ;
1372+ let ordered = name. starts_with ( "ordered" ) ;
1373+ if ordered {
1374+ config. policy = Some ( ordered_policy. clone ( ) ) ;
1375+ }
13491376 if name == "missing candidate" {
13501377 config. candidates . remove ( "a" ) ;
13511378 }
@@ -1378,15 +1405,15 @@ mod tests {
13781405 calls. fetch_add ( 1 , Ordering :: SeqCst ) ;
13791406 assert_eq ! ( call. model, "judge" ) ;
13801407 assert_eq ! ( call. request. model, Some ( "judge" . into( ) ) ) ;
1381- assert_eq ! (
1382- call . request . context ,
1383- json! ( {
1384- "task ": expected_task ,
1385- "candidates" : [ "a" , "b" , "c" ] ,
1386- "comparison" : { "capable" : "a" , "efficient" : "b" } ,
1387- "evidence ": settings . evidence ,
1388- } )
1389- ) ;
1408+ let mut context = json ! ( {
1409+ "task" : expected_task ,
1410+ "candidates" : [ "a" , "b" , "c" ] ,
1411+ "evidence ": settings . evidence ,
1412+ } ) ;
1413+ if !ordered {
1414+ context [ "comparison" ] = json ! ( { "capable" : "a" , "efficient ": "b" } ) ;
1415+ }
1416+ assert_eq ! ( call . request . context , context ) ;
13901417 assert_eq ! ( call. request. questions. len( ) , 1 ) ;
13911418 let question = & call. request . questions [ "route" ] ;
13921419 let switchyard_protocol:: DecisionKind :: Choice { options } = & question. kind
@@ -1398,16 +1425,20 @@ mod tests {
13981425 . iter( )
13991426 . map( |option| option. id. as_str( ) )
14001427 . collect:: <Vec <_>>( ) ,
1401- [ "advantage" , "no_advantage" ]
1428+ if ordered {
1429+ vec![ "a" , "b" , "c" ]
1430+ } else {
1431+ vec![ "advantage" , "no_advantage" ]
1432+ }
14021433 ) ;
14031434 if let Some ( instructions) = override_instructions {
14041435 assert_eq ! ( & question. instructions, instructions) ;
1405- } else {
1436+ } else if !ordered {
14061437 for key in [ "setting" , "evidence" , "comparison" , "boundary" , "policy" ] {
14071438 assert ! ( question. instructions[ key] . as_str( ) . is_some( ) ) ;
14081439 }
14091440 }
1410- if name == "provider error" {
1441+ if name. ends_with ( "provider error" ) {
14111442 return call
14121443 . respond ( std:: future:: ready ( Err ( LibsyError :: client_call (
14131444 "judge" ,
@@ -1423,8 +1454,16 @@ mod tests {
14231454 DecisionValue :: Boolean ( BooleanEstimate :: Value ( true ) )
14241455 } else {
14251456 DecisionValue :: Choice {
1426- selected : "no_advantage" . into ( ) ,
1427- probabilities : ( name != "no distribution" ) . then ( || {
1457+ selected : if ordered { "b" } else { "no_advantage" } . into ( ) ,
1458+ probabilities : ( !name. ends_with ( "no distribution" ) ) . then ( || {
1459+ if ordered {
1460+ let third = if name == "ordered fallback" { 0.2 } else { 0.3 } ;
1461+ return BTreeMap :: from ( [
1462+ ( "a" . into ( ) , Probability ( score) ) ,
1463+ ( "b" . into ( ) , Probability ( 1.0 - score - third) ) ,
1464+ ( "c" . into ( ) , Probability ( third) ) ,
1465+ ] ) ;
1466+ }
14281467 BTreeMap :: from ( [
14291468 ( "advantage" . into ( ) , Probability ( score) ) ,
14301469 ( "no_advantage" . into ( ) , Probability ( 1.0 - score) ) ,
@@ -1452,7 +1491,16 @@ mod tests {
14521491 . await
14531492 }
14541493 } ;
1455- let models = Arc :: new ( RuntimeModels :: new ( runtime_models ( ) ) ) ;
1494+ let mut runtime = runtime_models ( ) ;
1495+ if ordered {
1496+ runtime. remove ( & Category :: Capable ) ;
1497+ runtime. remove ( & Category :: Efficient ) ;
1498+ runtime
1499+ . get_mut ( & Category :: Any )
1500+ . unwrap ( )
1501+ . push ( "third" . into ( ) ) ;
1502+ }
1503+ let models = Arc :: new ( RuntimeModels :: new ( runtime) ) ;
14561504 let result = drive ( router. clone ( ) , request. clone ( ) , models. clone ( ) , & serve) . await ;
14571505 assert_eq ! (
14581506 calls. load( Ordering :: SeqCst ) ,
@@ -1480,14 +1528,19 @@ mod tests {
14801528 . metadata
14811529 . and_then ( |metadata| metadata. evidence )
14821530 . expect ( "routing evidence" ) ;
1483- if matches ! ( name, "above" | "equal" | "below" ) {
1531+ if matches ! (
1532+ name,
1533+ "ordered first" | "ordered second" | "ordered fallback"
1534+ ) {
1535+ assert_eq ! ( evidence[ "verdict" ] , "ordered_cutoffs" ) ;
1536+ } else if matches ! ( name, "above" | "equal" | "below" ) {
14841537 assert_eq ! ( evidence[ "source" ] , "decision_classifier" ) ;
14851538 assert_eq ! ( evidence[ "verdict" ] , "relative_advantage" ) ;
14861539 assert_eq ! ( evidence[ "threshold" ] , settings. cutoff) ;
14871540 assert_eq ! ( evidence[ "score" ] , score) ;
14881541 } else {
14891542 let reason = match name {
1490- "provider error" => "client_error" ,
1543+ name if name . ends_with ( "provider error" ) => "client_error" ,
14911544 "dropped reply" => "call_error" ,
14921545 _ => "invalid_verdict" ,
14931546 } ;
@@ -1504,9 +1557,18 @@ mod tests {
15041557 "affinity should skip the judge: {name}"
15051558 ) ;
15061559 }
1507- for cutoff in [ -0.1 , 1.1 , f64:: NAN ] {
1560+ for ( cutoff, ordered) in [ -0.1 , 1.1 , f64:: NAN ]
1561+ . into_iter ( )
1562+ . flat_map ( |cutoff| [ ( cutoff, false ) , ( cutoff, true ) ] )
1563+ {
15081564 let mut config = settings. clone ( ) ;
1509- config. cutoff = cutoff;
1565+ if ordered {
1566+ let mut policy = ordered_policy. clone ( ) ;
1567+ policy. rules [ 0 ] . cutoff = cutoff;
1568+ config. policy = Some ( policy) ;
1569+ } else {
1570+ config. cutoff = cutoff;
1571+ }
15101572 assert ! (
15111573 LlmTaskClassifier :: new( LlmClassifierConfig :: Capability {
15121574 config: TaskClassifierConfig {
@@ -1517,6 +1579,18 @@ mod tests {
15171579 . is_err( )
15181580 ) ;
15191581 }
1582+ for field in [ "score" , "target" , "fallback" , "rules" ] {
1583+ let mut config = settings. clone ( ) ;
1584+ let mut policy = ordered_policy. clone ( ) ;
1585+ match field {
1586+ "score" => policy. rules [ 0 ] . score = "missing" . into ( ) ,
1587+ "target" => policy. rules [ 0 ] . target = "missing" . into ( ) ,
1588+ "fallback" => policy. fallback = "missing" . into ( ) ,
1589+ _ => policy. rules . clear ( ) ,
1590+ }
1591+ config. policy = Some ( policy) ;
1592+ assert ! ( config. validate( ) . is_err( ) ) ;
1593+ }
15201594 let mut duplicate = settings. clone ( ) ;
15211595 duplicate
15221596 . candidates
0 commit comments