Skip to main content

mars_agents/routing/
acceptance.rs

1use std::collections::HashSet;
2
3use super::{CandidateAssessment, MatchEvidence, RoutingTrace};
4
5/// What evidence level to require for acceptance.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MatchPolicy {
8    /// Require Confirmed or Constrained slug evidence.
9    RequireSlugEvidence,
10    /// Accept Passthrough (harness may or may not support the model).
11    AllowPassthrough,
12    /// Accept anything — only check harness is installed.
13    InstalledOnly,
14}
15
16/// Why a route was rejected.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RejectionReason {
19    HarnessNotInstalled {
20        harness: String,
21    },
22    NoSlugEvidence {
23        harness: String,
24    },
25    AssessmentFailed {
26        harness: String,
27        skip_reason: Option<String>,
28    },
29}
30
31impl RejectionReason {
32    pub fn is_not_installed(&self) -> bool {
33        matches!(self, Self::HarnessNotInstalled { .. })
34    }
35
36    pub fn skip_reason(&self) -> Option<&str> {
37        match self {
38            Self::AssessmentFailed { skip_reason, .. } => skip_reason.as_deref(),
39            _ => None,
40        }
41    }
42
43    /// Whether the rejection is due to a provider constraint that makes the
44    /// harness fundamentally unable to run the requested model (e.g. codex
45    /// cannot run Anthropic models). Distinguished from `no_model_match` which
46    /// means the harness could potentially run the provider but doesn't
47    /// recognize the specific model slug.
48    pub fn is_provider_constraint(&self) -> bool {
49        self.skip_reason() == Some("provider_constraint_unsatisfied")
50    }
51}
52
53/// Check whether a routing trace meets the given acceptance policy.
54pub fn accept_route(
55    trace: &RoutingTrace,
56    installed: &HashSet<String>,
57    policy: MatchPolicy,
58) -> Result<(), RejectionReason> {
59    if !installed.contains(&trace.harness) {
60        return Err(RejectionReason::HarnessNotInstalled {
61            harness: trace.harness.clone(),
62        });
63    }
64
65    match policy {
66        MatchPolicy::InstalledOnly => Ok(()),
67        MatchPolicy::AllowPassthrough => match trace.match_evidence {
68            MatchEvidence::Confirmed | MatchEvidence::Constrained | MatchEvidence::Passthrough => {
69                Ok(())
70            }
71            MatchEvidence::None => Err(RejectionReason::NoSlugEvidence {
72                harness: trace.harness.clone(),
73            }),
74        },
75        MatchPolicy::RequireSlugEvidence => match trace.match_evidence {
76            MatchEvidence::Confirmed | MatchEvidence::Constrained => Ok(()),
77            MatchEvidence::Passthrough | MatchEvidence::None => {
78                Err(RejectionReason::NoSlugEvidence {
79                    harness: trace.harness.clone(),
80                })
81            }
82        },
83    }
84}
85
86/// Check whether a single candidate assessment is acceptable.
87pub fn accept_assessment(assessment: &CandidateAssessment) -> Result<(), RejectionReason> {
88    if !assessment.installed {
89        return Err(RejectionReason::HarnessNotInstalled {
90            harness: assessment.harness.clone(),
91        });
92    }
93
94    match assessment.match_evidence {
95        Some(_) => Ok(()),
96        None => Err(RejectionReason::AssessmentFailed {
97            harness: assessment.harness.clone(),
98            skip_reason: assessment.skip_reason.map(str::to_string),
99        }),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::routing::{RouteSource, SelectionKind};
107
108    fn installed(names: &[&str]) -> HashSet<String> {
109        names.iter().map(|name| (*name).to_string()).collect()
110    }
111
112    fn trace(harness: &str, match_evidence: MatchEvidence) -> RoutingTrace {
113        RoutingTrace {
114            source: RouteSource::Provider,
115            selection_kind: SelectionKind::Auto,
116            match_evidence,
117            harness: harness.to_string(),
118            harness_order_position: None,
119            candidates_tried: vec![harness.to_string()],
120            assessments: Vec::new(),
121            diagnostics: Vec::new(),
122        }
123    }
124
125    fn assessment(
126        harness: &str,
127        installed: bool,
128        match_evidence: Option<MatchEvidence>,
129        skip_reason: Option<&'static str>,
130    ) -> CandidateAssessment {
131        CandidateAssessment {
132            harness: harness.to_string(),
133            installed,
134            candidate_slugs: Vec::new(),
135            filtered_slugs: Vec::new(),
136            chosen_slug: None,
137            chosen_model: None,
138            match_evidence,
139            skip_reason,
140        }
141    }
142
143    #[test]
144    fn installed_only_accepts_any_evidence_when_installed() {
145        let installed = installed(&["pi"]);
146        for match_evidence in [
147            MatchEvidence::Confirmed,
148            MatchEvidence::Constrained,
149            MatchEvidence::Passthrough,
150            MatchEvidence::None,
151        ] {
152            assert_eq!(
153                accept_route(
154                    &trace("pi", match_evidence),
155                    &installed,
156                    MatchPolicy::InstalledOnly
157                ),
158                Ok(())
159            );
160        }
161    }
162
163    #[test]
164    fn any_policy_rejects_when_harness_not_installed() {
165        let installed = installed(&["codex"]);
166        for policy in [
167            MatchPolicy::InstalledOnly,
168            MatchPolicy::AllowPassthrough,
169            MatchPolicy::RequireSlugEvidence,
170        ] {
171            assert_eq!(
172                accept_route(&trace("pi", MatchEvidence::Confirmed), &installed, policy),
173                Err(RejectionReason::HarnessNotInstalled {
174                    harness: "pi".to_string()
175                })
176            );
177        }
178    }
179
180    #[test]
181    fn allow_passthrough_rejects_only_none_evidence() {
182        let installed = installed(&["pi"]);
183        for match_evidence in [
184            MatchEvidence::Confirmed,
185            MatchEvidence::Constrained,
186            MatchEvidence::Passthrough,
187        ] {
188            assert_eq!(
189                accept_route(
190                    &trace("pi", match_evidence),
191                    &installed,
192                    MatchPolicy::AllowPassthrough
193                ),
194                Ok(())
195            );
196        }
197        assert_eq!(
198            accept_route(
199                &trace("pi", MatchEvidence::None),
200                &installed,
201                MatchPolicy::AllowPassthrough
202            ),
203            Err(RejectionReason::NoSlugEvidence {
204                harness: "pi".to_string()
205            })
206        );
207    }
208
209    #[test]
210    fn require_slug_evidence_rejects_passthrough_and_none() {
211        let installed = installed(&["pi"]);
212        for match_evidence in [MatchEvidence::Confirmed, MatchEvidence::Constrained] {
213            assert_eq!(
214                accept_route(
215                    &trace("pi", match_evidence),
216                    &installed,
217                    MatchPolicy::RequireSlugEvidence
218                ),
219                Ok(())
220            );
221        }
222        for match_evidence in [MatchEvidence::Passthrough, MatchEvidence::None] {
223            assert_eq!(
224                accept_route(
225                    &trace("pi", match_evidence),
226                    &installed,
227                    MatchPolicy::RequireSlugEvidence
228                ),
229                Err(RejectionReason::NoSlugEvidence {
230                    harness: "pi".to_string()
231                })
232            );
233        }
234    }
235
236    #[test]
237    fn accept_assessment_rejects_not_installed() {
238        let rejection =
239            accept_assessment(&assessment("claude", false, None, Some("not_installed")))
240                .expect_err("assessment should reject when harness is not installed");
241        assert!(rejection.is_not_installed());
242        assert_eq!(
243            rejection,
244            RejectionReason::HarnessNotInstalled {
245                harness: "claude".to_string()
246            }
247        );
248    }
249
250    #[test]
251    fn accept_assessment_rejects_missing_evidence_with_skip_reason() {
252        assert_eq!(
253            accept_assessment(&assessment(
254                "codex",
255                true,
256                None,
257                Some("provider_constraint_unsatisfied")
258            )),
259            Err(RejectionReason::AssessmentFailed {
260                harness: "codex".to_string(),
261                skip_reason: Some("provider_constraint_unsatisfied".to_string())
262            })
263        );
264    }
265
266    #[test]
267    fn accept_assessment_accepts_any_present_evidence() {
268        for match_evidence in [
269            MatchEvidence::Confirmed,
270            MatchEvidence::Constrained,
271            MatchEvidence::Passthrough,
272            MatchEvidence::None,
273        ] {
274            assert_eq!(
275                accept_assessment(&assessment("pi", true, Some(match_evidence), None)),
276                Ok(())
277            );
278        }
279    }
280}