1use std::collections::{BTreeMap, BTreeSet};
7
8use rustev_contract::bounded::{ParseLimits, parse_bounded};
9use rustev_contract::canonical::{CanonicalError, tagged_digest};
10use rustev_contract::ids::ContentDigest;
11use rustev_contract::judgment::OutValue;
12use rustev_contract::limits::DESCRIPTOR_V1;
13use rustev_contract::{Document, DocumentError};
14use rustev_eval::config::AdapterRules;
15use rustev_eval::dataset::AdapterRef;
16use rustev_eval::measures::{MeasuredAdapter, Measures, TASK_ADAPTER_V2};
17use rustev_eval::metrics::TaskAdapter;
18use serde::{Deserialize, Serialize};
19
20pub const TASK_ADAPTER: &str = "rustev.task-adapter/1";
21const MAX_RULES: usize = 64;
22const MAX_LABELS: usize = 1024;
23const MAX_TEXT: usize = 256;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct TaskAdapterDoc {
28 pub schema: String,
29 pub name: String,
30 pub version: String,
31 pub labels: Labels,
32 pub rules: Vec<Rule>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub measures: Option<Measures>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct Labels {
44 pub values: Vec<String>,
45 pub prefixes: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(untagged)]
50pub enum Rule {
51 Label(LabelRule),
53 Param(ParamRule),
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct LabelRule {
61 pub action: String,
62 pub label: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ParamRule {
68 pub action: String,
69 pub param: String,
70 pub select: Select,
71 pub map: BTreeMap<String, String>,
72 pub otherwise: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum Select {
78 Enum,
79 Text,
80 Integer,
81 FirstRanked,
82}
83
84impl Document for TaskAdapterDoc {
88 const SCHEMA: &'static str = TASK_ADAPTER;
89 const LIMITS: ParseLimits = DESCRIPTOR_V1;
90 fn schema(&self) -> &str {
91 &self.schema
92 }
93
94 fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
95 let doc: Self = parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?;
96 if doc.schema != TASK_ADAPTER && doc.schema != TASK_ADAPTER_V2 {
97 return Err(DocumentError::Schema {
98 expected: TASK_ADAPTER_V2,
99 found: doc.schema.chars().take(80).collect(),
100 });
101 }
102 Ok(doc)
103 }
104
105 fn record_digest(&self) -> Result<ContentDigest, CanonicalError> {
106 let digest = tagged_digest(&self.schema, &self.record_canonical()?);
107 Ok(ContentDigest::parse(&digest).expect("a tagged digest is a content digest"))
108 }
109}
110
111impl Rule {
112 fn action(&self) -> &str {
113 match self {
114 Rule::Label(r) => &r.action,
115 Rule::Param(r) => &r.action,
116 }
117 }
118}
119
120fn text(what: &str, s: &str, max: usize) -> Result<(), String> {
121 if s.is_empty() || s.len() > max {
122 return Err(format!("{what} is 1 to {max} bytes"));
123 }
124 Ok(())
125}
126
127impl TaskAdapterDoc {
128 pub fn check(&self) -> Result<(), String> {
130 match (self.schema == TASK_ADAPTER_V2, &self.measures) {
131 (true, Some(m)) => m.check().map_err(|e| format!("measures: {e}"))?,
132 (true, None) => return Err(format!("{TASK_ADAPTER_V2} requires measures")),
133 (false, Some(_)) => return Err(format!("{TASK_ADAPTER} has no measures")),
134 (false, None) => {}
135 }
136 text("name", &self.name, 64)?;
137 text("version", &self.version, 64)?;
138 let l = &self.labels;
139 if l.values.is_empty() && l.prefixes.is_empty() {
140 return Err("labels need at least one value or prefix".into());
141 }
142 if l.values.len() + l.prefixes.len() > MAX_LABELS {
143 return Err(format!("at most {MAX_LABELS} label values and prefixes"));
144 }
145 for v in l.values.iter().chain(&l.prefixes) {
146 text("a label value or prefix", v, MAX_TEXT)?;
147 }
148 if self.rules.len() > MAX_RULES {
149 return Err(format!("at most {MAX_RULES} rules"));
150 }
151 let mut actions = BTreeSet::new();
152 for r in &self.rules {
153 text("an action", r.action(), MAX_TEXT)?;
154 if !actions.insert(r.action()) {
155 return Err(format!("two rules for action {:?}", r.action()));
156 }
157 match r {
158 Rule::Label(x) => text("a rule label", &x.label, MAX_TEXT)?,
159 Rule::Param(x) => {
160 text("a parameter name", &x.param, MAX_TEXT)?;
161 if x.map.len() > MAX_LABELS {
162 return Err(format!("a map has at most {MAX_LABELS} entries"));
163 }
164 for (k, v) in &x.map {
165 text("a map key", k, MAX_TEXT)?;
166 text("a map value", v, MAX_TEXT)?;
167 }
168 if let Some(o) = &x.otherwise {
169 text("otherwise", o, MAX_TEXT)?;
170 }
171 }
172 }
173 }
174 Ok(())
175 }
176}
177
178fn selected(select: Select, v: Option<&OutValue>) -> Option<String> {
181 match (select, v?) {
182 (Select::Enum, OutValue::Enum(s)) | (Select::Text, OutValue::Text(s)) => Some(s.clone()),
183 (Select::Integer, OutValue::Integer(i)) => Some(i.to_string()),
184 (Select::FirstRanked, OutValue::Ranking(r)) => {
185 r.entries.first().map(|e| e.candidate.clone())
186 }
187 _ => None,
188 }
189}
190
191impl TaskAdapter for TaskAdapterDoc {
192 fn adapter(&self) -> AdapterRef {
193 AdapterRef {
194 name: self.name.clone(),
195 version: self.version.clone(),
196 }
197 }
198
199 fn rules(&self) -> AdapterRules {
204 let bytes = self
205 .canonical()
206 .expect("a checked adapter document has a canonical form");
207 AdapterRules::Bound(
208 ContentDigest::parse(&tagged_digest(&self.schema, &bytes))
209 .expect("a tagged digest is a content digest"),
210 )
211 }
212
213 fn check_label(&self, label: &str) -> Result<(), String> {
214 let l = &self.labels;
215 if l.values.iter().any(|v| v == label)
216 || l.prefixes.iter().any(|p| label.starts_with(p.as_str()))
217 {
218 Ok(())
219 } else {
220 Err(format!("{label:?} is not a label of adapter {}", self.name))
221 }
222 }
223
224 fn correct(&self, action: &str, params: &BTreeMap<String, OutValue>, label: &str) -> bool {
225 let Some(rule) = self.rules.iter().find(|r| r.action() == action) else {
226 return false;
227 };
228 match rule {
229 Rule::Label(r) => r.label == label,
230 Rule::Param(r) => {
231 let Some(value) = selected(r.select, params.get(&r.param)) else {
232 return false;
233 };
234 let mapped = if r.map.is_empty() {
235 Some(value)
236 } else {
237 r.map.get(&value).cloned().or_else(|| r.otherwise.clone())
238 };
239 mapped.is_some_and(|m| m == label)
240 }
241 }
242 }
243}
244
245impl MeasuredAdapter for TaskAdapterDoc {
248 fn measures(&self) -> Measures {
249 self.measures.clone().unwrap_or_default()
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use rustev_contract::judgment::{RankEntry, Ranking};
257
258 fn support() -> TaskAdapterDoc {
259 TaskAdapterDoc {
260 schema: TASK_ADAPTER.into(),
261 name: "support-routing".into(),
262 version: "1".into(),
263 labels: Labels {
264 values: ["billing", "integration_defect", "account_access", "other"]
265 .map(String::from)
266 .to_vec(),
267 prefixes: vec![],
268 },
269 rules: vec![Rule::Param(ParamRule {
270 action: "route_ticket".into(),
271 param: "queue".into(),
272 select: Select::Enum,
273 map: [
274 ("billing", "billing"),
275 ("billing-priority", "billing"),
276 ("engineering", "integration_defect"),
277 ("identity", "account_access"),
278 ]
279 .into_iter()
280 .map(|(a, b)| (a.to_string(), b.to_string()))
281 .collect(),
282 otherwise: Some("other".into()),
283 })],
284 measures: None,
285 }
286 }
287
288 fn params(v: &[(&str, OutValue)]) -> BTreeMap<String, OutValue> {
289 v.iter().map(|(k, x)| (k.to_string(), x.clone())).collect()
290 }
291
292 #[test]
293 fn a_parameter_rule_maps_and_falls_back_to_otherwise() {
294 let a = support();
295 a.check().unwrap();
296 let q = |s: &str| params(&[("queue", OutValue::Enum(s.into()))]);
297 assert!(a.correct("route_ticket", &q("billing-priority"), "billing"));
298 assert!(!a.correct("route_ticket", &q("billing-priority"), "other"));
299 assert!(a.correct("route_ticket", &q("general"), "other"));
300 assert!(!a.correct("route_ticket", ¶ms(&[]), "other"));
302 let text = params(&[("queue", OutValue::Text("billing".into()))]);
303 assert!(!a.correct("route_ticket", &text, "billing"));
304 assert!(!a.correct("escalate", &q("billing"), "billing"));
305 let mut b = support();
307 if let Rule::Param(r) = &mut b.rules[0] {
308 r.otherwise = None;
309 }
310 assert!(!b.correct("route_ticket", &q("general"), "other"));
311 }
312
313 #[test]
314 fn label_rules_first_ranked_and_label_shapes() {
315 let a = TaskAdapterDoc {
316 schema: TASK_ADAPTER.into(),
317 name: "lodging".into(),
318 version: "1".into(),
319 labels: Labels {
320 values: vec!["none".into()],
321 prefixes: vec!["c-".into()],
322 },
323 rules: vec![
324 Rule::Param(ParamRule {
325 action: "present_ranked_lodging".into(),
326 param: "ranking".into(),
327 select: Select::FirstRanked,
328 map: BTreeMap::new(),
329 otherwise: None,
330 }),
331 Rule::Label(LabelRule {
332 action: "no_eligible_lodging".into(),
333 label: "none".into(),
334 }),
335 ],
336 measures: None,
337 };
338 a.check().unwrap();
339 let entry = |c: &str, p| RankEntry {
340 candidate: c.into(),
341 position: p,
342 score: 1.0,
343 components: BTreeMap::new(),
344 };
345 let ranking = |e: Vec<RankEntry>| {
346 params(&[(
347 "ranking",
348 OutValue::Ranking(Ranking {
349 entries: e,
350 excluded: vec![],
351 }),
352 )])
353 };
354 let r = ranking(vec![entry("c-1", 1), entry("c-2", 2)]);
355 assert!(a.correct("present_ranked_lodging", &r, "c-1"));
356 assert!(!a.correct("present_ranked_lodging", &r, "c-2"));
357 assert!(!a.correct("present_ranked_lodging", &ranking(vec![]), "c-1"));
358 assert!(a.correct("no_eligible_lodging", ¶ms(&[]), "none"));
359 assert!(!a.correct("no_eligible_lodging", ¶ms(&[]), "c-1"));
360 assert!(a.check_label("c-9").is_ok() && a.check_label("none").is_ok());
361 assert!(a.check_label("x-1").is_err());
362 }
363
364 #[test]
365 fn invalid_adapters_are_refused() {
366 let mut a = support();
367 a.rules.push(a.rules[0].clone());
368 assert!(a.check().unwrap_err().contains("two rules"));
369 let mut a = support();
370 a.labels.values.clear();
371 assert!(a.check().is_err());
372 let mut a = support();
373 a.name = "x".repeat(65);
374 assert!(a.check().is_err());
375 let bytes = br#"{"schema":"rustev.task-adapter/1","name":"n","version":"1","labels":{"values":["a"],"prefixes":[]},"rules":[{"action":"x","label":"a","extra":1}]}"#;
377 assert!(TaskAdapterDoc::parse(bytes).is_err());
378 }
379
380 #[test]
383 fn version_2_requires_measures_and_tags_its_digest() {
384 let v1 = support();
385 let mut v2 = support();
386 v2.schema = TASK_ADAPTER_V2.into();
387 assert!(v2.check().unwrap_err().contains("requires measures"));
388 v2.measures = Some(Measures::default());
389 v2.check().unwrap();
390 let mut v1_with = support();
391 v1_with.measures = Some(Measures::default());
392 assert!(v1_with.check().unwrap_err().contains("has no measures"));
393 let v1_bytes = v1.canonical().unwrap();
396 assert!(!String::from_utf8_lossy(&v1_bytes).contains("measures"));
397 let tagged = |schema: &str, bytes: &[u8]| {
398 AdapterRules::Bound(ContentDigest::parse(&tagged_digest(schema, bytes)).unwrap())
399 };
400 assert_eq!(v1.rules(), tagged(TASK_ADAPTER, &v1_bytes));
401 assert_eq!(
402 v2.rules(),
403 tagged(TASK_ADAPTER_V2, &v2.canonical().unwrap())
404 );
405 assert_eq!(
406 v2.record_digest().unwrap().to_string(),
407 tagged_digest(TASK_ADAPTER_V2, &v2.record_canonical().unwrap())
408 );
409 let round = TaskAdapterDoc::parse(&v2.canonical().unwrap()).unwrap();
411 assert_eq!(round, v2);
412 assert_eq!(round.measures(), Measures::default());
413 assert_eq!(v1.measures(), Measures::default());
414 let mut v3 = v2.clone();
415 v3.schema = "rustev.task-adapter/3".into();
416 assert!(TaskAdapterDoc::parse(&v3.canonical().unwrap()).is_err());
417 }
418}