//! The painhours urgency model. //! //! An item's urgency is a computed score rather than a stored enum. Two 1-5 //! guesstimates are supplied per item, `pain` (how much it hurts a hit user) and //! `scale` (how broadly it hits), and combined with the item's age as a power //! law: //! //! ```text //! raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP //! painhours = round(100 * (1 - e^(-raw/K))) (a 0-100 heat score) //! ``` //! //! Each exponent tunes one factor's influence independently: //! - `SCALE_EXP` is the largest, so `scale` is the driving factor: at equal age //! a widespread problem outranks a narrow one regardless of pain. //! - `PAIN_EXP` gives severity a secondary pull. //! - `AGE_EXP` sets how hard age escalates. Because age is unbounded, this is the //! anti-starvation lever: it decides how fast a low-scale problem climbs rather //! than languishing forever. It does not affect the ranking of same-age items. //! //! [`Priority`] survives only as a color *band* derived from the score. Nothing //! stores a priority directly. //! //! # Scope //! //! This crate is the scoring model and nothing else. It has no opinion about //! what an item is, what statuses it has, or where it is stored. Callers keep //! their own record type and hand its three inputs to [`painhours`]. //! //! Age is the one input a caller cannot compute naively, because a resolved item //! must stop climbing. [`age_weeks`] therefore takes an explicit anchor: the //! caller decides whether that is "now" (still open) or the instant the item was //! resolved (terminal). See its docs. //! //! # Consumers //! //! `MNW/wam` (tickets) and GoingsOn (problems), so one ranked list can span //! both. Changing a constant here reranks every consumer, which is the point: //! duplicating the tuning would let the two rankings drift. //! //! use std::fmt; use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // -- painhours tuning --------------------------------------------------------- /// Exponent on `pain` (severity). Gives severity a secondary pull below scale. const PAIN_EXP: f64 = 1.5; /// Exponent on `scale` (breadth). The largest of the three, which is what makes /// scale the driving factor in the ranking. const SCALE_EXP: f64 = 2.0; /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale items /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K` /// it sets how long the lowest-possible item (pain 1, scale 1) takes to force its /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3 /// item reaches Critical in ~1 month. Raise this (or lower K) to escalate faster. /// It has no effect on the ordering of items that share an age. const AGE_EXP: f64 = 1.5; /// Saturation constant for the painhours curve. Larger = the score climbs more /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on /// day one: High, with headroom to reach Critical within a couple of weeks, and /// the 0-100 number keeps a usable gradient across a months-long backlog instead /// of saturating everything to 100. const PAINHOURS_K: f64 = 266.0; /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down. const BAND_CRITICAL: u32 = 75; const BAND_HIGH: u32 = 50; const BAND_MEDIUM: u32 = 20; /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified. pub const DEFAULT_FACTOR: u8 = 3; /// Seconds in a week, the unit age is measured in. const SECS_PER_WEEK: f64 = 7.0 * 86_400.0; // -- Priority (color band) ---------------------------------------------------- /// The color band a painhours score falls into. /// /// Derived, never stored. Use it to color a row or to filter a list; sort by the /// score itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Priority { Low, Medium, High, Critical, } impl Priority { /// Bucket a 0-100 painhours score into a color band. pub fn from_painhours(score: u32) -> Self { if score >= BAND_CRITICAL { Self::Critical } else if score >= BAND_HIGH { Self::High } else if score >= BAND_MEDIUM { Self::Medium } else { Self::Low } } } impl fmt::Display for Priority { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Low => "low", Self::Medium => "medium", Self::High => "high", Self::Critical => "critical", }) } } impl FromStr for Priority { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "low" => Ok(Self::Low), "medium" => Ok(Self::Medium), "high" => Ok(Self::High), "critical" => Ok(Self::Critical), _ => Err(format!("unknown priority: {s}")), } } } // -- scoring ------------------------------------------------------------------ /// The painhours score: a 0-100 urgency number operators sort by. /// /// `round(100 * (1 - e^(-raw/K)))` where /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`. /// /// `pain` and `scale` are clamped to 1-5, so out-of-range guesstimates degrade /// to the nearest valid factor instead of producing a wild score. `age_weeks` /// comes from [`age_weeks`]. pub fn painhours(pain: u8, scale: u8, age_weeks: u32) -> u32 { let pain = (pain.clamp(1, 5) as f64).powf(PAIN_EXP); let scale = (scale.clamp(1, 5) as f64).powf(SCALE_EXP); let age = (age_weeks.max(1) as f64).powf(AGE_EXP); let raw = pain * scale * age; (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32 } /// The color band for a set of inputs, for callers that want the band without /// holding on to the score. pub fn band(pain: u8, scale: u8, age_weeks: u32) -> Priority { Priority::from_painhours(painhours(pain, scale, age_weeks)) } /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so a /// brand-new item still contributes to its score. /// /// `anchor` is the instant age is measured *to*, and it is the caller's decision: /// pass `Utc::now()` while the item is still live, and the instant it was /// resolved once it is terminal. Freezing the anchor on resolution is what stops /// closed items from climbing the list forever. pub fn age_weeks(created_at: DateTime, anchor: DateTime) -> u32 { let secs = (anchor - created_at).num_seconds().max(0); let weeks = (secs as f64 / SECS_PER_WEEK).ceil(); (weeks as u32).max(1) } /// Human-readable age string for display, e.g. `3m`, `2h`, `5d`. /// /// Coarsest unit that is non-zero, floored at `1m` so a just-created item does /// not render as `0m`. pub fn age_label(created_at: DateTime, now: DateTime) -> String { let dur = now - created_at; if dur.num_days() > 0 { format!("{}d", dur.num_days()) } else if dur.num_hours() > 0 { format!("{}h", dur.num_hours()) } else { format!("{}m", dur.num_minutes().max(1)) } } #[cfg(test)] mod tests { use super::*; use chrono::Duration; #[test] fn priority_bands() { assert_eq!(Priority::from_painhours(0), Priority::Low); assert_eq!(Priority::from_painhours(19), Priority::Low); assert_eq!(Priority::from_painhours(20), Priority::Medium); assert_eq!(Priority::from_painhours(49), Priority::Medium); assert_eq!(Priority::from_painhours(50), Priority::High); assert_eq!(Priority::from_painhours(74), Priority::High); assert_eq!(Priority::from_painhours(75), Priority::Critical); assert_eq!(Priority::from_painhours(100), Priority::Critical); } #[test] fn priority_roundtrips_through_string() { for p in [ Priority::Low, Priority::Medium, Priority::High, Priority::Critical, ] { assert_eq!(p.to_string().parse::().unwrap(), p); } assert!("nonsense".parse::().is_err()); } #[test] fn score_stays_in_range() { // Every corner of the input space, plus a very old item, stays 0-100. for pain in 1..=5 { for scale in 1..=5 { for weeks in [1, 4, 52, 5_000] { let s = painhours(pain, scale, weeks); assert!(s <= 100, "pain {pain} scale {scale} weeks {weeks} = {s}"); } } } } #[test] fn scale_outranks_pain_at_equal_age() { // SCALE_EXP > PAIN_EXP, so breadth drives the ranking. assert!(painhours(1, 5, 1) > painhours(5, 1, 1)); } #[test] fn score_climbs_with_age() { assert!(painhours(3, 3, 4) > painhours(3, 3, 1)); } #[test] fn fresh_blocker_reads_high() { // The calibration claim in PAINHOURS_K's docs: pain 5, scale 5, week one // lands in High with headroom below Critical. assert_eq!(band(5, 5, 1), Priority::High); } #[test] fn factors_clamp_instead_of_exploding() { assert_eq!(painhours(0, 3, 1), painhours(1, 3, 1)); assert_eq!(painhours(9, 3, 1), painhours(5, 3, 1)); assert_eq!(painhours(3, 3, 0), painhours(3, 3, 1)); } #[test] fn age_weeks_rounds_up_and_floors_at_one() { let created = DateTime::::from_timestamp(0, 0).unwrap(); assert_eq!(age_weeks(created, created), 1); assert_eq!(age_weeks(created, created + Duration::days(1)), 1); assert_eq!(age_weeks(created, created + Duration::days(7)), 1); assert_eq!(age_weeks(created, created + Duration::days(8)), 2); // A clock skewed backwards must not underflow. assert_eq!(age_weeks(created, created - Duration::days(30)), 1); } #[test] fn age_label_picks_the_coarsest_unit() { let created = DateTime::::from_timestamp(0, 0).unwrap(); assert_eq!(age_label(created, created), "1m"); assert_eq!(age_label(created, created + Duration::minutes(45)), "45m"); assert_eq!(age_label(created, created + Duration::hours(5)), "5h"); assert_eq!(age_label(created, created + Duration::days(3)), "3d"); } }