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:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-20p9
|
bd-226s
|
||||||
|
|||||||
20
migrations/026_scoring_indexes.sql
Normal file
20
migrations/026_scoring_indexes.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Indexes for time-decay expert scoring: dual-path matching and reviewer participation.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
|
||||||
|
ON notes(position_old_path, author_username, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
|
||||||
|
ON mr_file_changes(old_path, project_id, merge_request_id)
|
||||||
|
WHERE old_path IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
|
||||||
|
ON mr_file_changes(new_path, project_id, merge_request_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
|
||||||
|
ON notes(discussion_id, author_username, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
|
||||||
|
ON notes(position_old_path, project_id, created_at)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
|
||||||
@@ -4,7 +4,7 @@ title: ""
|
|||||||
status: iterating
|
status: iterating
|
||||||
iteration: 6
|
iteration: 6
|
||||||
target_iterations: 8
|
target_iterations: 8
|
||||||
beads_revision: 1
|
beads_revision: 2
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-08
|
created: 2026-02-08
|
||||||
updated: 2026-02-12
|
updated: 2026-02-12
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--fields",
|
"--fields",
|
||||||
"--detail",
|
"--detail",
|
||||||
"--no-detail",
|
"--no-detail",
|
||||||
|
"--as-of",
|
||||||
|
"--explain-score",
|
||||||
|
"--include-bots",
|
||||||
|
"--all-history",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("drift", &["--threshold", "--project"]),
|
("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")]
|
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
||||||
pub no_detail: bool,
|
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)]
|
#[derive(Parser)]
|
||||||
|
|||||||
@@ -164,6 +164,38 @@ pub struct ScoringConfig {
|
|||||||
/// Bonus points per individual inline review comment (DiffNote).
|
/// Bonus points per individual inline review comment (DiffNote).
|
||||||
#[serde(rename = "noteBonus")]
|
#[serde(rename = "noteBonus")]
|
||||||
pub note_bonus: i64,
|
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 {
|
impl Default for ScoringConfig {
|
||||||
@@ -172,6 +204,14 @@ impl Default for ScoringConfig {
|
|||||||
author_weight: 25,
|
author_weight: 25,
|
||||||
reviewer_weight: 10,
|
reviewer_weight: 10,
|
||||||
note_bonus: 1,
|
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(),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,4 +650,140 @@ mod tests {
|
|||||||
"set default_project should be present: {json}"
|
"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",
|
"025",
|
||||||
include_str!("../../migrations/025_note_dirty_backfill.sql"),
|
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> {
|
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||||
|
|||||||
Reference in New Issue
Block a user