1use std::collections::{BTreeMap, BTreeSet};
6
7use rustev_contract::Document;
8use rustev_contract::bounded::ParseLimits;
9use rustev_contract::canonical::tagged_digest;
10use rustev_contract::ids::ContentDigest;
11use rustev_contract::judgment::OutValue;
12use rustev_contract::limits::DESCRIPTOR_V1;
13use rustev_eval::config::AdapterRules;
14use rustev_eval::dataset::AdapterRef;
15use rustev_eval::metrics::TaskAdapter;
16use serde::{Deserialize, Serialize};
17
18pub const TASK_ADAPTER: &str = "rustev.task-adapter/1";
19const MAX_RULES: usize = 64;
20const MAX_LABELS: usize = 1024;
21const MAX_TEXT: usize = 256;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct TaskAdapterDoc {
26 pub schema: String,
27 pub name: String,
28 pub version: String,
29 pub labels: Labels,
30 pub rules: Vec<Rule>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Labels {
38 pub values: Vec<String>,
39 pub prefixes: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum Rule {
45 Label(LabelRule),
47 Param(ParamRule),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct LabelRule {
55 pub action: String,
56 pub label: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct ParamRule {
62 pub action: String,
63 pub param: String,
64 pub select: Select,
65 pub map: BTreeMap<String, String>,
66 pub otherwise: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum Select {
72 Enum,
73 Text,
74 Integer,
75 FirstRanked,
76}
77
78impl Document for TaskAdapterDoc {
79 const SCHEMA: &'static str = TASK_ADAPTER;
80 const LIMITS: ParseLimits = DESCRIPTOR_V1;
81 fn schema(&self) -> &str {
82 &self.schema
83 }
84}
85
86impl Rule {
87 fn action(&self) -> &str {
88 match self {
89 Rule::Label(r) => &r.action,
90 Rule::Param(r) => &r.action,
91 }
92 }
93}
94
95fn text(what: &str, s: &str, max: usize) -> Result<(), String> {
96 if s.is_empty() || s.len() > max {
97 return Err(format!("{what} is 1 to {max} bytes"));
98 }
99 Ok(())
100}
101
102impl TaskAdapterDoc {
103 pub fn check(&self) -> Result<(), String> {
105 text("name", &self.name, 64)?;
106 text("version", &self.version, 64)?;
107 let l = &self.labels;
108 if l.values.is_empty() && l.prefixes.is_empty() {
109 return Err("labels need at least one value or prefix".into());
110 }
111 if l.values.len() + l.prefixes.len() > MAX_LABELS {
112 return Err(format!("at most {MAX_LABELS} label values and prefixes"));
113 }
114 for v in l.values.iter().chain(&l.prefixes) {
115 text("a label value or prefix", v, MAX_TEXT)?;
116 }
117 if self.rules.len() > MAX_RULES {
118 return Err(format!("at most {MAX_RULES} rules"));
119 }
120 let mut actions = BTreeSet::new();
121 for r in &self.rules {
122 text("an action", r.action(), MAX_TEXT)?;
123 if !actions.insert(r.action()) {
124 return Err(format!("two rules for action {:?}", r.action()));
125 }
126 match r {
127 Rule::Label(x) => text("a rule label", &x.label, MAX_TEXT)?,
128 Rule::Param(x) => {
129 text("a parameter name", &x.param, MAX_TEXT)?;
130 if x.map.len() > MAX_LABELS {
131 return Err(format!("a map has at most {MAX_LABELS} entries"));
132 }
133 for (k, v) in &x.map {
134 text("a map key", k, MAX_TEXT)?;
135 text("a map value", v, MAX_TEXT)?;
136 }
137 if let Some(o) = &x.otherwise {
138 text("otherwise", o, MAX_TEXT)?;
139 }
140 }
141 }
142 }
143 Ok(())
144 }
145}
146
147fn selected(select: Select, v: Option<&OutValue>) -> Option<String> {
150 match (select, v?) {
151 (Select::Enum, OutValue::Enum(s)) | (Select::Text, OutValue::Text(s)) => Some(s.clone()),
152 (Select::Integer, OutValue::Integer(i)) => Some(i.to_string()),
153 (Select::FirstRanked, OutValue::Ranking(r)) => {
154 r.entries.first().map(|e| e.candidate.clone())
155 }
156 _ => None,
157 }
158}
159
160impl TaskAdapter for TaskAdapterDoc {
161 fn adapter(&self) -> AdapterRef {
162 AdapterRef {
163 name: self.name.clone(),
164 version: self.version.clone(),
165 }
166 }
167
168 fn rules(&self) -> AdapterRules {
172 let bytes = self
173 .canonical()
174 .expect("a checked adapter document has a canonical form");
175 AdapterRules::Bound(
176 ContentDigest::parse(&tagged_digest(TASK_ADAPTER, &bytes))
177 .expect("a tagged digest is a content digest"),
178 )
179 }
180
181 fn check_label(&self, label: &str) -> Result<(), String> {
182 let l = &self.labels;
183 if l.values.iter().any(|v| v == label)
184 || l.prefixes.iter().any(|p| label.starts_with(p.as_str()))
185 {
186 Ok(())
187 } else {
188 Err(format!("{label:?} is not a label of adapter {}", self.name))
189 }
190 }
191
192 fn correct(&self, action: &str, params: &BTreeMap<String, OutValue>, label: &str) -> bool {
193 let Some(rule) = self.rules.iter().find(|r| r.action() == action) else {
194 return false;
195 };
196 match rule {
197 Rule::Label(r) => r.label == label,
198 Rule::Param(r) => {
199 let Some(value) = selected(r.select, params.get(&r.param)) else {
200 return false;
201 };
202 let mapped = if r.map.is_empty() {
203 Some(value)
204 } else {
205 r.map.get(&value).cloned().or_else(|| r.otherwise.clone())
206 };
207 mapped.is_some_and(|m| m == label)
208 }
209 }
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use rustev_contract::judgment::{RankEntry, Ranking};
217
218 fn support() -> TaskAdapterDoc {
219 TaskAdapterDoc {
220 schema: TASK_ADAPTER.into(),
221 name: "support-routing".into(),
222 version: "1".into(),
223 labels: Labels {
224 values: ["billing", "integration_defect", "account_access", "other"]
225 .map(String::from)
226 .to_vec(),
227 prefixes: vec![],
228 },
229 rules: vec![Rule::Param(ParamRule {
230 action: "route_ticket".into(),
231 param: "queue".into(),
232 select: Select::Enum,
233 map: [
234 ("billing", "billing"),
235 ("billing-priority", "billing"),
236 ("engineering", "integration_defect"),
237 ("identity", "account_access"),
238 ]
239 .into_iter()
240 .map(|(a, b)| (a.to_string(), b.to_string()))
241 .collect(),
242 otherwise: Some("other".into()),
243 })],
244 }
245 }
246
247 fn params(v: &[(&str, OutValue)]) -> BTreeMap<String, OutValue> {
248 v.iter().map(|(k, x)| (k.to_string(), x.clone())).collect()
249 }
250
251 #[test]
252 fn a_parameter_rule_maps_and_falls_back_to_otherwise() {
253 let a = support();
254 a.check().unwrap();
255 let q = |s: &str| params(&[("queue", OutValue::Enum(s.into()))]);
256 assert!(a.correct("route_ticket", &q("billing-priority"), "billing"));
257 assert!(!a.correct("route_ticket", &q("billing-priority"), "other"));
258 assert!(a.correct("route_ticket", &q("general"), "other"));
259 assert!(!a.correct("route_ticket", ¶ms(&[]), "other"));
261 let text = params(&[("queue", OutValue::Text("billing".into()))]);
262 assert!(!a.correct("route_ticket", &text, "billing"));
263 assert!(!a.correct("escalate", &q("billing"), "billing"));
264 let mut b = support();
266 if let Rule::Param(r) = &mut b.rules[0] {
267 r.otherwise = None;
268 }
269 assert!(!b.correct("route_ticket", &q("general"), "other"));
270 }
271
272 #[test]
273 fn label_rules_first_ranked_and_label_shapes() {
274 let a = TaskAdapterDoc {
275 schema: TASK_ADAPTER.into(),
276 name: "lodging".into(),
277 version: "1".into(),
278 labels: Labels {
279 values: vec!["none".into()],
280 prefixes: vec!["c-".into()],
281 },
282 rules: vec![
283 Rule::Param(ParamRule {
284 action: "present_ranked_lodging".into(),
285 param: "ranking".into(),
286 select: Select::FirstRanked,
287 map: BTreeMap::new(),
288 otherwise: None,
289 }),
290 Rule::Label(LabelRule {
291 action: "no_eligible_lodging".into(),
292 label: "none".into(),
293 }),
294 ],
295 };
296 a.check().unwrap();
297 let entry = |c: &str, p| RankEntry {
298 candidate: c.into(),
299 position: p,
300 score: 1.0,
301 components: BTreeMap::new(),
302 };
303 let ranking = |e: Vec<RankEntry>| {
304 params(&[(
305 "ranking",
306 OutValue::Ranking(Ranking {
307 entries: e,
308 excluded: vec![],
309 }),
310 )])
311 };
312 let r = ranking(vec![entry("c-1", 1), entry("c-2", 2)]);
313 assert!(a.correct("present_ranked_lodging", &r, "c-1"));
314 assert!(!a.correct("present_ranked_lodging", &r, "c-2"));
315 assert!(!a.correct("present_ranked_lodging", &ranking(vec![]), "c-1"));
316 assert!(a.correct("no_eligible_lodging", ¶ms(&[]), "none"));
317 assert!(!a.correct("no_eligible_lodging", ¶ms(&[]), "c-1"));
318 assert!(a.check_label("c-9").is_ok() && a.check_label("none").is_ok());
319 assert!(a.check_label("x-1").is_err());
320 }
321
322 #[test]
323 fn invalid_adapters_are_refused() {
324 let mut a = support();
325 a.rules.push(a.rules[0].clone());
326 assert!(a.check().unwrap_err().contains("two rules"));
327 let mut a = support();
328 a.labels.values.clear();
329 assert!(a.check().is_err());
330 let mut a = support();
331 a.name = "x".repeat(65);
332 assert!(a.check().is_err());
333 let bytes = br#"{"schema":"rustev.task-adapter/1","name":"n","version":"1","labels":{"values":["a"],"prefixes":[]},"rules":[{"action":"x","label":"a","extra":1}]}"#;
335 assert!(TaskAdapterDoc::parse(bytes).is_err());
336 }
337}