use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;
use rustev_contract::decimal::Decimal;
use rustev_contract::ids::{CalibrationId, PlanId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KindError(pub String);
impl fmt::Display for KindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for KindError {}
fn err<T>(msg: impl Into<String>) -> Result<T, KindError> {
Err(KindError(msg.into()))
}
fn masses_in_order(options: &[String], map: &BTreeMap<String, f64>) -> Result<Vec<f64>, KindError> {
if let Some(extra) = map.keys().find(|k| !options.contains(k)) {
return err(format!("undeclared option {extra:?}"));
}
options
.iter()
.map(|o| match map.get(o) {
Some(v) => Ok(*v),
None => err(format!("missing option {o:?}")),
})
.collect()
}
fn check_distribution(masses: &[f64], tolerance: f64) -> Result<(), KindError> {
if masses.is_empty() {
return err("empty distribution");
}
for (i, m) in masses.iter().enumerate() {
if !m.is_finite() {
return err(format!("non-finite mass at option {i}"));
}
if *m < 0.0 {
return err(format!("negative mass at option {i}"));
}
}
let sum: f64 = masses.iter().fold(0.0, |acc, m| acc + m);
if (sum - 1.0).abs() > tolerance {
return err(format!(
"masses sum to {sum}, outside tolerance {tolerance}"
));
}
Ok(())
}
fn argmax(values: &[f64]) -> usize {
let mut best = 0;
for (i, v) in values.iter().enumerate().skip(1) {
if *v > values[best] {
best = i;
}
}
best
}
pub(crate) fn softmax(z: &[f64]) -> Vec<f64> {
let max = z.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let e: Vec<f64> = z
.iter()
.map(|v| {
if *v == f64::NEG_INFINITY {
0.0
} else {
crate::math::exp(v - max)
}
})
.collect();
let sum: f64 = e.iter().fold(0.0, |acc, v| acc + v);
e.into_iter().map(|v| v / sum).collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct Distribution {
options: Vec<String>,
masses: Vec<f64>,
}
impl Distribution {
pub fn new(
options: &[String],
map: &BTreeMap<String, f64>,
tolerance: Decimal,
) -> Result<Self, KindError> {
let masses = masses_in_order(options, map)?;
check_distribution(&masses, tolerance.to_f64_nearest())?;
Ok(Self {
options: options.to_vec(),
masses,
})
}
pub fn from_logits(
options: &[String],
map: &BTreeMap<String, f64>,
tolerance: Decimal,
) -> Result<Self, KindError> {
let z = masses_in_order(options, map)?;
if let Some(i) = z.iter().position(|v| !v.is_finite()) {
return err(format!("non-finite logit at option {i}"));
}
let masses = softmax(&z);
check_distribution(&masses, tolerance.to_f64_nearest())?;
Ok(Self {
options: options.to_vec(),
masses,
})
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn masses(&self) -> &[f64] {
&self.masses
}
pub fn mass(&self, option: &str) -> Option<f64> {
self.options
.iter()
.position(|o| o == option)
.map(|i| self.masses[i])
}
pub fn top(&self) -> (&str, f64) {
let i = argmax(&self.masses);
(&self.options[i], self.masses[i])
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CalibratedProbability {
calibration: CalibrationId,
options: Vec<String>,
masses: Vec<f64>,
}
impl CalibratedProbability {
pub(crate) fn from_applied(
calibration: CalibrationId,
options: Vec<String>,
masses: Vec<f64>,
tolerance: f64,
) -> Result<Self, KindError> {
check_distribution(&masses, tolerance)?;
Ok(Self {
calibration,
options,
masses,
})
}
pub fn calibration(&self) -> &CalibrationId {
&self.calibration
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn masses(&self) -> &[f64] {
&self.masses
}
pub fn mass(&self, option: &str) -> Option<f64> {
self.options
.iter()
.position(|o| o == option)
.map(|i| self.masses[i])
}
pub fn top(&self) -> (&str, f64) {
let i = argmax(&self.masses);
(&self.options[i], self.masses[i])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedLabel {
options: Vec<String>,
index: usize,
}
impl SelectedLabel {
pub fn new(options: &[String], label: &str) -> Result<Self, KindError> {
match options.iter().position(|o| o == label) {
Some(index) => Ok(Self {
options: options.to_vec(),
index,
}),
None => err(format!("undeclared label {label:?}")),
}
}
pub fn label(&self) -> &str {
&self.options[self.index]
}
pub fn index(&self) -> usize {
self.index
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OrdinalLevel {
levels: Vec<String>,
repr: OrdinalRepr,
}
#[derive(Debug, Clone, PartialEq)]
enum OrdinalRepr {
Distribution(Vec<f64>),
Single(usize),
}
impl OrdinalLevel {
pub fn from_distribution(d: Distribution) -> Self {
Self {
levels: d.options,
repr: OrdinalRepr::Distribution(d.masses),
}
}
pub fn from_label(l: SelectedLabel) -> Self {
Self {
levels: l.options,
repr: OrdinalRepr::Single(l.index),
}
}
pub fn levels(&self) -> &[String] {
&self.levels
}
pub fn has_distribution(&self) -> bool {
matches!(self.repr, OrdinalRepr::Distribution(_))
}
pub fn expectation(&self) -> f64 {
match &self.repr {
OrdinalRepr::Distribution(m) => m
.iter()
.enumerate()
.fold(0.0, |acc, (i, p)| acc + (i as f64) * p),
OrdinalRepr::Single(i) => *i as f64,
}
}
pub fn top(&self) -> &str {
match &self.repr {
OrdinalRepr::Distribution(m) => &self.levels[argmax(m)],
OrdinalRepr::Single(i) => &self.levels[*i],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ScoreScope {
pub plan: PlanId,
pub step: String,
pub instance: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelScore {
scope: ScoreScope,
candidate: String,
score: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeMismatch;
impl ModelScore {
pub fn new(scope: ScoreScope, candidate: String, score: f64) -> Result<Self, KindError> {
if !score.is_finite() {
return err(format!("non-finite score for {candidate:?}"));
}
Ok(Self {
scope,
candidate,
score,
})
}
pub fn candidate(&self) -> &str {
&self.candidate
}
pub fn scope(&self) -> &ScoreScope {
&self.scope
}
pub fn cmp_within(&self, other: &ModelScore) -> Result<Ordering, ScopeMismatch> {
if self.scope != other.scope {
return Err(ScopeMismatch);
}
Ok(self.score.total_cmp(&other.score))
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RankPosition {
pub(crate) entries: Vec<(String, f64, BTreeMap<String, f64>)>,
}
impl RankPosition {
pub fn order(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|(c, _, _)| c.as_str())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn map(v: &[(&str, f64)]) -> BTreeMap<String, f64> {
v.iter().map(|(k, m)| (k.to_string(), *m)).collect()
}
fn tol() -> Decimal {
Decimal::parse("0.000001").unwrap()
}
#[test]
fn a_valid_distribution_is_accepted_as_supplied() {
let d = Distribution::new(&opts(&["a", "b"]), &map(&[("a", 0.25), ("b", 0.75)]), tol())
.unwrap();
assert_eq!(d.masses(), &[0.25, 0.75]);
assert_eq!(d.top(), ("b", 0.75));
}
#[test]
fn invalid_distributions_are_refused() {
let o = opts(&["a", "b"]);
for (bad, why) in [
(map(&[("a", f64::NAN), ("b", 1.0)]), "NaN"),
(map(&[("a", f64::INFINITY), ("b", 0.0)]), "infinite"),
(map(&[("a", -0.1), ("b", 1.1)]), "negative"),
(map(&[("a", 1.0)]), "missing key"),
(map(&[("a", 0.5), ("b", 0.5), ("c", 0.0)]), "undeclared key"),
(map(&[("a", 0.5), ("b", 0.6)]), "sum above tolerance"),
] {
assert!(Distribution::new(&o, &bad, tol()).is_err(), "{why}");
}
assert!(Distribution::new(&o, &map(&[("a", 0.5), ("b", 0.5000005)]), tol()).is_ok());
assert!(Distribution::new(&o, &map(&[("a", 0.5), ("b", 0.500002)]), tol()).is_err());
}
#[test]
fn logits_are_normalized_and_non_finite_logits_refused() {
let o = opts(&["a", "b"]);
let d = Distribution::from_logits(&o, &map(&[("a", 0.0), ("b", 0.0)]), tol()).unwrap();
assert_eq!(d.masses(), &[0.5, 0.5]);
let d = Distribution::from_logits(&o, &map(&[("a", 1000.0), ("b", 999.0)]), tol()).unwrap();
assert!(d.masses()[0] > d.masses()[1]);
assert!(
Distribution::from_logits(&o, &map(&[("a", f64::NAN), ("b", 0.0)]), tol()).is_err()
);
}
#[test]
fn ties_go_to_the_earlier_declared_option() {
let d =
Distribution::new(&opts(&["z", "a"]), &map(&[("z", 0.5), ("a", 0.5)]), tol()).unwrap();
assert_eq!(d.top().0, "z");
}
#[test]
fn a_label_must_be_declared() {
assert!(SelectedLabel::new(&opts(&["a"]), "a").is_ok());
assert!(SelectedLabel::new(&opts(&["a"]), "b").is_err());
}
#[test]
fn ordinal_expectation_is_an_index_summary() {
let d = Distribution::new(
&opts(&["calm", "frustrated", "very_angry"]),
&map(&[("calm", 0.2), ("frustrated", 0.3), ("very_angry", 0.5)]),
tol(),
)
.unwrap();
let o = OrdinalLevel::from_distribution(d);
assert!((o.expectation() - 1.3).abs() < 1e-12);
let l =
OrdinalLevel::from_label(SelectedLabel::new(&opts(&["low", "high"]), "high").unwrap());
assert_eq!(l.expectation(), 1.0);
assert!(!l.has_distribution());
}
#[test]
fn scores_compare_only_within_their_scope() {
let plan = PlanId::parse(&format!("sha256:{}", "0".repeat(64))).unwrap();
let s1 = ScoreScope {
plan: plan.clone(),
step: "r".into(),
instance: vec![],
};
let s2 = ScoreScope {
plan,
step: "other".into(),
instance: vec![],
};
let a = ModelScore::new(s1.clone(), "x".into(), 1.0).unwrap();
let b = ModelScore::new(s1, "y".into(), 2.0).unwrap();
let c = ModelScore::new(s2, "x".into(), 3.0).unwrap();
assert_eq!(a.cmp_within(&b), Ok(Ordering::Less));
assert_eq!(a.cmp_within(&c), Err(ScopeMismatch));
assert!(ModelScore::new(a.scope().clone(), "z".into(), f64::NAN).is_err());
}
}