use std::collections::{BTreeMap, BTreeSet};
use rustev_contract::Document;
use rustev_contract::bounded::ParseLimits;
use rustev_contract::canonical::tagged_digest;
use rustev_contract::ids::ContentDigest;
use rustev_contract::judgment::OutValue;
use rustev_contract::limits::DESCRIPTOR_V1;
use rustev_eval::config::AdapterRules;
use rustev_eval::dataset::AdapterRef;
use rustev_eval::metrics::TaskAdapter;
use serde::{Deserialize, Serialize};
pub const TASK_ADAPTER: &str = "rustev.task-adapter/1";
const MAX_RULES: usize = 64;
const MAX_LABELS: usize = 1024;
const MAX_TEXT: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskAdapterDoc {
pub schema: String,
pub name: String,
pub version: String,
pub labels: Labels,
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Labels {
pub values: Vec<String>,
pub prefixes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Rule {
Label(LabelRule),
Param(ParamRule),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelRule {
pub action: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParamRule {
pub action: String,
pub param: String,
pub select: Select,
pub map: BTreeMap<String, String>,
pub otherwise: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Select {
Enum,
Text,
Integer,
FirstRanked,
}
impl Document for TaskAdapterDoc {
const SCHEMA: &'static str = TASK_ADAPTER;
const LIMITS: ParseLimits = DESCRIPTOR_V1;
fn schema(&self) -> &str {
&self.schema
}
}
impl Rule {
fn action(&self) -> &str {
match self {
Rule::Label(r) => &r.action,
Rule::Param(r) => &r.action,
}
}
}
fn text(what: &str, s: &str, max: usize) -> Result<(), String> {
if s.is_empty() || s.len() > max {
return Err(format!("{what} is 1 to {max} bytes"));
}
Ok(())
}
impl TaskAdapterDoc {
pub fn check(&self) -> Result<(), String> {
text("name", &self.name, 64)?;
text("version", &self.version, 64)?;
let l = &self.labels;
if l.values.is_empty() && l.prefixes.is_empty() {
return Err("labels need at least one value or prefix".into());
}
if l.values.len() + l.prefixes.len() > MAX_LABELS {
return Err(format!("at most {MAX_LABELS} label values and prefixes"));
}
for v in l.values.iter().chain(&l.prefixes) {
text("a label value or prefix", v, MAX_TEXT)?;
}
if self.rules.len() > MAX_RULES {
return Err(format!("at most {MAX_RULES} rules"));
}
let mut actions = BTreeSet::new();
for r in &self.rules {
text("an action", r.action(), MAX_TEXT)?;
if !actions.insert(r.action()) {
return Err(format!("two rules for action {:?}", r.action()));
}
match r {
Rule::Label(x) => text("a rule label", &x.label, MAX_TEXT)?,
Rule::Param(x) => {
text("a parameter name", &x.param, MAX_TEXT)?;
if x.map.len() > MAX_LABELS {
return Err(format!("a map has at most {MAX_LABELS} entries"));
}
for (k, v) in &x.map {
text("a map key", k, MAX_TEXT)?;
text("a map value", v, MAX_TEXT)?;
}
if let Some(o) = &x.otherwise {
text("otherwise", o, MAX_TEXT)?;
}
}
}
}
Ok(())
}
}
fn selected(select: Select, v: Option<&OutValue>) -> Option<String> {
match (select, v?) {
(Select::Enum, OutValue::Enum(s)) | (Select::Text, OutValue::Text(s)) => Some(s.clone()),
(Select::Integer, OutValue::Integer(i)) => Some(i.to_string()),
(Select::FirstRanked, OutValue::Ranking(r)) => {
r.entries.first().map(|e| e.candidate.clone())
}
_ => None,
}
}
impl TaskAdapter for TaskAdapterDoc {
fn adapter(&self) -> AdapterRef {
AdapterRef {
name: self.name.clone(),
version: self.version.clone(),
}
}
fn rules(&self) -> AdapterRules {
let bytes = self
.canonical()
.expect("a checked adapter document has a canonical form");
AdapterRules::Bound(
ContentDigest::parse(&tagged_digest(TASK_ADAPTER, &bytes))
.expect("a tagged digest is a content digest"),
)
}
fn check_label(&self, label: &str) -> Result<(), String> {
let l = &self.labels;
if l.values.iter().any(|v| v == label)
|| l.prefixes.iter().any(|p| label.starts_with(p.as_str()))
{
Ok(())
} else {
Err(format!("{label:?} is not a label of adapter {}", self.name))
}
}
fn correct(&self, action: &str, params: &BTreeMap<String, OutValue>, label: &str) -> bool {
let Some(rule) = self.rules.iter().find(|r| r.action() == action) else {
return false;
};
match rule {
Rule::Label(r) => r.label == label,
Rule::Param(r) => {
let Some(value) = selected(r.select, params.get(&r.param)) else {
return false;
};
let mapped = if r.map.is_empty() {
Some(value)
} else {
r.map.get(&value).cloned().or_else(|| r.otherwise.clone())
};
mapped.is_some_and(|m| m == label)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustev_contract::judgment::{RankEntry, Ranking};
fn support() -> TaskAdapterDoc {
TaskAdapterDoc {
schema: TASK_ADAPTER.into(),
name: "support-routing".into(),
version: "1".into(),
labels: Labels {
values: ["billing", "integration_defect", "account_access", "other"]
.map(String::from)
.to_vec(),
prefixes: vec![],
},
rules: vec![Rule::Param(ParamRule {
action: "route_ticket".into(),
param: "queue".into(),
select: Select::Enum,
map: [
("billing", "billing"),
("billing-priority", "billing"),
("engineering", "integration_defect"),
("identity", "account_access"),
]
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect(),
otherwise: Some("other".into()),
})],
}
}
fn params(v: &[(&str, OutValue)]) -> BTreeMap<String, OutValue> {
v.iter().map(|(k, x)| (k.to_string(), x.clone())).collect()
}
#[test]
fn a_parameter_rule_maps_and_falls_back_to_otherwise() {
let a = support();
a.check().unwrap();
let q = |s: &str| params(&[("queue", OutValue::Enum(s.into()))]);
assert!(a.correct("route_ticket", &q("billing-priority"), "billing"));
assert!(!a.correct("route_ticket", &q("billing-priority"), "other"));
assert!(a.correct("route_ticket", &q("general"), "other"));
assert!(!a.correct("route_ticket", ¶ms(&[]), "other"));
let text = params(&[("queue", OutValue::Text("billing".into()))]);
assert!(!a.correct("route_ticket", &text, "billing"));
assert!(!a.correct("escalate", &q("billing"), "billing"));
let mut b = support();
if let Rule::Param(r) = &mut b.rules[0] {
r.otherwise = None;
}
assert!(!b.correct("route_ticket", &q("general"), "other"));
}
#[test]
fn label_rules_first_ranked_and_label_shapes() {
let a = TaskAdapterDoc {
schema: TASK_ADAPTER.into(),
name: "lodging".into(),
version: "1".into(),
labels: Labels {
values: vec!["none".into()],
prefixes: vec!["c-".into()],
},
rules: vec![
Rule::Param(ParamRule {
action: "present_ranked_lodging".into(),
param: "ranking".into(),
select: Select::FirstRanked,
map: BTreeMap::new(),
otherwise: None,
}),
Rule::Label(LabelRule {
action: "no_eligible_lodging".into(),
label: "none".into(),
}),
],
};
a.check().unwrap();
let entry = |c: &str, p| RankEntry {
candidate: c.into(),
position: p,
score: 1.0,
components: BTreeMap::new(),
};
let ranking = |e: Vec<RankEntry>| {
params(&[(
"ranking",
OutValue::Ranking(Ranking {
entries: e,
excluded: vec![],
}),
)])
};
let r = ranking(vec![entry("c-1", 1), entry("c-2", 2)]);
assert!(a.correct("present_ranked_lodging", &r, "c-1"));
assert!(!a.correct("present_ranked_lodging", &r, "c-2"));
assert!(!a.correct("present_ranked_lodging", &ranking(vec![]), "c-1"));
assert!(a.correct("no_eligible_lodging", ¶ms(&[]), "none"));
assert!(!a.correct("no_eligible_lodging", ¶ms(&[]), "c-1"));
assert!(a.check_label("c-9").is_ok() && a.check_label("none").is_ok());
assert!(a.check_label("x-1").is_err());
}
#[test]
fn invalid_adapters_are_refused() {
let mut a = support();
a.rules.push(a.rules[0].clone());
assert!(a.check().unwrap_err().contains("two rules"));
let mut a = support();
a.labels.values.clear();
assert!(a.check().is_err());
let mut a = support();
a.name = "x".repeat(65);
assert!(a.check().is_err());
let bytes = br#"{"schema":"rustev.task-adapter/1","name":"n","version":"1","labels":{"values":["a"],"prefixes":[]},"rules":[{"action":"x","label":"a","extra":1}]}"#;
assert!(TaskAdapterDoc::parse(bytes).is_err());
}
}