Skip to main content

max / makenotwork

12.7 KB · 326 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 ///
81 /// **Not the midpoint, deliberately.** 3 is the arithmetic middle of 1-5 and
82 /// reads as the neutral choice, but neutrality is a property of the curve, not
83 /// of the number: with the exponents above, a 3x3 item crosses
84 /// [`BAND_CRITICAL`] in **week 4**. Any ingest that omits the factors would
85 /// therefore promote itself to Critical within a month of arriving, without a
86 /// human ever having judged it. That is not a default, it is a fuse.
87 ///
88 /// 2 crosses Critical at ~10 weeks instead, which leaves room for a real triage
89 /// pass before the score starts making claims on someone's attention. An
90 /// undertriaged item still climbs, because anti-starvation is the point of the
91 /// age term; it just no longer outruns the triage it is waiting for.
92 ///
93 /// [`default_does_not_self_promote`](tests::default_does_not_self_promote) pins
94 /// the relationship, so changing an exponent or `PAINHOURS_K` cannot quietly
95 /// re-arm it.
96 pub const DEFAULT_FACTOR: u8 = 2;
97
98 /// The triage window [`DEFAULT_FACTOR`] is chosen against: an item carrying only
99 /// the defaults must not reach [`BAND_CRITICAL`] inside this many weeks.
100 ///
101 /// Eight weeks is two monthly audit passes, so a finding nobody has scored gets
102 /// looked at twice before the ranking starts insisting on it.
103 ///
104 /// Public because it is contract, not trivia: a caller deciding whether to score
105 /// a finding by hand is really asking how long the default buys it.
106 pub const DEFAULT_TRIAGE_WEEKS: u32 = 8;
107
108 /// Seconds in a week, the unit age is measured in.
109 const SECS_PER_WEEK: f64 = 7.0 * 86_400.0;
110
111 // -- Priority (color band) ----------------------------------------------------
112
113 /// The color band a painhours score falls into.
114 ///
115 /// Derived, never stored. Use it to color a row or to filter a list; sort by the
116 /// score itself.
117 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
118 #[serde(rename_all = "lowercase")]
119 pub enum Priority {
120 Low,
121 Medium,
122 High,
123 Critical,
124 }
125
126 impl Priority {
127 /// Bucket a 0-100 painhours score into a color band.
128 pub fn from_painhours(score: u32) -> Self {
129 if score >= BAND_CRITICAL {
130 Self::Critical
131 } else if score >= BAND_HIGH {
132 Self::High
133 } else if score >= BAND_MEDIUM {
134 Self::Medium
135 } else {
136 Self::Low
137 }
138 }
139 }
140
141 impl fmt::Display for Priority {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.write_str(match self {
144 Self::Low => "low",
145 Self::Medium => "medium",
146 Self::High => "high",
147 Self::Critical => "critical",
148 })
149 }
150 }
151
152 impl FromStr for Priority {
153 type Err = String;
154 fn from_str(s: &str) -> Result<Self, Self::Err> {
155 match s.to_lowercase().as_str() {
156 "low" => Ok(Self::Low),
157 "medium" => Ok(Self::Medium),
158 "high" => Ok(Self::High),
159 "critical" => Ok(Self::Critical),
160 _ => Err(format!("unknown priority: {s}")),
161 }
162 }
163 }
164
165 // -- scoring ------------------------------------------------------------------
166
167 /// The painhours score: a 0-100 urgency number operators sort by.
168 ///
169 /// `round(100 * (1 - e^(-raw/K)))` where
170 /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`.
171 ///
172 /// `pain` and `scale` are clamped to 1-5, so out-of-range guesstimates degrade
173 /// to the nearest valid factor instead of producing a wild score. `age_weeks`
174 /// comes from [`age_weeks`].
175 pub fn painhours(pain: u8, scale: u8, age_weeks: u32) -> u32 {
176 let pain = (pain.clamp(1, 5) as f64).powf(PAIN_EXP);
177 let scale = (scale.clamp(1, 5) as f64).powf(SCALE_EXP);
178 let age = (age_weeks.max(1) as f64).powf(AGE_EXP);
179 let raw = pain * scale * age;
180 (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32
181 }
182
183 /// The color band for a set of inputs, for callers that want the band without
184 /// holding on to the score.
185 pub fn band(pain: u8, scale: u8, age_weeks: u32) -> Priority {
186 Priority::from_painhours(painhours(pain, scale, age_weeks))
187 }
188
189 /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so a
190 /// brand-new item still contributes to its score.
191 ///
192 /// `anchor` is the instant age is measured *to*, and it is the caller's decision:
193 /// pass `Utc::now()` while the item is still live, and the instant it was
194 /// resolved once it is terminal. Freezing the anchor on resolution is what stops
195 /// closed items from climbing the list forever.
196 pub fn age_weeks(created_at: DateTime<Utc>, anchor: DateTime<Utc>) -> u32 {
197 let secs = (anchor - created_at).num_seconds().max(0);
198 let weeks = (secs as f64 / SECS_PER_WEEK).ceil();
199 (weeks as u32).max(1)
200 }
201
202 /// Human-readable age string for display, e.g. `3m`, `2h`, `5d`.
203 ///
204 /// Coarsest unit that is non-zero, floored at `1m` so a just-created item does
205 /// not render as `0m`.
206 pub fn age_label(created_at: DateTime<Utc>, now: DateTime<Utc>) -> String {
207 let dur = now - created_at;
208 if dur.num_days() > 0 {
209 format!("{}d", dur.num_days())
210 } else if dur.num_hours() > 0 {
211 format!("{}h", dur.num_hours())
212 } else {
213 format!("{}m", dur.num_minutes().max(1))
214 }
215 }
216
217 #[cfg(test)]
218 mod tests {
219 use super::*;
220 use chrono::Duration;
221
222 #[test]
223 fn priority_bands() {
224 assert_eq!(Priority::from_painhours(0), Priority::Low);
225 assert_eq!(Priority::from_painhours(19), Priority::Low);
226 assert_eq!(Priority::from_painhours(20), Priority::Medium);
227 assert_eq!(Priority::from_painhours(49), Priority::Medium);
228 assert_eq!(Priority::from_painhours(50), Priority::High);
229 assert_eq!(Priority::from_painhours(74), Priority::High);
230 assert_eq!(Priority::from_painhours(75), Priority::Critical);
231 assert_eq!(Priority::from_painhours(100), Priority::Critical);
232 }
233
234 #[test]
235 fn priority_roundtrips_through_string() {
236 for p in [
237 Priority::Low,
238 Priority::Medium,
239 Priority::High,
240 Priority::Critical,
241 ] {
242 assert_eq!(p.to_string().parse::<Priority>().unwrap(), p);
243 }
244 assert!("nonsense".parse::<Priority>().is_err());
245 }
246
247 #[test]
248 fn score_stays_in_range() {
249 // Every corner of the input space, plus a very old item, stays 0-100.
250 for pain in 1..=5 {
251 for scale in 1..=5 {
252 for weeks in [1, 4, 52, 5_000] {
253 let s = painhours(pain, scale, weeks);
254 assert!(s <= 100, "pain {pain} scale {scale} weeks {weeks} = {s}");
255 }
256 }
257 }
258 }
259
260 #[test]
261 fn scale_outranks_pain_at_equal_age() {
262 // SCALE_EXP > PAIN_EXP, so breadth drives the ranking.
263 assert!(painhours(1, 5, 1) > painhours(5, 1, 1));
264 }
265
266 #[test]
267 fn score_climbs_with_age() {
268 assert!(painhours(3, 3, 4) > painhours(3, 3, 1));
269 }
270
271 /// The invariant `DEFAULT_FACTOR` is chosen for: an unscored item must not
272 /// climb to Critical before anyone has had a fair chance to triage it.
273 ///
274 /// This is the guard on the whole tuning block, not just the default. Every
275 /// constant above feeds the crossing point, so raising an exponent or
276 /// lowering `PAINHOURS_K` far enough will fail here, which is the intended
277 /// warning: the escalation curve and the default have to be picked together.
278 #[test]
279 fn default_does_not_self_promote() {
280 let d = DEFAULT_FACTOR;
281 assert_ne!(
282 band(d, d, DEFAULT_TRIAGE_WEEKS),
283 Priority::Critical,
284 "an item carrying only the default factors reached Critical within \
285 {DEFAULT_TRIAGE_WEEKS} weeks, so undertriaged input promotes itself"
286 );
287 // And it must still climb eventually: anti-starvation is the reason the
288 // age term exists, so a default that never escalated would be its own bug.
289 assert_eq!(band(d, d, 52), Priority::Critical);
290 }
291
292 #[test]
293 fn fresh_blocker_reads_high() {
294 // The calibration claim in PAINHOURS_K's docs: pain 5, scale 5, week one
295 // lands in High with headroom below Critical.
296 assert_eq!(band(5, 5, 1), Priority::High);
297 }
298
299 #[test]
300 fn factors_clamp_instead_of_exploding() {
301 assert_eq!(painhours(0, 3, 1), painhours(1, 3, 1));
302 assert_eq!(painhours(9, 3, 1), painhours(5, 3, 1));
303 assert_eq!(painhours(3, 3, 0), painhours(3, 3, 1));
304 }
305
306 #[test]
307 fn age_weeks_rounds_up_and_floors_at_one() {
308 let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
309 assert_eq!(age_weeks(created, created), 1);
310 assert_eq!(age_weeks(created, created + Duration::days(1)), 1);
311 assert_eq!(age_weeks(created, created + Duration::days(7)), 1);
312 assert_eq!(age_weeks(created, created + Duration::days(8)), 2);
313 // A clock skewed backwards must not underflow.
314 assert_eq!(age_weeks(created, created - Duration::days(30)), 1);
315 }
316
317 #[test]
318 fn age_label_picks_the_coarsest_unit() {
319 let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
320 assert_eq!(age_label(created, created), "1m");
321 assert_eq!(age_label(created, created + Duration::minutes(45)), "45m");
322 assert_eq!(age_label(created, created + Duration::hours(5)), "5h");
323 assert_eq!(age_label(created, created + Duration::days(3)), "3d");
324 }
325 }
326