Skip to main content

jamjet_core/
coordinator.rs

1use serde::{Deserialize, Serialize};
2
3/// Budget constraints for a coordinator routing decision.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct CoordinatorBudget {
6    /// Maximum allowable cost in USD for the delegated call. None = unlimited.
7    pub max_cost_usd: Option<f64>,
8    /// Maximum allowable latency in milliseconds. None = unlimited.
9    pub max_latency_ms: Option<u64>,
10}
11
12/// Configuration for the LLM tiebreaker step when scores are too close to call.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TiebreakerConfig {
15    /// Model identifier to invoke for tiebreaking (e.g. "claude-3-5-haiku-20241022").
16    pub model: String,
17    /// Composite score difference below which two candidates are considered tied.
18    pub threshold: f64,
19    /// Maximum number of candidates forwarded to the tiebreaker LLM.
20    #[serde(default = "default_max_candidates")]
21    pub max_candidates: usize,
22}
23
24fn default_max_candidates() -> usize {
25    3
26}
27
28/// Per-dimension scores for a single agent candidate.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DimensionScores {
31    /// How well the agent's declared capabilities match the task requirements.
32    pub capability_fit: f64,
33    /// How well the agent's cost profile fits within the budget.
34    pub cost_fit: f64,
35    /// How well the agent's latency profile fits within the deadline.
36    pub latency_fit: f64,
37    /// Whether the agent's trust level is compatible with the caller's policy.
38    pub trust_compatibility: f64,
39    /// Agent's historical success rate and quality for similar tasks.
40    pub historical_performance: f64,
41}
42
43impl DimensionScores {
44    /// Compute the weighted composite score across all dimensions.
45    ///
46    /// Uses weight-normalised averaging so that zero-weight dimensions are
47    /// ignored rather than dragging the score toward zero.
48    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/// Weights applied to each scoring dimension when computing a composite score.
70#[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/// Scoring result for a single candidate agent.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ScoringResult {
94    /// URI identifying the candidate agent.
95    pub agent_uri: String,
96    /// Raw per-dimension scores.
97    pub scores: DimensionScores,
98    /// Pre-computed composite score.
99    pub composite: f64,
100}
101
102/// How the coordinator arrived at its final routing decision.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum DecisionMethod {
106    /// Only one candidate was available; chosen without further scoring.
107    SingleCandidate,
108    /// Winner determined by structured multi-dimensional scoring.
109    Structured,
110    /// Structured scoring produced a tie; an LLM tiebreaker broke it.
111    LlmTiebreaker,
112    /// No candidates were available; routing failed.
113    NoCandidates,
114    /// The highest-scoring candidate did not meet the minimum threshold.
115    BelowThreshold,
116    /// The LLM tiebreaker was invoked but failed (e.g. timeout, error).
117    TiebreakerFailed,
118}
119
120/// A candidate agent that was considered but not selected, with a reason.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct RejectedAgent {
123    /// URI of the rejected agent.
124    pub agent_uri: String,
125    /// Human-readable reason for rejection.
126    pub reason: String,
127}
128
129/// Token usage recorded for an LLM tiebreaker call.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TokenUsage {
132    /// Number of input (prompt) tokens consumed.
133    pub input: u32,
134    /// Number of output (completion) tokens generated.
135    pub output: u32,
136}
137
138/// The final routing decision produced by the coordinator.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CoordinatorDecision {
141    /// URI of the selected agent, or None if routing failed.
142    pub selected: Option<String>,
143    /// How the decision was made.
144    pub method: DecisionMethod,
145    /// Optional natural-language explanation of the decision (from tiebreaker or scoring).
146    pub reasoning: Option<String>,
147    /// Confidence in the decision on a [0.0, 1.0] scale.
148    pub confidence: f64,
149    /// Agents that were considered but rejected, with reasons.
150    pub rejected: Vec<RejectedAgent>,
151    /// Token usage for the tiebreaker LLM call, if one was made.
152    pub tiebreaker_tokens: Option<TokenUsage>,
153    /// Estimated cost in USD for the tiebreaker LLM call, if one was made.
154    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        // (0.8 + 0.6 + 0.7 + 1.0 + 0.9) / 5 = 4.0 / 5 = 0.8
173        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, // irrelevant when weight is 0
181            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        // 4.0 / 4.0 = 1.0 (cost_fit dimension dropped)
194        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}