1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct CoordinatorBudget {
6 pub max_cost_usd: Option<f64>,
8 pub max_latency_ms: Option<u64>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TiebreakerConfig {
15 pub model: String,
17 pub threshold: f64,
19 #[serde(default = "default_max_candidates")]
21 pub max_candidates: usize,
22}
23
24fn default_max_candidates() -> usize {
25 3
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DimensionScores {
31 pub capability_fit: f64,
33 pub cost_fit: f64,
35 pub latency_fit: f64,
37 pub trust_compatibility: f64,
39 pub historical_performance: f64,
41}
42
43impl DimensionScores {
44 pub fn composite(&self, weights: &DimensionWeights) -> f64 {
49 let total_weight = weights.capability_fit
50 + weights.cost_fit
51 + weights.latency_fit
52 + weights.trust_compatibility
53 + weights.historical_performance;
54
55 if total_weight == 0.0 {
56 return 0.0;
57 }
58
59 let weighted_sum = self.capability_fit * weights.capability_fit
60 + self.cost_fit * weights.cost_fit
61 + self.latency_fit * weights.latency_fit
62 + self.trust_compatibility * weights.trust_compatibility
63 + self.historical_performance * weights.historical_performance;
64
65 weighted_sum / total_weight
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct DimensionWeights {
72 pub capability_fit: f64,
73 pub cost_fit: f64,
74 pub latency_fit: f64,
75 pub trust_compatibility: f64,
76 pub historical_performance: f64,
77}
78
79impl Default for DimensionWeights {
80 fn default() -> Self {
81 Self {
82 capability_fit: 1.0,
83 cost_fit: 1.0,
84 latency_fit: 1.0,
85 trust_compatibility: 1.0,
86 historical_performance: 1.0,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ScoringResult {
94 pub agent_uri: String,
96 pub scores: DimensionScores,
98 pub composite: f64,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum DecisionMethod {
106 SingleCandidate,
108 Structured,
110 LlmTiebreaker,
112 NoCandidates,
114 BelowThreshold,
116 TiebreakerFailed,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct RejectedAgent {
123 pub agent_uri: String,
125 pub reason: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TokenUsage {
132 pub input: u32,
134 pub output: u32,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CoordinatorDecision {
141 pub selected: Option<String>,
143 pub method: DecisionMethod,
145 pub reasoning: Option<String>,
147 pub confidence: f64,
149 pub rejected: Vec<RejectedAgent>,
151 pub tiebreaker_tokens: Option<TokenUsage>,
153 pub tiebreaker_cost: Option<f64>,
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn composite_equal_weights() {
163 let scores = DimensionScores {
164 capability_fit: 0.8,
165 cost_fit: 0.6,
166 latency_fit: 0.7,
167 trust_compatibility: 1.0,
168 historical_performance: 0.9,
169 };
170 let weights = DimensionWeights::default();
171 let c = scores.composite(&weights);
172 assert!((c - 0.8).abs() < 1e-9, "composite={c}");
174 }
175
176 #[test]
177 fn composite_zero_weight_dimension_ignored() {
178 let scores = DimensionScores {
179 capability_fit: 1.0,
180 cost_fit: 0.0, latency_fit: 1.0,
182 trust_compatibility: 1.0,
183 historical_performance: 1.0,
184 };
185 let weights = DimensionWeights {
186 capability_fit: 1.0,
187 cost_fit: 0.0,
188 latency_fit: 1.0,
189 trust_compatibility: 1.0,
190 historical_performance: 1.0,
191 };
192 let c = scores.composite(&weights);
193 assert!((c - 1.0).abs() < 1e-9, "composite={c}");
195 }
196
197 #[test]
198 fn composite_all_zero_weights_returns_zero() {
199 let scores = DimensionScores {
200 capability_fit: 0.9,
201 cost_fit: 0.9,
202 latency_fit: 0.9,
203 trust_compatibility: 0.9,
204 historical_performance: 0.9,
205 };
206 let weights = DimensionWeights {
207 capability_fit: 0.0,
208 cost_fit: 0.0,
209 latency_fit: 0.0,
210 trust_compatibility: 0.0,
211 historical_performance: 0.0,
212 };
213 assert_eq!(scores.composite(&weights), 0.0);
214 }
215
216 #[test]
217 fn tiebreaker_config_default_max_candidates() {
218 let json = r#"{"model":"claude-3-5-haiku-20241022","threshold":0.05}"#;
219 let cfg: TiebreakerConfig = serde_json::from_str(json).unwrap();
220 assert_eq!(cfg.max_candidates, 3);
221 }
222
223 #[test]
224 fn decision_method_snake_case_roundtrip() {
225 let method = DecisionMethod::LlmTiebreaker;
226 let json = serde_json::to_string(&method).unwrap();
227 assert_eq!(json, r#""llm_tiebreaker""#);
228 let back: DecisionMethod = serde_json::from_str(&json).unwrap();
229 assert!(matches!(back, DecisionMethod::LlmTiebreaker));
230 }
231}