From fc3fb313437fce9c427b9d717568f59fdea0480d Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 26 Feb 2026 13:21:20 -0700 Subject: [PATCH] Dampen effort in impact mode scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Impact mode formula changed from (ds+1)×pw/ew to (ds+1)×pw/ew^0.25 so that effort no longer perfectly cancels priority for standalone tasks. High-priority high-effort work now ranks above medium-priority low-effort work, matching impact mode's intent of favoring importance over cost. --- src/cmd/next.rs | 2 +- src/score.rs | 42 +++++++++++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/cmd/next.rs b/src/cmd/next.rs index 8327342b6492f214ea467c22a3cce819158b4df5..59ff275212f4dd5911e00927bb97feec6ace8b75 100644 --- a/src/cmd/next.rs +++ b/src/cmd/next.rs @@ -119,7 +119,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) match mode { Mode::Impact => { println!( - " ({:.2} + 1.00) × {:.0} / {:.0} = {:.2}", + " ({:.2} + 1.00) × {:.0} / {:.0}^0.25 = {:.2}", s.downstream_score, s.priority_weight, s.effort_weight, s.score ); } diff --git a/src/score.rs b/src/score.rs index cc9973c454930ce56753933aa2cef4e05b30d1c5..2ec790d014842fa84a90b6462cf063380479de72 100644 --- a/src/score.rs +++ b/src/score.rs @@ -8,9 +8,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; /// Scoring mode for ranking tasks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { - /// Critical path: downstream impact dominates. + /// Critical path: downstream impact dominates, effort is dampened. /// - /// `score = (downstream + 1.0) × priority_weight / effort_weight` + /// `score = (downstream + 1.0) × priority_weight / effort_weight^0.25` Impact, /// Effort-weighted: effort dominates, downstream is dampened. @@ -176,7 +176,7 @@ pub fn rank( let ew = effort_weight(node.effort); let score = match mode { - Mode::Impact => (ds + 1.0) * pw / ew, + Mode::Impact => (ds + 1.0) * pw / ew.powf(0.25), Mode::Effort => (ds * 0.25 + 1.0) * pw / (ew * ew), }; @@ -226,7 +226,7 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].id, "a"); - // (0 + 1.0) * 3.0 / 1.0 = 3.0 + // (0 + 1.0) * 3.0 / 1.0^0.25 = 3.0 assert!((result[0].score - 3.0).abs() < f64::EPSILON); assert_eq!(result[0].total_unblocked, 0); assert_eq!(result[0].direct_unblocked, 0); @@ -244,8 +244,9 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].id, "a"); // downstream of A = priority_weight(B) = 3.0 - // score = (3.0 + 1.0) * 2.0 / 2.0 = 4.0 - assert!((result[0].score - 4.0).abs() < f64::EPSILON); + // score = (3.0 + 1.0) * 2.0 / 2.0^0.25 = 8.0 / 1.1892 ≈ 6.727 + let expected = (3.0 + 1.0) * 2.0 / 2.0_f64.powf(0.25); + assert!((result[0].score - expected).abs() < 1e-10); assert_eq!(result[0].total_unblocked, 1); assert_eq!(result[0].direct_unblocked, 1); } @@ -264,8 +265,9 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].id, "a"); // downstream = pw(b) + pw(c) = 2.0 + 3.0 = 5.0 - // score = (5.0 + 1.0) * 2.0 / 2.0 = 6.0 - assert!((result[0].score - 6.0).abs() < f64::EPSILON); + // score = (5.0 + 1.0) * 2.0 / 2.0^0.25 + let expected = (5.0 + 1.0) * 2.0 / 2.0_f64.powf(0.25); + assert!((result[0].score - expected).abs() < 1e-10); assert_eq!(result[0].total_unblocked, 2); assert_eq!(result[0].direct_unblocked, 1); } @@ -283,12 +285,13 @@ mod tests { assert_eq!(result.len(), 2); // Both A and B see C as downstream. - // A: downstream = pw(c) = 3.0, score = (3+1)*3/1 = 12.0 - // B: downstream = pw(c) = 3.0, score = (3+1)*2/2 = 4.0 + // A: downstream = pw(c) = 3.0, score = (3+1)*3/1^0.25 = 12.0 + // B: downstream = pw(c) = 3.0, score = (3+1)*2/2^0.25 assert_eq!(result[0].id, "a"); assert!((result[0].score - 12.0).abs() < f64::EPSILON); + let expected_b = (3.0 + 1.0) * 2.0 / 2.0_f64.powf(0.25); assert_eq!(result[1].id, "b"); - assert!((result[1].score - 4.0).abs() < f64::EPSILON); + assert!((result[1].score - expected_b).abs() < 1e-10); } #[test] @@ -303,8 +306,9 @@ mod tests { let impact = rank(&tasks, &edges, &HashSet::new(), Mode::Impact, 5); let effort = rank(&tasks, &edges, &HashSet::new(), Mode::Effort, 5); - // Impact: (3.0 + 1.0) * 3.0 / 3.0 = 4.0 - assert!((impact[0].score - 4.0).abs() < f64::EPSILON); + // Impact: (3.0 + 1.0) * 3.0 / 3.0^0.25 + let expected_impact = (3.0 + 1.0) * 3.0 / 3.0_f64.powf(0.25); + assert!((impact[0].score - expected_impact).abs() < 1e-10); // Effort: (3.0 * 0.25 + 1.0) * 3.0 / 9.0 = 1.75 * 3.0 / 9.0 ≈ 0.583 assert!((effort[0].score - (1.75 * 3.0 / 9.0)).abs() < f64::EPSILON); } @@ -380,6 +384,18 @@ mod tests { assert_eq!(result[0].total_unblocked, 2); } + #[test] + fn impact_prefers_priority_over_effort_for_standalone() { + // Regression: high-pri/high-eff must rank above medium-pri/low-eff + // in impact mode. Previously effort canceled priority entirely. + let tasks = vec![ + task("a", "HighPriHighEff", 1, 3), + task("b", "MedPriLowEff", 2, 1), + ]; + let result = rank(&tasks, &[], &HashSet::new(), Mode::Impact, 5); + assert_eq!(result[0].id, "a"); + } + #[test] fn parent_with_all_children_closed_remains_candidate() { // A standalone task that would be in the open_tasks list but NOT