Skip to main content

pma_core/
rank.rs

1//! Places tasks in the Eisenhower matrix and orders projects by what needs
2//! attention.
3//!
4//! Importance is `tier multiplier * priority weight` against a threshold.
5//! Urgency is sequencing, not decay: a deadline, a signal that blocks other
6//! work, or an explicit mark. Age orders the queue and fills `pma stale`; it
7//! does not make a task urgent. Formulas are in `docs/dev/design.md`.
8
9use std::cmp::Ordering;
10
11use crate::config::Config;
12use crate::scan::Ci;
13use crate::todo::Priority;
14
15/// Tolerance for comparing a product of weights against the threshold.
16const EPSILON: f64 = 1e-9;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Quadrant {
20    Q1,
21    Q2,
22    Q3,
23    Q4,
24}
25
26impl Quadrant {
27    pub const ALL: [Quadrant; 4] = [Quadrant::Q1, Quadrant::Q2, Quadrant::Q3, Quadrant::Q4];
28
29    pub fn title(self) -> &'static str {
30        match self {
31            Quadrant::Q1 => "Do right away",
32            Quadrant::Q2 => "Schedule for later",
33            Quadrant::Q3 => "Delegate or avoid",
34            Quadrant::Q4 => "Later",
35        }
36    }
37
38    /// `q1` to `q4`, in either case.
39    pub fn parse(s: &str) -> Option<Quadrant> {
40        Quadrant::ALL
41            .into_iter()
42            .find(|q| format!("{q:?}").eq_ignore_ascii_case(s))
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Urgency {
48    /// Days until due; negative when overdue.
49    Due(i64),
50    Tagged,
51    /// A failing check blocks everything else in that repository.
52    Signal,
53}
54
55/// A project as ranking sees it: what the last scan recorded, plus its tier.
56#[derive(Debug, Clone)]
57pub struct Project {
58    pub name: String,
59    pub tier: u8,
60    pub open: Vec<Priority>,
61    /// Days since the last commit that counts as activity; `None` if none found.
62    pub idle_days: Option<i64>,
63    pub ci: Ci,
64    pub dirty: i64,
65    /// `pma/` branches that no open run owns.
66    pub leftover: i64,
67    pub ahead: Option<i64>,
68    /// Outdated dependencies and days since measured; `None` if never measured
69    /// or not applicable.
70    pub deps: Option<(i64, i64)>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub struct Task {
75    pub project: String,
76    pub tier: u8,
77    pub priority: Priority,
78    pub text: String,
79    /// TODO.md line; `None` for signal tasks.
80    pub line: Option<i64>,
81    /// What `pma dispatch` works on: an item's key, or `ci`. `None` for
82    /// signals agents cannot act on.
83    pub key: Option<String>,
84    /// The nearest `###` heading above the item.
85    pub group: Option<String>,
86    /// Day number of `due:`.
87    pub due: Option<i64>,
88    pub tagged_urgent: bool,
89    pub signal_urgent: bool,
90    pub age_days: i64,
91    /// Whether an agent may take it: the `ci` and `deps` signals at any tier,
92    /// and items tagged `#agent`. Importance orders the queue; this decides
93    /// what is dispatched from it.
94    pub eligible: bool,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct Placed {
99    pub task: Task,
100    pub importance: f64,
101    pub urgency: Option<Urgency>,
102    pub quadrant: Quadrant,
103}
104
105pub fn urgency(cfg: &Config, t: &Task, today: i64) -> Option<Urgency> {
106    if let Some(due) = t.due
107        && due - today <= cfg.urgent_within
108    {
109        return Some(Urgency::Due(due - today));
110    }
111    if t.tagged_urgent {
112        return Some(Urgency::Tagged);
113    }
114    t.signal_urgent.then_some(Urgency::Signal)
115}
116
117/// Places every task, ordered by quadrant, then importance, then days to due
118/// (dated first), then age.
119pub fn place(cfg: &Config, tasks: Vec<Task>, today: i64) -> Vec<Placed> {
120    let mut placed: Vec<Placed> = tasks
121        .into_iter()
122        .map(|task| {
123            let importance = cfg.tier(task.tier) * cfg.priority(task.priority);
124            let urgency = urgency(cfg, &task, today);
125            let quadrant = match (
126                importance + EPSILON >= cfg.important_threshold,
127                urgency.is_some(),
128            ) {
129                (true, true) => Quadrant::Q1,
130                (true, false) => Quadrant::Q2,
131                (false, true) => Quadrant::Q3,
132                (false, false) => Quadrant::Q4,
133            };
134            Placed {
135                task,
136                importance,
137                urgency,
138                quadrant,
139            }
140        })
141        .collect();
142    placed.sort_by(|a, b| {
143        a.quadrant
144            .cmp(&b.quadrant)
145            .then(b.importance.total_cmp(&a.importance))
146            .then(due_order(a.task.due, b.task.due))
147            .then(b.task.age_days.cmp(&a.task.age_days))
148            .then_with(|| (&a.task.project, a.task.line).cmp(&(&b.task.project, b.task.line)))
149    });
150    placed
151}
152
153fn due_order(a: Option<i64>, b: Option<i64>) -> Ordering {
154    match (a, b) {
155        (Some(a), Some(b)) => a.cmp(&b),
156        (Some(_), None) => Ordering::Less,
157        (None, Some(_)) => Ordering::Greater,
158        (None, None) => Ordering::Equal,
159    }
160}
161
162/// Tasks for project conditions: failing CI, outdated dependencies, local
163/// changes, and inactivity beyond the tier's horizon.
164pub fn signal_tasks(cfg: &Config, p: &Project) -> Vec<Task> {
165    let task = |priority, text: String, urgent, age_days| Task {
166        project: p.name.clone(),
167        tier: p.tier,
168        priority,
169        text,
170        line: None,
171        key: None,
172        group: None,
173        due: None,
174        tagged_urgent: false,
175        signal_urgent: urgent,
176        age_days,
177        eligible: false,
178    };
179    let mut tasks = Vec::new();
180    if let Ci::Failing(workflows) = &p.ci {
181        tasks.push(Task {
182            key: Some("ci".into()),
183            eligible: true,
184            ..task(
185                cfg.signals.ci,
186                format!("fix CI: {}", workflows.join(", ")),
187                true,
188                0,
189            )
190        });
191    }
192    if let Some((n, _)) = p.deps.filter(|(n, _)| *n > 0) {
193        tasks.push(Task {
194            key: Some("deps".into()),
195            eligible: true,
196            ..task(
197                cfg.signals.deps,
198                format!("update dependencies: {n} outdated"),
199                false,
200                0,
201            )
202        });
203    }
204    let local = local_changes(p);
205    if !local.is_empty() {
206        tasks.push(task(
207            cfg.signals.hygiene,
208            format!("resolve local changes: {local}"),
209            false,
210            0,
211        ));
212    }
213    let horizon = cfg.activity_horizon[usize::from(p.tier) - 1];
214    match p.idle_days {
215        Some(days) if days > horizon => tasks.push(task(
216            cfg.signals.activity,
217            format!("review project: no code commits in {days} days"),
218            false,
219            // The task exists from the day the horizon was crossed.
220            days - horizon,
221        )),
222        None => tasks.push(task(
223            cfg.signals.activity,
224            "review project: no code commits found".into(),
225            false,
226            0,
227        )),
228        Some(_) => {}
229    }
230    tasks
231}
232
233/// `n` with the noun that agrees with it.
234fn count(n: i64, one: &str, many: &str) -> String {
235    format!("{n} {}", if n == 1 { one } else { many })
236}
237
238pub fn local_changes(p: &Project) -> String {
239    let mut parts = Vec::new();
240    if p.dirty > 0 {
241        parts.push(count(p.dirty, "changed file", "changed files"));
242    }
243    if let Some(n) = p.ahead.filter(|n| *n > 0) {
244        parts.push(count(n, "unpushed commit", "unpushed commits"));
245    }
246    if p.leftover > 0 {
247        parts.push(count(
248            p.leftover,
249            "leftover pma branch",
250            "leftover pma branches",
251        ));
252    }
253    parts.join(", ")
254}
255
256/// What places a project in `pma status`, worst first. Each is a condition
257/// with an action, so the order says what to do first: a failing check blocks
258/// the repository, broken facts make the rest unreliable, unpublished work is
259/// at risk, then the work itself, then upkeep.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
261pub enum State {
262    /// CI fails on the default branch.
263    FailingCi,
264    /// `TODO.md` has lint errors, or the scan could not read the project.
265    Broken,
266    /// Commits not pushed, or `pma/` branches no open run owns.
267    Unpublished,
268    /// Open items under `## Critical`.
269    Critical,
270    /// Outdated dependencies at the last measurement.
271    StaleDeps,
272    /// No commit that counts as activity within the tier's horizon.
273    Idle,
274    /// None of the above.
275    Clear,
276}
277
278impl State {
279    pub fn name(self) -> &'static str {
280        match self {
281            State::FailingCi => "failing CI",
282            State::Broken => "broken",
283            State::Unpublished => "unpublished",
284            State::Critical => "critical",
285            State::StaleDeps => "stale deps",
286            State::Idle => "idle",
287            State::Clear => "clear",
288        }
289    }
290}
291
292/// What the scan could not read about a project, which `rank::Project` does
293/// not carry.
294#[derive(Debug, Clone, Copy, Default)]
295pub struct Facts<'a> {
296    pub lint_errors: i64,
297    pub scan_error: Option<&'a str>,
298}
299
300/// Every state a project is in, worst first, each with what put it there.
301/// `Clear` alone when it is in none. A signal that was not measured, such as
302/// CI offline or dependencies never counted, puts a project in no state.
303pub fn states(cfg: &Config, p: &Project, facts: Facts<'_>) -> Vec<(State, String)> {
304    let mut out = Vec::new();
305    if let Ci::Failing(workflows) = &p.ci {
306        out.push((State::FailingCi, workflows.join(", ")));
307    }
308    let mut broken = Vec::new();
309    if let Some(e) = facts.scan_error {
310        broken.push(format!("scan error: {e}"));
311    }
312    if facts.lint_errors > 0 {
313        broken.push(format!(
314            "{} in TODO.md",
315            count(facts.lint_errors, "lint error", "lint errors")
316        ));
317    }
318    if !broken.is_empty() {
319        out.push((State::Broken, broken.join("; ")));
320    }
321    let mut unpublished = Vec::new();
322    if let Some(n) = p.ahead.filter(|n| *n > 0) {
323        unpublished.push(count(n, "unpushed commit", "unpushed commits"));
324    }
325    if p.leftover > 0 {
326        unpublished.push(count(
327            p.leftover,
328            "leftover pma branch",
329            "leftover pma branches",
330        ));
331    }
332    if !unpublished.is_empty() {
333        out.push((State::Unpublished, unpublished.join(", ")));
334    }
335    let critical = p.open.iter().filter(|&&x| x == Priority::Critical).count() as i64;
336    if critical > 0 {
337        out.push((State::Critical, count(critical, "open item", "open items")));
338    }
339    if let Some((n, age)) = p.deps.filter(|(n, _)| *n > 0) {
340        let when = match age {
341            0 => "today".to_string(),
342            1 => "1 day ago".to_string(),
343            d => format!("{d} days ago"),
344        };
345        out.push((State::StaleDeps, format!("{n} outdated, measured {when}")));
346    }
347    let horizon = cfg.activity_horizon[usize::from(p.tier) - 1];
348    match p.idle_days {
349        Some(d) if d > horizon => out.push((
350            State::Idle,
351            format!("no code commit in {d} days, horizon {horizon}"),
352        )),
353        None => out.push((State::Idle, "no code commits found".into())),
354        Some(_) => {}
355    }
356    if out.is_empty() {
357        out.push((State::Clear, String::new()));
358    }
359    out
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const TODAY: i64 = 20_000;
367
368    fn task(tier: u8, priority: Priority) -> Task {
369        Task {
370            project: "p".into(),
371            tier,
372            priority,
373            text: "t".into(),
374            line: Some(1),
375            key: Some("t".into()),
376            group: None,
377            due: None,
378            tagged_urgent: false,
379            signal_urgent: false,
380            age_days: 0,
381            eligible: false,
382        }
383    }
384
385    fn quadrant(t: Task) -> Quadrant {
386        place(&Config::default(), vec![t], TODAY)[0].quadrant
387    }
388
389    fn project(tier: u8) -> Project {
390        Project {
391            name: "p".into(),
392            tier,
393            open: vec![],
394            idle_days: Some(0),
395            ci: Ci::Passing,
396            dirty: 0,
397            leftover: 0,
398            ahead: Some(0),
399            deps: None,
400        }
401    }
402
403    #[test]
404    fn importance_follows_the_design_table() {
405        use Priority::*;
406        let important = |tier, p| quadrant(task(tier, p)) == Quadrant::Q2;
407        for tier in 1..=4 {
408            assert!(important(tier, Critical), "critical, tier {tier}");
409        }
410        assert!(!important(5, Critical));
411        assert!(
412            important(1, High) && important(2, High),
413            "0.8 * 0.5 meets 0.4 exactly"
414        );
415        assert!(!important(3, High));
416        for tier in 1..=5 {
417            assert!(!important(tier, Medium) && !important(tier, Low));
418        }
419    }
420
421    #[test]
422    fn urgency_sources() {
423        let cfg = Config::default();
424        let mut t = task(4, Priority::Low);
425
426        t.due = Some(TODAY + 7);
427        assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Due(7)));
428        t.due = Some(TODAY - 2);
429        assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Due(-2)));
430        t.due = Some(TODAY + 8);
431        assert_eq!(urgency(&cfg, &t, TODAY), None);
432
433        t.tagged_urgent = true;
434        assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Tagged));
435        t.tagged_urgent = false;
436        t.signal_urgent = true;
437        assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Signal));
438    }
439
440    /// Age no longer makes a task urgent. With no due dates in a portfolio,
441    /// the old rule reduced to "older than 30 days", which within a month
442    /// admitted most tier-1 tasks and carried no ordering information the
443    /// queue did not already have.
444    #[test]
445    fn age_alone_is_not_urgency() {
446        let cfg = Config::default();
447        let aged = |tier, age_days| Task {
448            age_days,
449            ..task(tier, Priority::Low)
450        };
451        for (tier, age) in [(1, 31), (1, 400), (3, 91), (4, 5000)] {
452            assert_eq!(urgency(&cfg, &aged(tier, age), TODAY), None, "{tier} {age}");
453        }
454        // It still orders the queue, oldest first among equals.
455        let placed = place(&cfg, vec![aged(1, 10), aged(1, 90)], TODAY);
456        assert_eq!(
457            placed.iter().map(|p| p.task.age_days).collect::<Vec<_>>(),
458            [90, 10]
459        );
460    }
461
462    #[test]
463    fn ordering_within_and_across_quadrants() {
464        let mut crit = task(1, Priority::Critical);
465        crit.text = "crit".into();
466        let mut high_due_late = task(1, Priority::High);
467        high_due_late.text = "late".into();
468        high_due_late.due = Some(TODAY + 60);
469        let mut high_due_soon = task(1, Priority::High);
470        high_due_soon.text = "soon".into();
471        high_due_soon.due = Some(TODAY + 30);
472        let mut high_old = task(1, Priority::High);
473        high_old.text = "old".into();
474        high_old.age_days = 20;
475        let mut urgent_low = task(1, Priority::Low);
476        urgent_low.text = "q3".into();
477        urgent_low.tagged_urgent = true;
478        let mut fire = task(2, Priority::Critical);
479        fire.text = "q1".into();
480        fire.due = Some(TODAY);
481
482        let order: Vec<_> = place(
483            &Config::default(),
484            vec![
485                urgent_low,
486                high_old,
487                high_due_late,
488                crit,
489                high_due_soon,
490                fire,
491            ],
492            TODAY,
493        )
494        .into_iter()
495        .map(|p| (p.quadrant, p.task.text))
496        .collect();
497        assert_eq!(
498            order,
499            [
500                (Quadrant::Q1, "q1".to_string()),
501                (Quadrant::Q2, "crit".to_string()),
502                (Quadrant::Q2, "soon".to_string()),
503                (Quadrant::Q2, "late".to_string()),
504                (Quadrant::Q2, "old".to_string()),
505                (Quadrant::Q3, "q3".to_string()),
506            ]
507        );
508    }
509
510    #[test]
511    fn signal_tasks_for_ci_local_changes_and_idleness() {
512        let cfg = Config::default();
513        assert!(signal_tasks(&cfg, &project(1)).is_empty());
514
515        let p = Project {
516            ci: Ci::Failing(vec!["test".into(), "wheels".into()]),
517            dirty: 1,
518            ahead: Some(2),
519            leftover: 1,
520            idle_days: Some(31),
521            deps: Some((3, 2)),
522            ..project(1)
523        };
524        let tasks = signal_tasks(&cfg, &p);
525        let ages: Vec<i64> = tasks.iter().map(|t| t.age_days).collect();
526        assert_eq!(
527            ages,
528            [0, 0, 0, 1],
529            "an idle task is as old as the time past its horizon"
530        );
531        let got: Vec<_> = tasks
532            .iter()
533            .map(|t| (t.priority, t.text.as_str(), t.signal_urgent))
534            .collect();
535        assert_eq!(
536            got,
537            [
538                (Priority::High, "fix CI: test, wheels", true),
539                (Priority::Medium, "update dependencies: 3 outdated", false),
540                (
541                    Priority::Medium,
542                    "resolve local changes: 1 changed file, 2 unpushed commits, 1 leftover pma branch",
543                    false
544                ),
545                (
546                    Priority::Low,
547                    "review project: no code commits in 31 days",
548                    false
549                ),
550            ]
551        );
552        let placed = place(&cfg, tasks, TODAY);
553        assert_eq!(
554            placed[0].quadrant,
555            Quadrant::Q1,
556            "failing CI in tier 1 is important and urgent"
557        );
558
559        let tier3 = Project {
560            ci: Ci::Failing(vec!["t".into()]),
561            ..project(3)
562        };
563        assert_eq!(
564            place(&cfg, signal_tasks(&cfg, &tier3), TODAY)[0].quadrant,
565            Quadrant::Q3
566        );
567
568        let quiet = Project {
569            idle_days: None,
570            ..project(5)
571        };
572        assert_eq!(
573            signal_tasks(&cfg, &quiet)[0].text,
574            "review project: no code commits found"
575        );
576    }
577
578    #[test]
579    fn a_project_is_in_each_state_it_meets_worst_first() {
580        let cfg = Config::default();
581        let p = Project {
582            open: vec![Priority::Critical, Priority::High, Priority::Critical],
583            idle_days: Some(31),
584            ci: Ci::Failing(vec!["test".into()]),
585            dirty: 4,
586            ahead: Some(1),
587            leftover: 2,
588            deps: Some((3, 1)),
589            ..project(1)
590        };
591        let facts = Facts {
592            lint_errors: 1,
593            scan_error: None,
594        };
595        let got = states(&cfg, &p, facts);
596        assert_eq!(
597            got,
598            [
599                (State::FailingCi, "test".to_string()),
600                (State::Broken, "1 lint error in TODO.md".to_string()),
601                (
602                    State::Unpublished,
603                    "1 unpushed commit, 2 leftover pma branches".to_string()
604                ),
605                (State::Critical, "2 open items".to_string()),
606                (
607                    State::StaleDeps,
608                    "3 outdated, measured 1 day ago".to_string()
609                ),
610                (
611                    State::Idle,
612                    "no code commit in 31 days, horizon 30".to_string()
613                ),
614            ]
615        );
616        assert!(got.windows(2).all(|w| w[0].0 < w[1].0), "worst first");
617    }
618
619    /// A clean project is clear; changed files alone, CI not measured and
620    /// dependencies never counted place it nowhere worse.
621    #[test]
622    fn what_was_not_measured_places_a_project_in_no_state() {
623        let cfg = Config::default();
624        let quiet = Project {
625            dirty: 3,
626            ci: Ci::Unknown("offline".into()),
627            ..project(1)
628        };
629        assert_eq!(
630            states(&cfg, &quiet, Facts::default()),
631            [(State::Clear, String::new())]
632        );
633        let scan = Facts {
634            lint_errors: 0,
635            scan_error: Some("git status failed"),
636        };
637        assert_eq!(
638            states(&cfg, &project(1), scan)[0],
639            (State::Broken, "scan error: git status failed".to_string())
640        );
641        let never = Project {
642            idle_days: None,
643            ..project(5)
644        };
645        assert_eq!(states(&cfg, &never, Facts::default())[0].0, State::Idle);
646    }
647}