Skip to main content

max / makenotwork

Extract the painhours model into a shared crate wam and GoingsOn need to rank on one scale so a single problems list can span both. Duplicating the tuning constants would let the two rankings drift, so the model moves to MNW/shared/painhours and both consume it. The crate is the scoring model and nothing else: no record type, no statuses, no storage. Age is the one input a caller cannot compute naively, since a resolved item must stop climbing, so age_weeks takes an explicit anchor. Ticket::age_anchor stays in wam, keyed on wam's status. Behavior is unchanged. db.rs loses band_buckets_by_score, which tested the moved pure function; the crate now tests those cutoffs next to the constants that set them, along with the PAINHOURS_K calibration claim and clamping that were previously only asserted in comments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 00:57 UTC
Commit: c582ca6c67db666cf6184c58242d849491d4b9c0
Parent: 42d668a
8 files changed, +755 insertions, -136 deletions
@@ -0,0 +1,364 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "android_system_properties"
7 + version = "0.1.5"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
10 + dependencies = [
11 + "libc",
12 + ]
13 +
14 + [[package]]
15 + name = "autocfg"
16 + version = "1.5.1"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
19 +
20 + [[package]]
21 + name = "bumpalo"
22 + version = "3.20.3"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
25 +
26 + [[package]]
27 + name = "cc"
28 + version = "1.4.0"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
31 + dependencies = [
32 + "find-msvc-tools",
33 + "shlex",
34 + ]
35 +
36 + [[package]]
37 + name = "cfg-if"
38 + version = "1.0.4"
39 + source = "registry+https://github.com/rust-lang/crates.io-index"
40 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
41 +
42 + [[package]]
43 + name = "chrono"
44 + version = "0.4.45"
45 + source = "registry+https://github.com/rust-lang/crates.io-index"
46 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
47 + dependencies = [
48 + "iana-time-zone",
49 + "js-sys",
50 + "num-traits",
51 + "serde",
52 + "wasm-bindgen",
53 + "windows-link",
54 + ]
55 +
56 + [[package]]
57 + name = "core-foundation-sys"
58 + version = "0.8.7"
59 + source = "registry+https://github.com/rust-lang/crates.io-index"
60 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
61 +
62 + [[package]]
63 + name = "find-msvc-tools"
64 + version = "0.1.9"
65 + source = "registry+https://github.com/rust-lang/crates.io-index"
66 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
67 +
68 + [[package]]
69 + name = "futures-core"
70 + version = "0.3.33"
71 + source = "registry+https://github.com/rust-lang/crates.io-index"
72 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
73 +
74 + [[package]]
75 + name = "futures-task"
76 + version = "0.3.33"
77 + source = "registry+https://github.com/rust-lang/crates.io-index"
78 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
79 +
80 + [[package]]
81 + name = "futures-util"
82 + version = "0.3.33"
83 + source = "registry+https://github.com/rust-lang/crates.io-index"
84 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
85 + dependencies = [
86 + "futures-core",
87 + "futures-task",
88 + "pin-project-lite",
89 + "slab",
90 + ]
91 +
92 + [[package]]
93 + name = "iana-time-zone"
94 + version = "0.1.65"
95 + source = "registry+https://github.com/rust-lang/crates.io-index"
96 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
97 + dependencies = [
98 + "android_system_properties",
99 + "core-foundation-sys",
100 + "iana-time-zone-haiku",
101 + "js-sys",
102 + "log",
103 + "wasm-bindgen",
104 + "windows-core",
105 + ]
106 +
107 + [[package]]
108 + name = "iana-time-zone-haiku"
109 + version = "0.1.2"
110 + source = "registry+https://github.com/rust-lang/crates.io-index"
111 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
112 + dependencies = [
113 + "cc",
114 + ]
115 +
116 + [[package]]
117 + name = "js-sys"
118 + version = "0.3.103"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
121 + dependencies = [
122 + "cfg-if",
123 + "futures-util",
124 + "wasm-bindgen",
125 + ]
126 +
127 + [[package]]
128 + name = "libc"
129 + version = "0.2.189"
130 + source = "registry+https://github.com/rust-lang/crates.io-index"
131 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
132 +
133 + [[package]]
134 + name = "log"
135 + version = "0.4.33"
136 + source = "registry+https://github.com/rust-lang/crates.io-index"
137 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
138 +
139 + [[package]]
140 + name = "num-traits"
141 + version = "0.2.19"
142 + source = "registry+https://github.com/rust-lang/crates.io-index"
143 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
144 + dependencies = [
145 + "autocfg",
146 + ]
147 +
148 + [[package]]
149 + name = "once_cell"
150 + version = "1.21.4"
151 + source = "registry+https://github.com/rust-lang/crates.io-index"
152 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
153 +
154 + [[package]]
155 + name = "painhours"
156 + version = "0.1.0"
157 + dependencies = [
158 + "chrono",
159 + "serde",
160 + ]
161 +
162 + [[package]]
163 + name = "pin-project-lite"
164 + version = "0.2.17"
165 + source = "registry+https://github.com/rust-lang/crates.io-index"
166 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
167 +
168 + [[package]]
169 + name = "proc-macro2"
170 + version = "1.0.107"
171 + source = "registry+https://github.com/rust-lang/crates.io-index"
172 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
173 + dependencies = [
174 + "unicode-ident",
175 + ]
176 +
177 + [[package]]
178 + name = "quote"
179 + version = "1.0.47"
180 + source = "registry+https://github.com/rust-lang/crates.io-index"
181 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
182 + dependencies = [
183 + "proc-macro2",
184 + ]
185 +
186 + [[package]]
187 + name = "rustversion"
188 + version = "1.0.23"
189 + source = "registry+https://github.com/rust-lang/crates.io-index"
190 + checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
191 +
192 + [[package]]
193 + name = "serde"
194 + version = "1.0.229"
195 + source = "registry+https://github.com/rust-lang/crates.io-index"
196 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
197 + dependencies = [
198 + "serde_core",
199 + "serde_derive",
200 + ]
201 +
202 + [[package]]
203 + name = "serde_core"
204 + version = "1.0.229"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
207 + dependencies = [
208 + "serde_derive",
209 + ]
210 +
211 + [[package]]
212 + name = "serde_derive"
213 + version = "1.0.229"
214 + source = "registry+https://github.com/rust-lang/crates.io-index"
215 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
216 + dependencies = [
217 + "proc-macro2",
218 + "quote",
219 + "syn 3.0.3",
220 + ]
221 +
222 + [[package]]
223 + name = "shlex"
224 + version = "2.0.1"
225 + source = "registry+https://github.com/rust-lang/crates.io-index"
226 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
227 +
228 + [[package]]
229 + name = "slab"
230 + version = "0.4.12"
231 + source = "registry+https://github.com/rust-lang/crates.io-index"
232 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
233 +
234 + [[package]]
235 + name = "syn"
236 + version = "2.0.119"
237 + source = "registry+https://github.com/rust-lang/crates.io-index"
238 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
239 + dependencies = [
240 + "proc-macro2",
241 + "quote",
242 + "unicode-ident",
243 + ]
244 +
245 + [[package]]
246 + name = "syn"
247 + version = "3.0.3"
248 + source = "registry+https://github.com/rust-lang/crates.io-index"
249 + checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
250 + dependencies = [
251 + "proc-macro2",
252 + "quote",
253 + "unicode-ident",
254 + ]
255 +
256 + [[package]]
257 + name = "unicode-ident"
258 + version = "1.0.24"
259 + source = "registry+https://github.com/rust-lang/crates.io-index"
260 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
261 +
262 + [[package]]
263 + name = "wasm-bindgen"
264 + version = "0.2.126"
265 + source = "registry+https://github.com/rust-lang/crates.io-index"
266 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
267 + dependencies = [
268 + "cfg-if",
269 + "once_cell",
270 + "rustversion",
271 + "wasm-bindgen-macro",
272 + "wasm-bindgen-shared",
273 + ]
274 +
275 + [[package]]
276 + name = "wasm-bindgen-macro"
277 + version = "0.2.126"
278 + source = "registry+https://github.com/rust-lang/crates.io-index"
279 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
280 + dependencies = [
281 + "quote",
282 + "wasm-bindgen-macro-support",
283 + ]
284 +
285 + [[package]]
286 + name = "wasm-bindgen-macro-support"
287 + version = "0.2.126"
288 + source = "registry+https://github.com/rust-lang/crates.io-index"
289 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
290 + dependencies = [
291 + "bumpalo",
292 + "proc-macro2",
293 + "quote",
294 + "syn 2.0.119",
295 + "wasm-bindgen-shared",
296 + ]
297 +
298 + [[package]]
299 + name = "wasm-bindgen-shared"
300 + version = "0.2.126"
301 + source = "registry+https://github.com/rust-lang/crates.io-index"
302 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
303 + dependencies = [
304 + "unicode-ident",
305 + ]
306 +
307 + [[package]]
308 + name = "windows-core"
309 + version = "0.62.2"
310 + source = "registry+https://github.com/rust-lang/crates.io-index"
311 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
312 + dependencies = [
313 + "windows-implement",
314 + "windows-interface",
315 + "windows-link",
316 + "windows-result",
317 + "windows-strings",
318 + ]
319 +
320 + [[package]]
321 + name = "windows-implement"
322 + version = "0.60.2"
323 + source = "registry+https://github.com/rust-lang/crates.io-index"
324 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
325 + dependencies = [
326 + "proc-macro2",
327 + "quote",
328 + "syn 2.0.119",
329 + ]
330 +
331 + [[package]]
332 + name = "windows-interface"
333 + version = "0.59.3"
334 + source = "registry+https://github.com/rust-lang/crates.io-index"
335 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
336 + dependencies = [
337 + "proc-macro2",
338 + "quote",
339 + "syn 2.0.119",
340 + ]
341 +
342 + [[package]]
343 + name = "windows-link"
344 + version = "0.2.1"
345 + source = "registry+https://github.com/rust-lang/crates.io-index"
346 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
347 +
348 + [[package]]
349 + name = "windows-result"
350 + version = "0.4.1"
351 + source = "registry+https://github.com/rust-lang/crates.io-index"
352 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
353 + dependencies = [
354 + "windows-link",
355 + ]
356 +
357 + [[package]]
358 + name = "windows-strings"
359 + version = "0.5.1"
360 + source = "registry+https://github.com/rust-lang/crates.io-index"
361 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
362 + dependencies = [
363 + "windows-link",
364 + ]
@@ -0,0 +1,42 @@
1 + [package]
2 + name = "painhours"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 + description = "The painhours urgency model: a 0-100 heat score from pain, scale, and age, plus the color band it buckets into. Shared by wam and GoingsOn."
7 +
8 + [dependencies]
9 + chrono = { version = "0.4", features = ["serde"] }
10 + serde = { version = "1", features = ["derive"] }
11 +
12 + [lints.rust]
13 + unused = "warn"
14 + unreachable_pub = "warn"
15 +
16 + [lints.clippy]
17 + pedantic = { level = "warn", priority = -1 }
18 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
19 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
20 + # else in `pedantic` stays a warning. Keep this block identical across repos.
21 + module_name_repetitions = "allow"
22 + # Doc lints. No docs-completeness push is underway.
23 + missing_errors_doc = "allow"
24 + missing_panics_doc = "allow"
25 + doc_markdown = "allow"
26 + # Numeric casts. Endemic and mostly intentional in size and byte math.
27 + cast_possible_truncation = "allow"
28 + cast_sign_loss = "allow"
29 + cast_precision_loss = "allow"
30 + cast_possible_wrap = "allow"
31 + cast_lossless = "allow"
32 + # Subjective structure and style nags. High churn, low signal.
33 + must_use_candidate = "allow"
34 + too_many_lines = "allow"
35 + struct_excessive_bools = "allow"
36 + similar_names = "allow"
37 + items_after_statements = "allow"
38 + single_match_else = "allow"
39 + # Frequent false-positives in TUI and router-heavy code.
40 + match_same_arms = "allow"
41 + unnecessary_wraps = "allow"
42 + type_complexity = "allow"
@@ -0,0 +1,43 @@
1 + # painhours
2 +
3 + The painhours urgency model for Rust: a 0-100 heat score computed from how much
4 + something hurts, how broadly it hits, and how long it has been sitting there.
5 +
6 + Nothing stores a priority. Rank by the score; use the band only for color.
7 +
8 + ## The model
9 +
10 + ```text
11 + raw = pain^1.5 * scale^2.0 * age_weeks^1.5
12 + painhours = round(100 * (1 - e^(-raw/266)))
13 + ```
14 +
15 + `pain` and `scale` are 1-5 guesstimates. Each exponent tunes one factor
16 + independently: `scale` drives the ranking, `pain` gives severity a secondary
17 + pull, and the exponent on age is the anti-starvation lever that decides how fast
18 + a narrow problem climbs rather than languishing forever.
19 +
20 + Band cutoffs on the score: Critical at 75, High at 50, Medium at 20, Low below.
21 +
22 + ## Use
23 +
24 + ```rust
25 + use chrono::Utc;
26 + use painhours::{Priority, age_weeks, painhours};
27 +
28 + // Age is measured to an anchor you choose. Pass `Utc::now()` while the item is
29 + // live; pass the resolution instant once it is terminal, so closed items stop
30 + // climbing the list.
31 + let weeks = age_weeks(created_at, Utc::now());
32 + let score = painhours(pain, scale, weeks);
33 + let band = Priority::from_painhours(score);
34 + ```
35 +
36 + The crate is the scoring model and nothing else. It has no opinion about what
37 + your record type is, what statuses it has, or where you store it.
38 +
39 + ## Consumers
40 +
41 + `MNW/wam` (tickets) and GoingsOn (problems), so one ranked list can span both.
42 + Changing a constant here reranks both, which is the point: duplicating the tuning
43 + would let the rankings drift.
@@ -0,0 +1,278 @@
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 + }
@@ -1068,6 +1068,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1068 1068 checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
1069 1069
1070 1070 [[package]]
1071 + name = "painhours"
1072 + version = "0.1.0"
1073 + dependencies = [
1074 + "chrono",
1075 + "serde",
1076 + ]
1077 +
1078 + [[package]]
1071 1079 name = "percent-encoding"
1072 1080 version = "2.3.2"
1073 1081 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1924,6 +1932,7 @@ dependencies = [
1924 1932 "clap",
1925 1933 "color-eyre",
1926 1934 "directories",
1935 + "painhours",
1927 1936 "reqwest",
1928 1937 "rusqlite",
1929 1938 "serde",
@@ -14,6 +14,7 @@ chrono = { version = "0.4", features = ["serde"] }
14 14 clap = { version = "4", features = ["derive"] }
15 15 color-eyre = "0.6"
16 16 directories = "6"
17 + painhours = { path = "../shared/painhours" }
17 18 rusqlite = { version = "0.39", features = ["bundled"] }
18 19 serde = { version = "1", features = ["derive"] }
19 20 serde_json = "1"
M wam/src/db.rs +2 -12
@@ -831,16 +831,6 @@ mod tests {
831 831 assert!(all[0].painhours() > all[1].painhours());
832 832 }
833 833
834 - #[test]
835 - fn band_buckets_by_score() {
836 - // Pure function: verify the color-band cutoffs.
837 - assert_eq!(Priority::from_painhours(0), Priority::Low);
838 - assert_eq!(Priority::from_painhours(19), Priority::Low);
839 - assert_eq!(Priority::from_painhours(20), Priority::Medium);
840 - assert_eq!(Priority::from_painhours(49), Priority::Medium);
841 - assert_eq!(Priority::from_painhours(50), Priority::High);
842 - assert_eq!(Priority::from_painhours(74), Priority::High);
843 - assert_eq!(Priority::from_painhours(75), Priority::Critical);
844 - assert_eq!(Priority::from_painhours(100), Priority::Critical);
845 - }
834 + // The color-band cutoffs are a pure function of the score and are tested in
835 + // the `painhours` crate, alongside the constants that set them.
846 836 }
M wam/src/types.rs +16 -124
@@ -1,114 +1,22 @@
1 - //! Core types: Priority, Status, Channel, Ticket.
1 + //! Core types: Status, Channel, Ticket.
2 2 //!
3 - //! Ticket urgency is a computed **painhours** score rather than a stored enum.
4 - //! Two 1-5 guesstimates are stored per ticket — `pain` (how much it hurts a hit
5 - //! user) and `scale` (how broadly it hits) — and combined with the bug's age as
6 - //! a power law:
3 + //! Ticket urgency is a computed **painhours** score rather than a stored enum:
4 + //! two 1-5 guesstimates, `pain` (how much it hurts a hit user) and `scale` (how
5 + //! broadly it hits), combined with the ticket's age as a power law. The model,
6 + //! its tuning constants, and the [`Priority`] color band all live in the shared
7 + //! `painhours` crate so wam and GoingsOn rank on one scale; see its crate docs.
7 8 //!
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 bug 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 bug climbs rather than
19 - //! languishing forever. It does not affect the ranking of same-age tickets.
20 - //!
21 - //! [`Priority`] survives only as a color *band* derived from the painhours
22 - //! score; tickets no longer store a priority directly.
23 -
24 - use std::fmt;
25 - use std::str::FromStr;
9 + //! What stays here is the part that is wam's own: which instant age is measured
10 + //! to. [`Ticket::age_anchor`] freezes it at resolution so terminal tickets stop
11 + //! climbing the list.
26 12
27 13 use chrono::{DateTime, Utc};
28 14 use serde::{Deserialize, Serialize};
29 15
30 - // -- painhours tuning ---------------------------------------------------------
31 -
32 - /// Exponent on `pain` (severity). Gives severity a secondary pull below scale.
33 - const PAIN_EXP: f64 = 1.5;
34 -
35 - /// Exponent on `scale` (breadth). The largest of the three, which is what makes
36 - /// scale the driving factor in the ranking.
37 - const SCALE_EXP: f64 = 2.0;
38 -
39 - /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale bugs
40 - /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K`
41 - /// it sets how long the lowest-possible bug (pain 1, scale 1) takes to force its
42 - /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3
43 - /// bug reaches Critical in ~1 month. Raise this (or lower K) to escalate faster.
44 - /// It has no effect on the ordering of tickets that share an age.
45 - const AGE_EXP: f64 = 1.5;
46 -
47 - /// Saturation constant for the painhours curve. Larger = the score climbs more
48 - /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the
49 - /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on
50 - /// day one — High, with headroom to reach Critical within a couple of weeks —
51 - /// and the 0-100 number keeps a usable gradient across a months-long backlog
52 - /// instead of saturating everything to 100.
53 - const PAINHOURS_K: f64 = 266.0;
16 + pub(crate) use painhours::{DEFAULT_FACTOR, Priority};
54 17
55 - /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down.
56 - const BAND_CRITICAL: u32 = 75;
57 - const BAND_HIGH: u32 = 50;
58 - const BAND_MEDIUM: u32 = 20;
59 -
60 - /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified.
61 - pub(crate) const DEFAULT_FACTOR: u8 = 3;
62 -
63 - // -- Priority (color band) ----------------------------------------------------
64 -
65 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66 - #[serde(rename_all = "lowercase")]
67 - pub(crate) enum Priority {
68 - Low,
69 - Medium,
70 - High,
71 - Critical,
72 - }
73 -
74 - impl Priority {
75 - /// Bucket a 0-100 painhours score into a color band.
76 - pub(crate) fn from_painhours(score: u32) -> Self {
77 - if score >= BAND_CRITICAL {
78 - Self::Critical
79 - } else if score >= BAND_HIGH {
80 - Self::High
81 - } else if score >= BAND_MEDIUM {
82 - Self::Medium
83 - } else {
84 - Self::Low
85 - }
86 - }
87 - }
88 -
89 - impl fmt::Display for Priority {
90 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 - f.write_str(match self {
92 - Self::Low => "low",
93 - Self::Medium => "medium",
94 - Self::High => "high",
95 - Self::Critical => "critical",
96 - })
97 - }
98 - }
99 -
100 - impl FromStr for Priority {
101 - type Err = String;
102 - fn from_str(s: &str) -> Result<Self, Self::Err> {
103 - match s.to_lowercase().as_str() {
104 - "low" => Ok(Self::Low),
105 - "medium" => Ok(Self::Medium),
106 - "high" => Ok(Self::High),
107 - "critical" => Ok(Self::Critical),
108 - _ => Err(format!("unknown priority: {s}")),
109 - }
110 - }
111 - }
18 + use std::fmt;
19 + use std::str::FromStr;
112 20
113 21 // -- Status -------------------------------------------------------------------
114 22
@@ -218,14 +126,7 @@ pub(crate) struct Ticket {
218 126 impl Ticket {
219 127 /// Human-readable age string (e.g. "3m", "2h", "5d").
220 128 pub(crate) fn age(&self) -> String {
221 - let dur = Utc::now() - self.created_at;
222 - if dur.num_days() > 0 {
223 - format!("{}d", dur.num_days())
224 - } else if dur.num_hours() > 0 {
225 - format!("{}h", dur.num_hours())
226 - } else {
227 - format!("{}m", dur.num_minutes().max(1))
228 - }
129 + painhours::age_label(self.created_at, Utc::now())
229 130 }
230 131
231 132 /// The instant against which age is measured: frozen at resolution once the
@@ -237,23 +138,14 @@ impl Ticket {
237 138 }
238 139 }
239 140
240 - /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so
241 - /// a brand-new ticket still contributes to its score.
141 + /// Age in whole weeks, measured to [`Self::age_anchor`].
242 142 pub(crate) fn age_weeks(&self) -> u32 {
243 - let secs = (self.age_anchor() - self.created_at).num_seconds().max(0);
244 - let weeks = (secs as f64 / (7.0 * 86_400.0)).ceil();
245 - (weeks as u32).max(1)
143 + painhours::age_weeks(self.created_at, self.age_anchor())
246 144 }
247 145
248 146 /// The painhours score: a 0-100 urgency number operators sort by.
249 - /// `round(100 * (1 - e^(-raw/K)))` where
250 - /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`.
251 147 pub(crate) fn painhours(&self) -> u32 {
252 - let pain = (self.pain.clamp(1, 5) as f64).powf(PAIN_EXP);
253 - let scale = (self.scale.clamp(1, 5) as f64).powf(SCALE_EXP);
254 - let age = (self.age_weeks() as f64).powf(AGE_EXP);
255 - let raw = pain * scale * age;
256 - (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32
148 + painhours::painhours(self.pain, self.scale, self.age_weeks())
257 149 }
258 150
259 151 /// Color band derived from the painhours score.