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
37/// Check whether a routing trace meets the given acceptance policy.
38pub fn accept_route(
39    trace: &RoutingTrace,
40    installed: &HashSet<String>,
41    policy: MatchPolicy,
42) -> Result<(), RejectionReason> {
43    if !installed.contains(&trace.harness) {
44        return Err(RejectionReason::HarnessNotInstalled {
45            harness: trace.harness.clone(),
46        });
47    }
48
49    match policy {
50        MatchPolicy::InstalledOnly => Ok(()),
51        MatchPolicy::AllowPassthrough => match trace.match_evidence {
52            MatchEvidence::Confirmed | MatchEvidence::Constrained | MatchEvidence::Passthrough => {
53                Ok(())
54            }
55            MatchEvidence::None => Err(RejectionReason::NoSlugEvidence {
56                harness: trace.harness.clone(),
57            }),
58        },
59        MatchPolicy::RequireSlugEvidence => match trace.match_evidence {
60            MatchEvidence::Confirmed | MatchEvidence::Constrained => Ok(()),
61            MatchEvidence::Passthrough | MatchEvidence::None => {
62                Err(RejectionReason::NoSlugEvidence {
63                    harness: trace.harness.clone(),
64                })
65            }
66        },
67    }
68}
69
70/// Check whether a single candidate assessment is acceptable.
71pub fn accept_assessment(assessment: &CandidateAssessment) -> Result<(), RejectionReason> {
72    if !assessment.installed {
73        return Err(RejectionReason::HarnessNotInstalled {
74            harness: assessment.harness.clone(),
75        });
76    }
77
78    match assessment.match_evidence {
79        Some(_) => Ok(()),
80        None => Err(RejectionReason::AssessmentFailed {
81            harness: assessment.harness.clone(),
82            skip_reason: assessment.skip_reason.map(str::to_string),
83        }),
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::routing::{RouteSource, SelectionKind};
91
92    fn installed(names: &[&str]) -> HashSet<String> {
93        names.iter().map(|name| (*name).to_string()).collect()
94    }
95
96    fn trace(harness: &str, match_evidence: MatchEvidence) -> RoutingTrace {
97        RoutingTrace {
98            source: RouteSource::Provider,
99            selection_kind: SelectionKind::Auto,
100            match_evidence,
101            harness: harness.to_string(),
102            harness_order_position: None,
103            candidates_tried: vec![harness.to_string()],
104            assessments: Vec::new(),
105            diagnostics: Vec::new(),
106        }
107    }
108
109    fn assessment(
110        harness: &str,
111        installed: bool,
112        match_evidence: Option<MatchEvidence>,
113        skip_reason: Option<&'static str>,
114    ) -> CandidateAssessment {
115        CandidateAssessment {
116            harness: harness.to_string(),
117            installed,
118            candidate_slugs: Vec::new(),
119            filtered_slugs: Vec::new(),
120            chosen_slug: None,
121            chosen_model: None,
122            match_evidence,
123            skip_reason,
124        }
125    }
126
127    #[test]
128    fn installed_only_accepts_any_evidence_when_installed() {
129        let installed = installed(&["pi"]);
130        for match_evidence in [
131            MatchEvidence::Confirmed,
132            MatchEvidence::Constrained,
133            MatchEvidence::Passthrough,
134            MatchEvidence::None,
135        ] {
136            assert_eq!(
137                accept_route(
138                    &trace("pi", match_evidence),
139                    &installed,
140                    MatchPolicy::InstalledOnly
141                ),
142                Ok(())
143            );
144        }
145    }
146
147    #[test]
148    fn any_policy_rejects_when_harness_not_installed() {
149        let installed = installed(&["codex"]);
150        for policy in [
151            MatchPolicy::InstalledOnly,
152            MatchPolicy::AllowPassthrough,
153            MatchPolicy::RequireSlugEvidence,
154        ] {
155            assert_eq!(
156                accept_route(&trace("pi", MatchEvidence::Confirmed), &installed, policy),
157                Err(RejectionReason::HarnessNotInstalled {
158                    harness: "pi".to_string()
159                })
160            );
161        }
162    }
163
164    #[test]
165    fn allow_passthrough_rejects_only_none_evidence() {
166        let installed = installed(&["pi"]);
167        for match_evidence in [
168            MatchEvidence::Confirmed,
169            MatchEvidence::Constrained,
170            MatchEvidence::Passthrough,
171        ] {
172            assert_eq!(
173                accept_route(
174                    &trace("pi", match_evidence),
175                    &installed,
176                    MatchPolicy::AllowPassthrough
177                ),
178                Ok(())
179            );
180        }
181        assert_eq!(
182            accept_route(
183                &trace("pi", MatchEvidence::None),
184                &installed,
185                MatchPolicy::AllowPassthrough
186            ),
187            Err(RejectionReason::NoSlugEvidence {
188                harness: "pi".to_string()
189            })
190        );
191    }
192
193    #[test]
194    fn require_slug_evidence_rejects_passthrough_and_none() {
195        let installed = installed(&["pi"]);
196        for match_evidence in [MatchEvidence::Confirmed, MatchEvidence::Constrained] {
197            assert_eq!(
198                accept_route(
199                    &trace("pi", match_evidence),
200                    &installed,
201                    MatchPolicy::RequireSlugEvidence
202                ),
203                Ok(())
204            );
205        }
206        for match_evidence in [MatchEvidence::Passthrough, MatchEvidence::None] {
207            assert_eq!(
208                accept_route(
209                    &trace("pi", match_evidence),
210                    &installed,
211                    MatchPolicy::RequireSlugEvidence
212                ),
213                Err(RejectionReason::NoSlugEvidence {
214                    harness: "pi".to_string()
215                })
216            );
217        }
218    }
219
220    #[test]
221    fn accept_assessment_rejects_not_installed() {
222        let rejection =
223            accept_assessment(&assessment("claude", false, None, Some("not_installed")))
224                .expect_err("assessment should reject when harness is not installed");
225        assert!(rejection.is_not_installed());
226        assert_eq!(
227            rejection,
228            RejectionReason::HarnessNotInstalled {
229                harness: "claude".to_string()
230            }
231        );
232    }
233
234    #[test]
235    fn accept_assessment_rejects_missing_evidence_with_skip_reason() {
236        assert_eq!(
237            accept_assessment(&assessment(
238                "codex",
239                true,
240                None,
241                Some("provider_constraint_unsatisfied")
242            )),
243            Err(RejectionReason::AssessmentFailed {
244                harness: "codex".to_string(),
245                skip_reason: Some("provider_constraint_unsatisfied".to_string())
246            })
247        );
248    }
249
250    #[test]
251    fn accept_assessment_accepts_any_present_evidence() {
252        for match_evidence in [
253            MatchEvidence::Confirmed,
254            MatchEvidence::Constrained,
255            MatchEvidence::Passthrough,
256            MatchEvidence::None,
257        ] {
258            assert_eq!(
259                accept_assessment(&assessment("pi", true, Some(match_evidence), None)),
260                Ok(())
261            );
262        }
263    }
264}