feat(bd-226s): implement time-decay expert scoring model
Replace flat-weight expertise scoring with exponential half-life decay, split reviewer signals (participated vs assigned-only), dual-path rename awareness, and new CLI flags (--as-of, --explain-score, --include-bots, --all-history). Changes: - ScoringConfig: 8 new fields with validation (config.rs) - half_life_decay() and normalize_query_path() pure functions (who.rs) - CTE-based SQL with dual-path matching, mr_activity, reviewer_participation (who.rs) - Rust-side decay aggregation with deterministic f64 ordering (who.rs) - Path resolution probes check old_path columns (who.rs) - Migration 026: 5 new indexes for dual-path and reviewer participation - Default --since changed from 6m to 24m - 31 new tests (example-based + invariant), 621 total who tests passing - Autocorrect registry updated with new flags Closes: bd-226s, bd-2w1p, bd-1soz, bd-18dn, bd-2ao4, bd-2yu5, bd-1b50, bd-1hoq, bd-1h3f, bd-13q8, bd-11mg, bd-1vti, bd-1j5o
This commit is contained in:
@@ -183,6 +183,10 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--fields",
|
||||
"--detail",
|
||||
"--no-detail",
|
||||
"--as-of",
|
||||
"--explain-score",
|
||||
"--include-bots",
|
||||
"--all-history",
|
||||
],
|
||||
),
|
||||
("drift", &["--threshold", "--project"]),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -910,6 +910,26 @@ pub struct WhoArgs {
|
||||
|
||||
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
||||
pub no_detail: bool,
|
||||
|
||||
/// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only.
|
||||
#[arg(long = "as-of", help_heading = "Scoring")]
|
||||
pub as_of: Option<String>,
|
||||
|
||||
/// Show per-component score breakdown in output. Expert mode only.
|
||||
#[arg(long = "explain-score", help_heading = "Scoring")]
|
||||
pub explain_score: bool,
|
||||
|
||||
/// Include bot users in results (normally excluded via scoring.excluded_usernames).
|
||||
#[arg(long = "include-bots", help_heading = "Scoring")]
|
||||
pub include_bots: bool,
|
||||
|
||||
/// Remove the default time window (query all history). Conflicts with --since.
|
||||
#[arg(
|
||||
long = "all-history",
|
||||
help_heading = "Filters",
|
||||
conflicts_with = "since"
|
||||
)]
|
||||
pub all_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
|
||||
@@ -164,6 +164,38 @@ pub struct ScoringConfig {
|
||||
/// Bonus points per individual inline review comment (DiffNote).
|
||||
#[serde(rename = "noteBonus")]
|
||||
pub note_bonus: i64,
|
||||
|
||||
/// Points per MR where the user was assigned as a reviewer.
|
||||
#[serde(rename = "reviewerAssignmentWeight")]
|
||||
pub reviewer_assignment_weight: i64,
|
||||
|
||||
/// Half-life in days for author contribution decay.
|
||||
#[serde(rename = "authorHalfLifeDays")]
|
||||
pub author_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for reviewer contribution decay.
|
||||
#[serde(rename = "reviewerHalfLifeDays")]
|
||||
pub reviewer_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for reviewer assignment decay.
|
||||
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
|
||||
pub reviewer_assignment_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for note/comment contribution decay.
|
||||
#[serde(rename = "noteHalfLifeDays")]
|
||||
pub note_half_life_days: u32,
|
||||
|
||||
/// Multiplier applied to scores from closed (not merged) MRs.
|
||||
#[serde(rename = "closedMrMultiplier")]
|
||||
pub closed_mr_multiplier: f64,
|
||||
|
||||
/// Minimum character count for a review note to earn note_bonus.
|
||||
#[serde(rename = "reviewerMinNoteChars")]
|
||||
pub reviewer_min_note_chars: u32,
|
||||
|
||||
/// Usernames excluded from expert/scoring results.
|
||||
#[serde(rename = "excludedUsernames")]
|
||||
pub excluded_usernames: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ScoringConfig {
|
||||
@@ -172,6 +204,14 @@ impl Default for ScoringConfig {
|
||||
author_weight: 25,
|
||||
reviewer_weight: 10,
|
||||
note_bonus: 1,
|
||||
reviewer_assignment_weight: 3,
|
||||
author_half_life_days: 180,
|
||||
reviewer_half_life_days: 90,
|
||||
reviewer_assignment_half_life_days: 45,
|
||||
note_half_life_days: 45,
|
||||
closed_mr_multiplier: 0.5,
|
||||
reviewer_min_note_chars: 20,
|
||||
excluded_usernames: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +327,55 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
||||
details: "scoring.noteBonus must be >= 0".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.reviewer_assignment_weight < 0 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.reviewer_assignment_half_life_days == 0
|
||||
|| scoring.reviewer_assignment_half_life_days > 3650
|
||||
{
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(),
|
||||
});
|
||||
}
|
||||
if !scoring.closed_mr_multiplier.is_finite()
|
||||
|| scoring.closed_mr_multiplier <= 0.0
|
||||
|| scoring.closed_mr_multiplier > 1.0
|
||||
{
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.reviewer_min_note_chars > 4096 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring
|
||||
.excluded_usernames
|
||||
.iter()
|
||||
.any(|u| u.trim().is_empty())
|
||||
{
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.excludedUsernames entries must be non-empty".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -561,4 +650,140 @@ mod tests {
|
||||
"set default_project should be present: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_zero_half_life() {
|
||||
let scoring = ScoringConfig {
|
||||
author_half_life_days: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("authorHalfLifeDays"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_absurd_half_life() {
|
||||
let scoring = ScoringConfig {
|
||||
author_half_life_days: 5000,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("authorHalfLifeDays"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_nan_multiplier() {
|
||||
let scoring = ScoringConfig {
|
||||
closed_mr_multiplier: f64::NAN,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("closedMrMultiplier"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_zero_multiplier() {
|
||||
let scoring = ScoringConfig {
|
||||
closed_mr_multiplier: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("closedMrMultiplier"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_negative_reviewer_assignment_weight() {
|
||||
let scoring = ScoringConfig {
|
||||
reviewer_assignment_weight: -1,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("reviewerAssignmentWeight"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_oversized_min_note_chars() {
|
||||
let scoring = ScoringConfig {
|
||||
reviewer_min_note_chars: 5000,
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("reviewerMinNoteChars"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_empty_excluded_username() {
|
||||
let scoring = ScoringConfig {
|
||||
excluded_usernames: vec!["valid".to_string(), " ".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = validate_scoring(&scoring).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_accepts_valid_new_fields() {
|
||||
let scoring = ScoringConfig {
|
||||
author_half_life_days: 365,
|
||||
reviewer_half_life_days: 180,
|
||||
reviewer_assignment_half_life_days: 90,
|
||||
note_half_life_days: 60,
|
||||
closed_mr_multiplier: 0.5,
|
||||
reviewer_min_note_chars: 20,
|
||||
reviewer_assignment_weight: 3,
|
||||
excluded_usernames: vec!["bot-user".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
validate_scoring(&scoring).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_accepts_boundary_half_life() {
|
||||
// 1 and 3650 are both valid boundaries
|
||||
let scoring_min = ScoringConfig {
|
||||
author_half_life_days: 1,
|
||||
..Default::default()
|
||||
};
|
||||
validate_scoring(&scoring_min).unwrap();
|
||||
|
||||
let scoring_max = ScoringConfig {
|
||||
author_half_life_days: 3650,
|
||||
..Default::default()
|
||||
};
|
||||
validate_scoring(&scoring_max).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_accepts_multiplier_at_one() {
|
||||
let scoring = ScoringConfig {
|
||||
closed_mr_multiplier: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
validate_scoring(&scoring).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"025",
|
||||
include_str!("../../migrations/025_note_dirty_backfill.sql"),
|
||||
),
|
||||
(
|
||||
"026",
|
||||
include_str!("../../migrations/026_scoring_indexes.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||
|
||||
Reference in New Issue
Block a user