Skip to main content

rustev_surface/
adapter.rs

1//! The declarative task adapter, `rustev.task-adapter/1` (spec 006, 3.8):
2//! how evaluation reads labels and decides whether a proposal is correct,
3//! without writing Rust. Version 2 (spec 041, 3.1.2) adds the measure
4//! declarations a version 3 evaluator configuration binds.
5
6use 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    /// Required in version 2 and absent in version 1, whose bytes and
34    /// rules digest are unchanged.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub measures: Option<Measures>,
37}
38
39/// A label is well formed when it equals one of `values` or starts with one
40/// of `prefixes`.
41#[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    /// The proposal is correct for exactly this label.
52    Label(LabelRule),
53    /// The proposal is correct when a selected parameter value, mapped,
54    /// equals the label.
55    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
84/// Either version parses; `SCHEMA` names version 1 only because the trait
85/// holds one constant. Digests are tagged with the document's own schema,
86/// so a version 1 digest is what it always was.
87impl 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    /// Validate the adapter (spec 006, 3.8.1; spec 041, 3.4.1).
129    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
178/// The value a parameter rule selects, or none when the parameter is
179/// missing or of another type.
180fn 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    /// Always bound: the document is the whole definition (spec 014,
200    /// 3.1.4), and the digest is over the canonical bytes `eval` writes as
201    /// `adapter.json`, tagged with the document's schema, so a version 2
202    /// digest covers `measures` (spec 041, 3.1.2).
203    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
245/// A version 1 document declares no measures; `eval` refuses it under a
246/// version 3 configuration before evaluation.
247impl 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        // A missing parameter, another type or another action is never correct.
301        assert!(!a.correct("route_ticket", &params(&[]), "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        // Without `otherwise`, an unmapped value is never correct.
306        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", &params(&[]), "none"));
359        assert!(!a.correct("no_eligible_lodging", &params(&[]), "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        // Unknown fields are refused by the parser.
376        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    /// Spec 041, 3.1.2: version 2 is version 1 plus required `measures`,
381    /// and its rules digest is tagged with its own schema.
382    #[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        // Version 1 bytes carry no `measures` member, so its digests are
394        // unchanged; version 2's differ.
395        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        // Both versions parse; another schema does not.
410        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}