Skip to main content

max / makenotwork

10.2 KB · 279 lines History Blame Raw
1 //! The painhours urgency model.
2 //!
3 //! An item's urgency is a computed score rather than a stored enum. Two 1-5
4 //! guesstimates are supplied per item, `pain` (how much it hurts a hit user) and
5 //! `scale` (how broadly it hits), and combined with the item's age as a power
6 //! law:
7 //!
8 //! ```text
9 //! raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP
10 //! painhours = round(100 * (1 - e^(-raw/K))) (a 0-100 heat score)
11 //! ```
12 //!
13 //! Each exponent tunes one factor's influence independently:
14 //! - `SCALE_EXP` is the largest, so `scale` is the driving factor: at equal age
15 //! a widespread problem outranks a narrow one regardless of pain.
16 //! - `PAIN_EXP` gives severity a secondary pull.
17 //! - `AGE_EXP` sets how hard age escalates. Because age is unbounded, this is the
18 //! anti-starvation lever: it decides how fast a low-scale problem climbs rather
19 //! than languishing forever. It does not affect the ranking of same-age items.
20 //!
21 //! [`Priority`] survives only as a color *band* derived from the score. Nothing
22 //! stores a priority directly.
23 //!
24 //! # Scope
25 //!
26 //! This crate is the scoring model and nothing else. It has no opinion about
27 //! what an item is, what statuses it has, or where it is stored. Callers keep
28 //! their own record type and hand its three inputs to [`painhours`].
29 //!
30 //! Age is the one input a caller cannot compute naively, because a resolved item
31 //! must stop climbing. [`age_weeks`] therefore takes an explicit anchor: the
32 //! caller decides whether that is "now" (still open) or the instant the item was
33 //! resolved (terminal). See its docs.
34 //!
35 //! # Consumers
36 //!
37 //! `MNW/wam` (tickets) and GoingsOn (problems), so one ranked list can span
38 //! both. Changing a constant here reranks every consumer, which is the point:
39 //! duplicating the tuning would let the two rankings drift.
40 //!
41 //! <!-- wiki: wam-overview -->
42
43 use std::fmt;
44 use std::str::FromStr;
45
46 use chrono::{DateTime, Utc};
47 use serde::{Deserialize, Serialize};
48
49 // -- painhours tuning ---------------------------------------------------------
50
51 /// Exponent on `pain` (severity). Gives severity a secondary pull below scale.
52 const PAIN_EXP: f64 = 1.5;
53
54 /// Exponent on `scale` (breadth). The largest of the three, which is what makes
55 /// scale the driving factor in the ranking.
56 const SCALE_EXP: f64 = 2.0;
57
58 /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale items
59 /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K`
60 /// it sets how long the lowest-possible item (pain 1, scale 1) takes to force its
61 /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3
62 /// item reaches Critical in ~1 month. Raise this (or lower K) to escalate faster.
63 /// It has no effect on the ordering of items that share an age.
64 const AGE_EXP: f64 = 1.5;
65
66 /// Saturation constant for the painhours curve. Larger = the score climbs more
67 /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the
68 /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on
69 /// day one: High, with headroom to reach Critical within a couple of weeks, and
70 /// the 0-100 number keeps a usable gradient across a months-long backlog instead
71 /// of saturating everything to 100.
72 const PAINHOURS_K: f64 = 266.0;
73
74 /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down.
75 const BAND_CRITICAL: u32 = 75;
76 const BAND_HIGH: u32 = 50;
77 const BAND_MEDIUM: u32 = 20;
78
79 /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified.
80 pub const DEFAULT_FACTOR: u8 = 3;
81
82 /// Seconds in a week, the unit age is measured in.
83 const SECS_PER_WEEK: f64 = 7.0 * 86_400.0;
84
85 // -- Priority (color band) ----------------------------------------------------
86
87 /// The color band a painhours score falls into.
88 ///
89 /// Derived, never stored. Use it to color a row or to filter a list; sort by the
90 /// score itself.
91 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
92 #[serde(rename_all = "lowercase")]
93 pub enum Priority {
94 Low,
95 Medium,
96 High,
97 Critical,
98 }
99
100 impl Priority {
101 /// Bucket a 0-100 painhours score into a color band.
102 pub fn from_painhours(score: u32) -> Self {
103 if score >= BAND_CRITICAL {
104 Self::Critical
105 } else if score >= BAND_HIGH {
106 Self::High
107 } else if score >= BAND_MEDIUM {
108 Self::Medium
109 } else {
110 Self::Low
111 }
112 }
113 }
114
115 impl fmt::Display for Priority {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.write_str(match self {
118 Self::Low => "low",
119 Self::Medium => "medium",
120 Self::High => "high",
121 Self::Critical => "critical",
122 })
123 }
124 }
125
126 impl FromStr for Priority {
127 type Err = String;
128 fn from_str(s: &str) -> Result<Self, Self::Err> {
129 match s.to_lowercase().as_str() {
130 "low" => Ok(Self::Low),
131 "medium" => Ok(Self::Medium),
132 "high" => Ok(Self::High),
133 "critical" => Ok(Self::Critical),
134 _ => Err(format!("unknown priority: {s}")),
135 }
136 }
137 }
138
139 // -- scoring ------------------------------------------------------------------
140
141 /// The painhours score: a 0-100 urgency number operators sort by.
142 ///
143 /// `round(100 * (1 - e^(-raw/K)))` where
144 /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`.
145 ///
146 /// `pain` and `scale` are clamped to 1-5, so out-of-range guesstimates degrade
147 /// to the nearest valid factor instead of producing a wild score. `age_weeks`
148 /// comes from [`age_weeks`].
149 pub fn painhours(pain: u8, scale: u8, age_weeks: u32) -> u32 {
150 let pain = (pain.clamp(1, 5) as f64).powf(PAIN_EXP);
151 let scale = (scale.clamp(1, 5) as f64).powf(SCALE_EXP);
152 let age = (age_weeks.max(1) as f64).powf(AGE_EXP);
153 let raw = pain * scale * age;
154 (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32
155 }
156
157 /// The color band for a set of inputs, for callers that want the band without
158 /// holding on to the score.
159 pub fn band(pain: u8, scale: u8, age_weeks: u32) -> Priority {
160 Priority::from_painhours(painhours(pain, scale, age_weeks))
161 }
162
163 /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so a
164 /// brand-new item still contributes to its score.
165 ///
166 /// `anchor` is the instant age is measured *to*, and it is the caller's decision:
167 /// pass `Utc::now()` while the item is still live, and the instant it was
168 /// resolved once it is terminal. Freezing the anchor on resolution is what stops
169 /// closed items from climbing the list forever.
170 pub fn age_weeks(created_at: DateTime<Utc>, anchor: DateTime<Utc>) -> u32 {
171 let secs = (anchor - created_at).num_seconds().max(0);
172 let weeks = (secs as f64 / SECS_PER_WEEK).ceil();
173 (weeks as u32).max(1)
174 }
175
176 /// Human-readable age string for display, e.g. `3m`, `2h`, `5d`.
177 ///
178 /// Coarsest unit that is non-zero, floored at `1m` so a just-created item does
179 /// not render as `0m`.
180 pub fn age_label(created_at: DateTime<Utc>, now: DateTime<Utc>) -> String {
181 let dur = now - created_at;
182 if dur.num_days() > 0 {
183 format!("{}d", dur.num_days())
184 } else if dur.num_hours() > 0 {
185 format!("{}h", dur.num_hours())
186 } else {
187 format!("{}m", dur.num_minutes().max(1))
188 }
189 }
190
191 #[cfg(test)]
192 mod tests {
193 use super::*;
194 use chrono::Duration;
195
196 #[test]
197 fn priority_bands() {
198 assert_eq!(Priority::from_painhours(0), Priority::Low);
199 assert_eq!(Priority::from_painhours(19), Priority::Low);
200 assert_eq!(Priority::from_painhours(20), Priority::Medium);
201 assert_eq!(Priority::from_painhours(49), Priority::Medium);
202 assert_eq!(Priority::from_painhours(50), Priority::High);
203 assert_eq!(Priority::from_painhours(74), Priority::High);
204 assert_eq!(Priority::from_painhours(75), Priority::Critical);
205 assert_eq!(Priority::from_painhours(100), Priority::Critical);
206 }
207
208 #[test]
209 fn priority_roundtrips_through_string() {
210 for p in [
211 Priority::Low,
212 Priority::Medium,
213 Priority::High,
214 Priority::Critical,
215 ] {
216 assert_eq!(p.to_string().parse::<Priority>().unwrap(), p);
217 }
218 assert!("nonsense".parse::<Priority>().is_err());
219 }
220
221 #[test]
222 fn score_stays_in_range() {
223 // Every corner of the input space, plus a very old item, stays 0-100.
224 for pain in 1..=5 {
225 for scale in 1..=5 {
226 for weeks in [1, 4, 52, 5_000] {
227 let s = painhours(pain, scale, weeks);
228 assert!(s <= 100, "pain {pain} scale {scale} weeks {weeks} = {s}");
229 }
230 }
231 }
232 }
233
234 #[test]
235 fn scale_outranks_pain_at_equal_age() {
236 // SCALE_EXP > PAIN_EXP, so breadth drives the ranking.
237 assert!(painhours(1, 5, 1) > painhours(5, 1, 1));
238 }
239
240 #[test]
241 fn score_climbs_with_age() {
242 assert!(painhours(3, 3, 4) > painhours(3, 3, 1));
243 }
244
245 #[test]
246 fn fresh_blocker_reads_high() {
247 // The calibration claim in PAINHOURS_K's docs: pain 5, scale 5, week one
248 // lands in High with headroom below Critical.
249 assert_eq!(band(5, 5, 1), Priority::High);
250 }
251
252 #[test]
253 fn factors_clamp_instead_of_exploding() {
254 assert_eq!(painhours(0, 3, 1), painhours(1, 3, 1));
255 assert_eq!(painhours(9, 3, 1), painhours(5, 3, 1));
256 assert_eq!(painhours(3, 3, 0), painhours(3, 3, 1));
257 }
258
259 #[test]
260 fn age_weeks_rounds_up_and_floors_at_one() {
261 let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
262 assert_eq!(age_weeks(created, created), 1);
263 assert_eq!(age_weeks(created, created + Duration::days(1)), 1);
264 assert_eq!(age_weeks(created, created + Duration::days(7)), 1);
265 assert_eq!(age_weeks(created, created + Duration::days(8)), 2);
266 // A clock skewed backwards must not underflow.
267 assert_eq!(age_weeks(created, created - Duration::days(30)), 1);
268 }
269
270 #[test]
271 fn age_label_picks_the_coarsest_unit() {
272 let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
273 assert_eq!(age_label(created, created), "1m");
274 assert_eq!(age_label(created, created + Duration::minutes(45)), "45m");
275 assert_eq!(age_label(created, created + Duration::hours(5)), "5h");
276 assert_eq!(age_label(created, created + Duration::days(3)), "3d");
277 }
278 }
279