use std::collections::BTreeMap;
use std::sync::Arc;
use std::task::Poll;
use async_trait::async_trait;
use crate::classifier_agreement::{AgreementLog, AgreementReport, answer_margin, compare};
use crate::cost::{Accounting, AdapterId, CallRole, QuestionKind};
use crate::ports::{
Answer, Calibration, Classifier, ClassifierError, ClassifierProfile, Question,
validate_questions,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Thresholds {
pub calibration: Calibration,
pub min_confidence: f32,
pub min_margin: f32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RoutingMode {
#[default]
Off,
Pinned(AdapterId),
Measured,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoutingPolicy {
mode: RoutingMode,
evidence: Option<AgreementReport>,
thresholds: BTreeMap<AdapterId, Thresholds>,
min_questions: usize,
max_disagreement_rate: f64,
}
impl Default for RoutingPolicy {
fn default() -> Self {
Self::off()
}
}
impl RoutingPolicy {
#[must_use]
pub fn off() -> Self {
Self {
mode: RoutingMode::Off,
evidence: None,
thresholds: BTreeMap::new(),
min_questions: 20,
max_disagreement_rate: 0.05,
}
}
#[must_use]
pub fn pinned(adapter: AdapterId) -> Self {
Self {
mode: RoutingMode::Pinned(adapter),
..Self::off()
}
}
#[must_use]
pub fn measured(evidence: AgreementReport) -> Self {
Self {
mode: RoutingMode::Measured,
evidence: Some(evidence),
..Self::off()
}
}
#[must_use]
pub fn thresholds(mut self, adapter: AdapterId, thresholds: Thresholds) -> Self {
self.thresholds.insert(adapter, thresholds);
self
}
#[must_use]
pub const fn min_questions(mut self, n: usize) -> Self {
self.min_questions = n;
self
}
#[must_use]
pub const fn max_disagreement_rate(mut self, rate: f64) -> Self {
self.max_disagreement_rate = rate;
self
}
fn distrust(
&self,
kind: &QuestionKind,
thresholds: Option<&Thresholds>,
) -> Option<RouteReason> {
let Some(t) = thresholds else {
return Some(RouteReason::NoCalibration);
};
let Some(k) = self.evidence.as_ref().and_then(|e| e.kinds.get(kind)) else {
return Some(RouteReason::NoMeasurement);
};
let at = k.agreement_at(t.min_confidence, t.min_margin);
if at.questions < self.min_questions {
return Some(RouteReason::NotMeasuredEnough);
}
match at.disagreement_rate() {
Some(rate) if rate <= self.max_disagreement_rate => None,
_ => Some(RouteReason::DisagreementTooHigh),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RoutingError {
#[error(
"thresholds for {adapter} declare {declared:?} calibration, the adapter reports {reported:?}"
)]
CalibrationMismatch {
adapter: AdapterId,
declared: Calibration,
reported: Calibration,
},
#[error("the policy names {0}, which this router does not hold")]
UnknownAdapter(AdapterId),
#[error("the evidence compares {cheap} with {expensive}, not this router's adapters")]
EvidenceForOtherAdapters {
cheap: AdapterId,
expensive: AdapterId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteReason {
RoutingOff,
Pinned,
NoCalibration,
NoMeasurement,
NotMeasuredEnough,
DisagreementTooHigh,
CheapAccepted,
EscalatedLowConfidence,
EscalatedNarrowMargin,
EscalatedCheapFailed,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Routed {
pub adapter: AdapterId,
pub reason: RouteReason,
pub answer: Answer,
}
pub struct ShadowClassifier {
cheap: (AdapterId, Arc<dyn Classifier>),
expensive: (AdapterId, Arc<dyn Classifier>),
accounting: Accounting,
log: AgreementLog,
}
impl ShadowClassifier {
#[must_use]
pub fn new(
cheap: (AdapterId, Arc<dyn Classifier>),
expensive: (AdapterId, Arc<dyn Classifier>),
accounting: Accounting,
) -> Self {
let log = AgreementLog::new(cheap.0.clone(), expensive.0.clone());
Self {
cheap,
expensive,
accounting,
log,
}
}
#[must_use]
pub fn report(&self) -> AgreementReport {
self.log.report()
}
}
async fn join<A: Future, B: Future>(a: A, b: B) -> (A::Output, B::Output) {
let (mut a, mut b) = (std::pin::pin!(a), std::pin::pin!(b));
let (mut done_a, mut done_b) = (None, None);
std::future::poll_fn(|cx| {
if done_a.is_none()
&& let Poll::Ready(out) = a.as_mut().poll(cx)
{
done_a = Some(out);
}
if done_b.is_none()
&& let Poll::Ready(out) = b.as_mut().poll(cx)
{
done_b = Some(out);
}
match (done_a.take(), done_b.take()) {
(Some(a), Some(b)) => Poll::Ready((a, b)),
(a, b) => {
(done_a, done_b) = (a, b);
Poll::Pending
}
}
})
.await
}
#[async_trait]
impl Classifier for ShadowClassifier {
fn profile(&self) -> ClassifierProfile {
self.expensive.1.profile()
}
async fn ask(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
let (dear_id, dear) = &self.expensive;
let (cheap_id, cheap) = &self.cheap;
let serve = async {
let answers = dear.ask(state, questions).await;
let answered = answers.as_ref().ok();
let profile = dear.profile();
self.accounting.record(
dear_id,
&profile,
state,
questions,
answered,
CallRole::Served,
);
answers
};
let measure = async {
let answers = cheap.ask(state, questions).await;
let answered = answers.as_ref().ok();
let profile = cheap.profile();
self.accounting.record(
cheap_id,
&profile,
state,
questions,
answered,
CallRole::Shadow,
);
answers
};
let (served, shadow) = join(serve, measure).await;
if let Err(err) = &shadow {
tracing::debug!(adapter = %cheap_id, error = %err, "shadow classifier failed");
}
let served = match served {
Ok(answers) => answers,
Err(err) => {
compare(&self.log, questions, None, None, &BTreeMap::new(), None);
return Err(err);
}
};
compare(
&self.log,
questions,
shadow.as_ref().ok(),
Some(&served),
&BTreeMap::new(),
None,
);
Ok(served)
}
}
pub struct RoutingClassifier {
cheap: (AdapterId, Arc<dyn Classifier>),
expensive: (AdapterId, Arc<dyn Classifier>),
policy: RoutingPolicy,
accounting: Accounting,
}
impl RoutingClassifier {
pub fn new(
cheap: (AdapterId, Arc<dyn Classifier>),
expensive: (AdapterId, Arc<dyn Classifier>),
policy: RoutingPolicy,
accounting: Accounting,
) -> Result<Self, RoutingError> {
let held = |id: &AdapterId| {
[&cheap, &expensive]
.into_iter()
.find(|(held, _)| held == id)
};
if let RoutingMode::Pinned(id) = &policy.mode
&& held(id).is_none()
{
return Err(RoutingError::UnknownAdapter(id.clone()));
}
for (id, t) in &policy.thresholds {
let (_, adapter) = held(id).ok_or_else(|| RoutingError::UnknownAdapter(id.clone()))?;
let reported = adapter.profile().calibration;
if reported != t.calibration {
return Err(RoutingError::CalibrationMismatch {
adapter: id.clone(),
declared: t.calibration,
reported,
});
}
}
if policy.mode == RoutingMode::Measured
&& let Some(e) = &policy.evidence
&& (e.cheap != cheap.0 || e.expensive != expensive.0)
{
return Err(RoutingError::EvidenceForOtherAdapters {
cheap: e.cheap.clone(),
expensive: e.expensive.clone(),
});
}
Ok(Self {
cheap,
expensive,
policy,
accounting,
})
}
pub async fn ask_routed(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Routed>, ClassifierError> {
let (target, reason) = match &self.policy.mode {
RoutingMode::Measured => return self.ask_measured(state, questions).await,
RoutingMode::Off => (&self.expensive, RouteReason::RoutingOff),
RoutingMode::Pinned(id) if *id == self.cheap.0 => (&self.cheap, RouteReason::Pinned),
RoutingMode::Pinned(_) => (&self.expensive, RouteReason::Pinned),
};
let answers = target.1.ask(state, questions).await;
self.accounting.record(
&target.0,
&target.1.profile(),
state,
questions,
answers.as_ref().ok(),
CallRole::Served,
);
Ok(answers?
.into_iter()
.map(|(id, answer)| {
(
id,
Routed {
adapter: target.0.clone(),
reason,
answer,
},
)
})
.collect())
}
async fn ask_measured(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Routed>, ClassifierError> {
validate_questions(questions)?;
let (cheap_id, cheap) = &self.cheap;
let (dear_id, dear) = &self.expensive;
let thresholds = self.policy.thresholds.get(cheap_id);
let mut to_cheap = BTreeMap::new();
let mut to_dear = BTreeMap::new();
let mut reasons = BTreeMap::new();
for (id, question) in questions {
let kind = QuestionKind::new(id.as_str());
if let Some(reason) = self.policy.distrust(&kind, thresholds) {
reasons.insert(id.clone(), reason);
to_dear.insert(id.clone(), question.clone());
} else {
to_cheap.insert(id.clone(), question.clone());
}
}
let mut routed = BTreeMap::new();
if let Some(t) = thresholds.filter(|_| !to_cheap.is_empty()) {
let answers = cheap.ask(state, &to_cheap).await;
if let Err(err) = &answers {
tracing::debug!(adapter = %cheap_id, error = %err, "cheap classifier failed; escalating");
}
for (id, question) in &to_cheap {
let answer = answers.as_ref().ok().and_then(|a| a.get(id));
match (judge(answer, t), answer) {
(RouteReason::CheapAccepted, Some(answer)) => {
let reason = RouteReason::CheapAccepted;
let adapter = cheap_id.clone();
let answer = answer.clone();
routed.insert(
id.clone(),
Routed {
adapter,
reason,
answer,
},
);
}
(reason, _) => {
reasons.insert(id.clone(), reason);
to_dear.insert(id.clone(), question.clone());
}
}
}
let role = if routed.is_empty() {
CallRole::Discarded
} else {
CallRole::Served
};
let answered = answers.as_ref().ok();
let profile = cheap.profile();
self.accounting
.record(cheap_id, &profile, state, &to_cheap, answered, role);
}
if !to_dear.is_empty() {
let answers = dear.ask(state, &to_dear).await;
self.accounting.record(
dear_id,
&dear.profile(),
state,
&to_dear,
answers.as_ref().ok(),
CallRole::Served,
);
for (id, answer) in answers? {
if let Some(&reason) = reasons.get(&id) {
let adapter = dear_id.clone();
routed.insert(
id,
Routed {
adapter,
reason,
answer,
},
);
}
}
}
Ok(routed)
}
}
fn judge(answer: Option<&Answer>, t: &Thresholds) -> RouteReason {
let Some(answer) = answer else {
return RouteReason::EscalatedCheapFailed;
};
let confident = answer.confidence >= t.min_confidence;
let decisive = answer_margin(answer) >= t.min_margin;
if !confident {
RouteReason::EscalatedLowConfidence
} else if !decisive {
RouteReason::EscalatedNarrowMargin
} else {
RouteReason::CheapAccepted
}
}
#[async_trait]
impl Classifier for RoutingClassifier {
fn profile(&self) -> ClassifierProfile {
match &self.policy.mode {
RoutingMode::Pinned(id) if *id == self.cheap.0 => self.cheap.1.profile(),
RoutingMode::Off | RoutingMode::Pinned(_) => self.expensive.1.profile(),
RoutingMode::Measured => {
let (cheap, dear) = (self.cheap.1.profile(), self.expensive.1.profile());
ClassifierProfile::new(
dear.calibration,
cheap.max_state_chars.min(dear.max_state_chars),
)
}
}
}
async fn ask(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
let routed = self.ask_routed(state, questions).await?;
Ok(routed.into_iter().map(|(id, r)| (id, r.answer)).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_noul_probability_escalates_on_its_unreported_complement() {
let answer = Answer::noul(true, BTreeMap::from([("true".to_owned(), 0.55)]));
let t = Thresholds {
calibration: Calibration::Classifier,
min_confidence: 0.5,
min_margin: 0.3,
};
assert_eq!(judge(Some(&answer), &t), RouteReason::EscalatedNarrowMargin);
let lenient = Thresholds {
min_margin: 0.05,
..t
};
assert_eq!(judge(Some(&answer), &lenient), RouteReason::CheapAccepted);
assert_eq!(judge(None, &t), RouteReason::EscalatedCheapFailed);
}
}