Skip to main content

bvr/analysis/
triage.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use chrono::{Datelike, Duration, Utc, Weekday};
4use serde::Serialize;
5
6use crate::analysis::graph::{GraphMetrics, IssueGraph};
7use crate::model::Issue;
8
9// ---------------------------------------------------------------------------
10// ImpactScore – 8-component transparent scoring (matches Go's priority.go)
11// ---------------------------------------------------------------------------
12
13/// Weight constants for ImpactScore components (must sum to 1.0).
14///
15/// These weights balance structural importance (PageRank, betweenness) against
16/// operational signals (priority, urgency, staleness). The values are tuned to
17/// match the Go legacy tool's `priority.go` behavior — do not change without
18/// updating conformance fixtures (78 tests).
19///
20/// Design rationale:
21/// - PageRank (0.22) + betweenness (0.20) = 42% structural: ensures graph
22///   centrality dominates over cosmetic metadata for ranking decisions.
23/// - BlockerRatio (0.13): rewards issues that unblock the most downstream work.
24/// - PriorityBoost + urgency + risk (0.30 combined): respects declared priority
25///   and status without letting any single metadata field override the graph.
26/// - Staleness (0.05): minor tiebreaker — prevents long-dead items from floating
27///   up, but does not dominate over structural signals.
28const W_PAGERANK: f64 = 0.22;
29const W_BETWEENNESS: f64 = 0.20;
30const W_BLOCKER_RATIO: f64 = 0.13;
31const W_STALENESS: f64 = 0.05;
32const W_PRIORITY_BOOST: f64 = 0.10;
33const W_TIME_TO_IMPACT: f64 = 0.10;
34const W_URGENCY: f64 = 0.10;
35const W_RISK: f64 = 0.10;
36
37/// Named presets for triage scoring weight emphasis.
38///
39/// Each preset applies multipliers to the default component weights,
40/// shifting emphasis without replacing the underlying scoring model.
41/// Multipliers are applied through `weight_adjustments` and renormalized
42/// to sum to 1.0, so presets compose safely with feedback adjustments.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum WeightPreset {
45    /// Default Go-compatible weights (no adjustments).
46    #[default]
47    Default,
48    /// Emphasize graph structure: boost PageRank, betweenness, time-to-impact.
49    GraphHeavy,
50    /// Emphasize declared priority and urgency over graph signals.
51    PriorityFirst,
52    /// Emphasize quick wins: boost blocker ratio, time-to-impact, lower risk penalty.
53    QuickWins,
54    /// Emphasize risk reduction: boost risk, betweenness, blocker ratio.
55    RiskAverse,
56}
57
58impl WeightPreset {
59    /// Return weight adjustment multipliers for this preset.
60    /// Values > 1.0 boost the component; < 1.0 diminish it.
61    #[must_use]
62    pub fn adjustments(self) -> HashMap<String, f64> {
63        match self {
64            Self::Default => HashMap::new(),
65            Self::GraphHeavy => [
66                ("PageRank", 1.5),
67                ("Betweenness", 1.5),
68                ("TimeToImpact", 1.3),
69                ("PriorityBoost", 0.6),
70                ("Urgency", 0.6),
71            ]
72            .into_iter()
73            .map(|(k, v)| (k.to_string(), v))
74            .collect(),
75            Self::PriorityFirst => [
76                ("PriorityBoost", 2.0),
77                ("Urgency", 1.5),
78                ("PageRank", 0.7),
79                ("Betweenness", 0.7),
80            ]
81            .into_iter()
82            .map(|(k, v)| (k.to_string(), v))
83            .collect(),
84            Self::QuickWins => [
85                ("BlockerRatio", 1.5),
86                ("TimeToImpact", 1.5),
87                ("Risk", 0.6),
88                ("Staleness", 0.5),
89            ]
90            .into_iter()
91            .map(|(k, v)| (k.to_string(), v))
92            .collect(),
93            Self::RiskAverse => [
94                ("Risk", 1.8),
95                ("Betweenness", 1.3),
96                ("BlockerRatio", 1.3),
97                ("PageRank", 0.8),
98            ]
99            .into_iter()
100            .map(|(k, v)| (k.to_string(), v))
101            .collect(),
102        }
103    }
104
105    /// All available preset names (for CLI help text).
106    pub const ALL: &[&str] = &[
107        "default",
108        "graph-heavy",
109        "priority-first",
110        "quick-wins",
111        "risk-averse",
112    ];
113
114    /// Parse a preset name.
115    #[must_use]
116    pub fn from_name(name: &str) -> Option<Self> {
117        match name {
118            "default" => Some(Self::Default),
119            "graph-heavy" => Some(Self::GraphHeavy),
120            "priority-first" => Some(Self::PriorityFirst),
121            "quick-wins" => Some(Self::QuickWins),
122            "risk-averse" => Some(Self::RiskAverse),
123            _ => None,
124        }
125    }
126}
127
128/// One component of the impact score breakdown.
129#[derive(Debug, Clone, Serialize)]
130pub struct ScoreComponent {
131    pub name: &'static str,
132    pub weight: f64,
133    pub raw: f64,
134    pub normalized: f64,
135    pub weighted: f64,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub explanation: Option<String>,
138}
139
140/// Full impact score with transparent 8-component breakdown.
141#[derive(Debug, Clone, Serialize)]
142pub struct ImpactScore {
143    pub issue_id: String,
144    pub score: f64,
145    pub breakdown: Vec<ScoreComponent>,
146}
147
148/// Context needed to compute ImpactScores across a set of issues.
149struct ScoringContext {
150    max_pagerank: f64,
151    max_betweenness: f64,
152    max_blocks: f64,
153    total_open: usize,
154    now_ts: i64,
155}
156
157impl ScoringContext {
158    fn from_metrics(metrics: &GraphMetrics, total_open: usize) -> Self {
159        Self {
160            max_pagerank: metrics
161                .pagerank
162                .values()
163                .copied()
164                .fold(0.0_f64, f64::max)
165                .max(1e-9),
166            max_betweenness: metrics
167                .betweenness
168                .values()
169                .copied()
170                .fold(0.0_f64, f64::max)
171                .max(1e-9),
172            max_blocks: metrics
173                .blocks_count
174                .values()
175                .copied()
176                .max()
177                .unwrap_or(1)
178                .max(1) as f64,
179            total_open,
180            now_ts: Utc::now().timestamp(),
181        }
182    }
183}
184
185struct TriageLookupCache<'a> {
186    issue_by_id: HashMap<&'a str, &'a Issue>,
187    open_blocker_count_by_issue: HashMap<&'a str, usize>,
188    issues_in_cycles: HashSet<&'a str>,
189}
190
191impl<'a> TriageLookupCache<'a> {
192    fn new(issues: &'a [Issue], graph: &IssueGraph, metrics: &'a GraphMetrics) -> Self {
193        let issue_by_id = issues
194            .iter()
195            .map(|issue| (issue.id.as_str(), issue))
196            .collect();
197        let open_blocker_count_by_issue = issues
198            .iter()
199            .map(|issue| (issue.id.as_str(), graph.open_blockers(&issue.id).len()))
200            .collect();
201        let issues_in_cycles = metrics
202            .cycles
203            .iter()
204            .flat_map(|cycle| cycle.iter().map(String::as_str))
205            .collect();
206
207        Self {
208            issue_by_id,
209            open_blocker_count_by_issue,
210            issues_in_cycles,
211        }
212    }
213}
214
215/// Compute the full 8-component ImpactScore for a single issue.
216fn compute_impact_score(
217    issue: &Issue,
218    metrics: &GraphMetrics,
219    ctx: &ScoringContext,
220    lookups: &TriageLookupCache<'_>,
221    weight_adjustments: &HashMap<String, f64>,
222) -> ImpactScore {
223    // 1. PageRank (graph centrality)
224    let pr_raw = metrics.pagerank.get(&issue.id).copied().unwrap_or_default();
225    let pr_norm = pr_raw / ctx.max_pagerank;
226
227    // 2. Betweenness (bridge importance)
228    let bt_raw = metrics
229        .betweenness
230        .get(&issue.id)
231        .copied()
232        .unwrap_or_default();
233    let bt_norm = bt_raw / ctx.max_betweenness;
234
235    // 3. BlockerRatio (fraction of open issues this blocks)
236    let blocks = metrics
237        .blocks_count
238        .get(&issue.id)
239        .copied()
240        .unwrap_or_default();
241    let blocker_ratio_raw = blocks as f64;
242    let blocker_ratio_norm = if ctx.total_open > 1 {
243        blocker_ratio_raw / ctx.max_blocks
244    } else {
245        0.0
246    };
247
248    // 4. Staleness (how long since last update — stale items get deprioritized)
249    let updated_ts = issue
250        .updated_at
251        .map(|dt| dt.timestamp())
252        .unwrap_or(ctx.now_ts);
253    const SECS_PER_DAY: f64 = 86_400.0;
254    let days_stale = ((ctx.now_ts - updated_ts) as f64 / SECS_PER_DAY).max(0.0);
255    // Inverse staleness: recently updated items score higher.  Cap at 90 days.
256    let staleness_norm = 1.0 - (days_stale / 90.0).min(1.0);
257
258    // 5. PriorityBoost (declared priority)
259    let priority_norm = issue.priority_normalized();
260
261    // 6. TimeToImpact (how quickly completion propagates value)
262    let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0);
263    let max_depth = metrics
264        .critical_depth
265        .values()
266        .copied()
267        .max()
268        .unwrap_or(1)
269        .max(1);
270    // Higher depth = root blocker = faster propagation of value.
271    let tti_raw = depth as f64;
272    let tti_norm = tti_raw / max_depth as f64;
273    let tti_explanation = if depth > 0 {
274        Some(format!(
275            "critical depth {depth}/{max_depth} — completing this unlocks a chain of {blocks} issue(s)"
276        ))
277    } else {
278        None
279    };
280
281    // 7. Urgency (status-based urgency signal)
282    let (urgency_norm, urgency_explanation) = match issue.normalized_status().as_str() {
283        "in_progress" => (1.0, Some("actively being worked on".to_string())),
284        "open" => (0.8, None),
285        "review" => (0.7, Some("awaiting review".to_string())),
286        "blocked" => (0.5, Some("blocked — resolve blockers first".to_string())),
287        "deferred" | "pinned" => (0.3, Some("deferred/pinned — lower urgency".to_string())),
288        _ => (0.6, None),
289    };
290
291    // 8. Risk (signals that increase execution risk)
292    let open_blockers = lookups
293        .open_blocker_count_by_issue
294        .get(issue.id.as_str())
295        .copied()
296        .unwrap_or_default();
297    let is_in_cycle = lookups.issues_in_cycles.contains(issue.id.as_str());
298    let is_articulation = metrics.articulation_points.contains(issue.id.as_str());
299    let mut risk_signals = Vec::new();
300    let mut risk_raw = 0.0_f64;
301    if open_blockers > 0 {
302        risk_raw += 0.3;
303        risk_signals.push(format!("{open_blockers} open blocker(s)"));
304    }
305    if is_in_cycle {
306        risk_raw += 0.4;
307        risk_signals.push("part of a dependency cycle".to_string());
308    }
309    if is_articulation {
310        risk_raw += 0.3;
311        risk_signals.push("articulation point (removing breaks graph)".to_string());
312    }
313    let risk_norm = risk_raw.min(1.0);
314    // For scoring, LOWER risk is better — invert so low-risk items score higher.
315    let risk_benefit = 1.0 - risk_norm;
316    let risk_explanation = if risk_signals.is_empty() {
317        None
318    } else {
319        Some(risk_signals.join("; "))
320    };
321
322    // Helper: look up feedback adjustment for a component (default 1.0 = no change).
323    let adj = |name: &str| -> f64 {
324        weight_adjustments
325            .get(name)
326            .copied()
327            .unwrap_or(1.0)
328            .clamp(0.5, 2.0)
329    };
330
331    // Assemble components with feedback-adjusted weights.
332    let w_pr = W_PAGERANK * adj("PageRank");
333    let w_bt = W_BETWEENNESS * adj("Betweenness");
334    let weight_blocker_ratio = W_BLOCKER_RATIO * adj("BlockerRatio");
335    let weight_staleness = W_STALENESS * adj("Staleness");
336    let weight_priority_boost = W_PRIORITY_BOOST * adj("PriorityBoost");
337    let w_tti = W_TIME_TO_IMPACT * adj("TimeToImpact");
338    let w_urg = W_URGENCY * adj("Urgency");
339    let w_risk = W_RISK * adj("Risk");
340
341    // Renormalize so adjusted weights still sum to 1.0.
342    let w_sum = w_pr
343        + w_bt
344        + weight_blocker_ratio
345        + weight_staleness
346        + weight_priority_boost
347        + w_tti
348        + w_urg
349        + w_risk;
350    let norm = if w_sum > 0.0 { 1.0 / w_sum } else { 1.0 };
351
352    let components = vec![
353        ScoreComponent {
354            name: "PageRank",
355            weight: w_pr * norm,
356            raw: pr_raw,
357            normalized: pr_norm,
358            weighted: w_pr * norm * pr_norm,
359            explanation: None,
360        },
361        ScoreComponent {
362            name: "Betweenness",
363            weight: w_bt * norm,
364            raw: bt_raw,
365            normalized: bt_norm,
366            weighted: w_bt * norm * bt_norm,
367            explanation: None,
368        },
369        ScoreComponent {
370            name: "BlockerRatio",
371            weight: weight_blocker_ratio * norm,
372            raw: blocker_ratio_raw,
373            normalized: blocker_ratio_norm,
374            weighted: weight_blocker_ratio * norm * blocker_ratio_norm,
375            explanation: if blocks > 0 {
376                Some(format!("blocks {blocks} issue(s)"))
377            } else {
378                None
379            },
380        },
381        ScoreComponent {
382            name: "Staleness",
383            weight: weight_staleness * norm,
384            raw: days_stale,
385            normalized: staleness_norm,
386            weighted: weight_staleness * norm * staleness_norm,
387            explanation: if days_stale > 30.0 {
388                Some(format!("stale: {days_stale:.0} days since last update"))
389            } else {
390                None
391            },
392        },
393        ScoreComponent {
394            name: "PriorityBoost",
395            weight: weight_priority_boost * norm,
396            raw: issue.priority as f64,
397            normalized: priority_norm,
398            weighted: weight_priority_boost * norm * priority_norm,
399            explanation: None,
400        },
401        ScoreComponent {
402            name: "TimeToImpact",
403            weight: w_tti * norm,
404            raw: tti_raw,
405            normalized: tti_norm,
406            weighted: w_tti * norm * tti_norm,
407            explanation: tti_explanation,
408        },
409        ScoreComponent {
410            name: "Urgency",
411            weight: w_urg * norm,
412            raw: urgency_norm, // urgency is already a normalized signal
413            normalized: urgency_norm,
414            weighted: w_urg * norm * urgency_norm,
415            explanation: urgency_explanation,
416        },
417        ScoreComponent {
418            name: "Risk",
419            weight: w_risk * norm,
420            raw: risk_norm,
421            normalized: risk_benefit,
422            weighted: w_risk * norm * risk_benefit,
423            explanation: risk_explanation,
424        },
425    ];
426
427    let score: f64 = components.iter().map(|c| c.weighted).sum();
428
429    ImpactScore {
430        issue_id: issue.id.clone(),
431        score: score.clamp(0.0, 1.0),
432        breakdown: components,
433    }
434}
435
436// ---------------------------------------------------------------------------
437// Original triage types
438// ---------------------------------------------------------------------------
439
440#[derive(Debug, Clone, Serialize)]
441pub struct QuickPick {
442    pub id: String,
443    pub title: String,
444    pub score: f64,
445    pub reasons: Vec<String>,
446    pub unblocks: usize,
447}
448
449#[derive(Debug, Clone, Serialize)]
450pub struct Recommendation {
451    pub id: String,
452    pub title: String,
453    #[serde(rename = "type")]
454    pub issue_type: String,
455    pub status: String,
456    pub priority: i32,
457    pub labels: Vec<String>,
458    pub score: f64,
459    pub impact_score: f64,
460    pub confidence: f64,
461    pub action: String,
462    pub reasons: Vec<String>,
463    pub unblocks: usize,
464    #[serde(skip_serializing_if = "Vec::is_empty")]
465    pub unblocks_ids: Vec<String>,
466    #[serde(skip_serializing_if = "Vec::is_empty")]
467    pub blocked_by: Vec<String>,
468    pub assignee: String,
469    pub claim_command: String,
470    pub show_command: String,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub breakdown: Option<Vec<ScoreComponent>>,
473}
474
475#[derive(Debug, Clone, Serialize)]
476pub struct BlockerToClear {
477    pub id: String,
478    pub title: String,
479    pub status: String,
480    pub unblocks: usize,
481}
482
483#[derive(Debug, Clone, Serialize)]
484pub struct RecommendationsByTrack {
485    pub track_id: String,
486    pub top_pick: Option<Recommendation>,
487    pub item_ids: Vec<String>,
488}
489
490#[derive(Debug, Clone, Serialize)]
491pub struct RecommendationsByLabel {
492    pub label: String,
493    pub top_pick: Option<Recommendation>,
494    pub item_ids: Vec<String>,
495}
496
497#[derive(Debug, Clone, Serialize)]
498pub struct QuickRef {
499    pub total_open: usize,
500    pub total_actionable: usize,
501    pub open_count: usize,
502    pub actionable_count: usize,
503    pub blocked_count: usize,
504    pub in_progress_count: usize,
505    pub top_picks: Vec<QuickPick>,
506}
507
508#[derive(Debug, Clone, Serialize)]
509pub struct ProjectHealthCounts {
510    pub total: usize,
511    pub open: usize,
512    pub closed: usize,
513    pub actionable: usize,
514    pub blocked: usize,
515    pub by_status: BTreeMap<String, usize>,
516    pub by_priority: BTreeMap<i32, usize>,
517    pub by_type: BTreeMap<String, usize>,
518}
519
520#[derive(Debug, Clone, Serialize)]
521pub struct ProjectHealthGraph {
522    pub node_count: usize,
523    pub edge_count: usize,
524    pub density: f64,
525    pub has_cycles: bool,
526    pub cycle_count: usize,
527    pub phase2_ready: bool,
528}
529
530#[derive(Debug, Clone, Serialize)]
531pub struct WeeklyClosureCount {
532    pub week_start: chrono::DateTime<Utc>,
533    pub closed: usize,
534}
535
536#[derive(Debug, Clone, Serialize)]
537pub struct ProjectHealthVelocity {
538    pub closed_last_7_days: usize,
539    pub closed_last_30_days: usize,
540    pub avg_days_to_close: f64,
541    pub weekly: Vec<WeeklyClosureCount>,
542}
543
544#[derive(Debug, Clone, Serialize)]
545pub struct ProjectHealth {
546    pub counts: ProjectHealthCounts,
547    pub graph: ProjectHealthGraph,
548    pub velocity: ProjectHealthVelocity,
549}
550
551#[derive(Debug, Clone, Serialize)]
552pub struct TriageResult {
553    pub meta: TriageMeta,
554    pub quick_ref: QuickRef,
555    pub recommendations: Vec<Recommendation>,
556    pub quick_wins: Vec<Recommendation>,
557    pub blockers_to_clear: Vec<BlockerToClear>,
558    pub recommendations_by_track: Vec<RecommendationsByTrack>,
559    pub recommendations_by_label: Vec<RecommendationsByLabel>,
560    pub project_health: ProjectHealth,
561    pub commands: TriageCommands,
562}
563
564#[derive(Debug, Clone, Serialize)]
565pub struct TriageMeta {
566    pub version: &'static str,
567    pub generated_at: String,
568    pub phase2_ready: bool,
569    pub issue_count: usize,
570    pub compute_time_ms: u64,
571}
572
573#[derive(Debug, Clone, Serialize)]
574pub struct TriageCommands {
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub claim_top: Option<String>,
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub show_top: Option<String>,
579    pub list_ready: String,
580    pub list_blocked: String,
581    pub refresh_triage: String,
582}
583
584/// Configurable scoring weights and thresholds for triage (matches Go's TriageScoringOptions).
585#[derive(Debug, Clone, Serialize)]
586pub struct TriageScoringOptions {
587    /// Weight for the base ImpactScore (0.0–1.0).
588    pub base_score_weight: f64,
589    /// Weight for the unblock boost (0.0–1.0).
590    pub unblock_boost_weight: f64,
591    /// Weight for the quick-win bonus (0.0–1.0).
592    pub quick_win_weight: f64,
593    /// Issues that unblock >= this many others get the full unblock boost.
594    pub unblock_threshold: usize,
595    /// Issues with critical_depth <= this are considered quick wins.
596    pub quick_win_max_depth: usize,
597    /// Phase 2: incorporate label health into scoring.
598    pub enable_label_health: bool,
599    /// Phase 3: penalize items already claimed by another agent.
600    pub enable_claim_penalty: bool,
601    /// Phase 4: boost items in high-attention labels.
602    pub enable_attention_score: bool,
603    /// Identity of the agent computing triage (for claim penalty).
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub claimed_by_agent: Option<String>,
606    /// Per-component weight multipliers from feedback (e.g. "PageRank" -> 1.1).
607    /// Applied multiplicatively to the default component weights during scoring.
608    #[serde(skip_serializing_if = "HashMap::is_empty")]
609    pub weight_adjustments: HashMap<String, f64>,
610}
611
612impl Default for TriageScoringOptions {
613    fn default() -> Self {
614        Self {
615            base_score_weight: 0.70,
616            unblock_boost_weight: 0.15,
617            quick_win_weight: 0.15,
618            unblock_threshold: 5,
619            quick_win_max_depth: 2,
620            enable_label_health: false,
621            enable_claim_penalty: false,
622            enable_attention_score: false,
623            claimed_by_agent: None,
624            weight_adjustments: HashMap::new(),
625        }
626    }
627}
628
629#[derive(Debug, Clone, Default)]
630pub struct TriageOptions {
631    pub group_by_track: bool,
632    pub group_by_label: bool,
633    pub max_recommendations: usize,
634    pub scoring: TriageScoringOptions,
635}
636
637#[derive(Debug, Clone)]
638pub struct TriageComputation {
639    pub result: TriageResult,
640    pub score_by_id: HashMap<String, f64>,
641}
642
643pub fn compute_triage(
644    issues: &[Issue],
645    graph: &IssueGraph,
646    metrics: &GraphMetrics,
647    options: &TriageOptions,
648) -> TriageComputation {
649    let max_recommendations = if options.max_recommendations == 0 {
650        50
651    } else {
652        options.max_recommendations
653    };
654
655    let actionable: HashSet<String> = graph.actionable_ids().into_iter().collect();
656    let total_open = issues.iter().filter(|issue| issue.is_open_like()).count();
657
658    let ctx = ScoringContext::from_metrics(metrics, total_open);
659    let lookups = TriageLookupCache::new(issues, graph, metrics);
660
661    let mut recommendations = Vec::<Recommendation>::new();
662    let mut score_by_id = HashMap::<String, f64>::new();
663
664    let scoring = &options.scoring;
665
666    for issue in issues.iter().filter(|issue| actionable.contains(&issue.id)) {
667        let impact =
668            compute_impact_score(issue, metrics, &ctx, &lookups, &scoring.weight_adjustments);
669        let base_score = impact.score;
670
671        let unblocks = metrics
672            .blocks_count
673            .get(&issue.id)
674            .copied()
675            .unwrap_or_default();
676
677        // Unblock boost: scales linearly up to the threshold then caps at 1.0.
678        let unblock_boost = if scoring.unblock_threshold > 0 && unblocks > 0 {
679            (unblocks as f64 / scoring.unblock_threshold as f64).min(1.0)
680        } else {
681            0.0
682        };
683
684        // Quick-win bonus: low-depth, high-priority items that can be done fast.
685        let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0);
686        let quick_win_bonus = if depth <= scoring.quick_win_max_depth
687            && issue.priority <= 2
688            && issue.estimated_minutes.is_none_or(|m| m <= 120)
689        {
690            1.0
691        } else {
692            0.0
693        };
694
695        // Composite triage score.
696        let score = (scoring.base_score_weight * base_score
697            + scoring.unblock_boost_weight * unblock_boost
698            + scoring.quick_win_weight * quick_win_bonus)
699            .clamp(0.0, 1.0);
700
701        // Build human-readable reasons from the top contributing components.
702        let mut reasons = Vec::<String>::new();
703        let pr_norm = impact.breakdown[0].normalized;
704        if pr_norm > 0.6 {
705            reasons.push("high graph centrality".to_string());
706        }
707        if unblocks > 0 {
708            reasons.push(format!("unblocks {unblocks} issues"));
709        }
710        if issue.priority <= 2 {
711            reasons.push("high declared priority".to_string());
712        }
713        if unblock_boost > 0.5 {
714            reasons.push(format!("unblock boost {:.0}%", unblock_boost * 100.0));
715        }
716        if quick_win_bonus > 0.0 {
717            reasons.push("quick win candidate".to_string());
718        }
719        if reasons.is_empty() {
720            reasons.push("ready to execute now".to_string());
721        }
722
723        score_by_id.insert(issue.id.clone(), score);
724
725        let open_blocker_ids: Vec<String> = graph.open_blockers(&issue.id).into_iter().collect();
726        let unblocks_ids: Vec<String> = graph
727            .dependents(&issue.id)
728            .into_iter()
729            .filter(|dep_id| graph.issue(dep_id).is_some_and(Issue::is_open_like))
730            .collect();
731
732        let action = if !open_blocker_ids.is_empty() {
733            format!(
734                "Work on {} first to unblock this",
735                open_blocker_ids.first().unwrap_or(&String::new())
736            )
737        } else if issue.normalized_status() == "in_progress" {
738            "Continue work on this issue".to_string()
739        } else {
740            "Start work on this issue".to_string()
741        };
742
743        recommendations.push(Recommendation {
744            id: issue.id.clone(),
745            title: issue.title.clone(),
746            issue_type: issue.issue_type.clone(),
747            status: issue.status.clone(),
748            priority: issue.priority,
749            labels: issue.labels.clone(),
750            score,
751            impact_score: base_score,
752            confidence: (0.5 + 0.5 * score).clamp(0.0, 1.0),
753            action,
754            reasons,
755            unblocks,
756            unblocks_ids,
757            blocked_by: open_blocker_ids,
758            assignee: issue.assignee.clone(),
759            claim_command: format!("br update {} --status=in_progress", issue.id),
760            show_command: format!("br show {}", issue.id),
761            breakdown: Some(impact.breakdown),
762        });
763    }
764
765    recommendations.sort_by(|left, right| {
766        right
767            .score
768            .total_cmp(&left.score)
769            .then_with(|| left.id.cmp(&right.id))
770    });
771    recommendations.truncate(max_recommendations);
772
773    // Top picks exclude in_progress issues (already being worked) to match
774    // legacy behavior: recommendations should surface NEW work to pick up.
775    let top_picks: Vec<QuickPick> = recommendations
776        .iter()
777        .filter(|rec| {
778            lookups
779                .issue_by_id
780                .get(rec.id.as_str())
781                .is_none_or(|issue| issue.normalized_status() != "in_progress")
782        })
783        .take(3)
784        .map(|rec| QuickPick {
785            id: rec.id.clone(),
786            title: rec.title.clone(),
787            score: rec.score,
788            reasons: rec.reasons.clone(),
789            unblocks: rec.unblocks,
790        })
791        .collect();
792
793    let quick_wins = recommendations
794        .iter()
795        .filter(|rec| {
796            lookups
797                .issue_by_id
798                .get(rec.id.as_str())
799                .is_some_and(|issue| {
800                    issue.estimated_minutes.is_some_and(|mins| mins <= 120)
801                        || (issue.priority <= 2 && rec.unblocks > 0)
802                })
803        })
804        .take(10)
805        .cloned()
806        .collect();
807
808    let blockers_to_clear = compute_blockers_to_clear(issues, metrics, &actionable, &lookups);
809
810    let recommendations_by_track = if options.group_by_track {
811        compute_recommendations_by_track(graph, &recommendations)
812    } else {
813        Vec::new()
814    };
815
816    let recommendations_by_label = if options.group_by_label {
817        compute_recommendations_by_label(&recommendations)
818    } else {
819        Vec::new()
820    };
821
822    let blocked_count = total_open.saturating_sub(actionable.len());
823    let in_progress_count = issues
824        .iter()
825        .filter(|issue| issue.is_open_like() && issue.normalized_status() == "in_progress")
826        .count();
827
828    let claim_top = top_picks
829        .first()
830        .map(|pick| format!("CI=1 br update {} --status in_progress --json", pick.id));
831    let show_top = top_picks
832        .first()
833        .map(|pick| format!("CI=1 br show {} --json", pick.id));
834
835    let result = TriageResult {
836        meta: TriageMeta {
837            version: env!("CARGO_PKG_VERSION"),
838            generated_at: chrono::Utc::now().to_rfc3339(),
839            phase2_ready: true,
840            issue_count: issues.len(),
841            compute_time_ms: 0,
842        },
843        quick_ref: QuickRef {
844            total_open,
845            total_actionable: actionable.len(),
846            open_count: total_open,
847            actionable_count: actionable.len(),
848            blocked_count,
849            in_progress_count,
850            top_picks,
851        },
852        recommendations,
853        quick_wins,
854        blockers_to_clear,
855        recommendations_by_track,
856        recommendations_by_label,
857        project_health: compute_project_health(
858            issues,
859            graph,
860            metrics,
861            total_open,
862            actionable.len(),
863        ),
864        commands: TriageCommands {
865            claim_top,
866            show_top,
867            list_ready: "CI=1 br ready --json".to_string(),
868            list_blocked: "CI=1 br blocked --json".to_string(),
869            refresh_triage: "bvr --robot-triage".to_string(),
870        },
871    };
872
873    TriageComputation {
874        result,
875        score_by_id,
876    }
877}
878
879fn compute_project_health(
880    issues: &[Issue],
881    graph: &IssueGraph,
882    metrics: &GraphMetrics,
883    total_open: usize,
884    actionable_count: usize,
885) -> ProjectHealth {
886    let mut by_status = BTreeMap::<String, usize>::new();
887    let mut by_priority = BTreeMap::<i32, usize>::new();
888    let mut by_type = BTreeMap::<String, usize>::new();
889    let mut closed_count = 0usize;
890    let mut in_progress_count = 0usize;
891    let mut closed_last_7_days = 0usize;
892    let mut closed_last_30_days = 0usize;
893    let mut total_close_days = 0.0_f64;
894    let mut close_samples = 0usize;
895    let now = Utc::now();
896    let week_starts = recent_week_starts(now, 8);
897    let mut weekly = week_starts
898        .iter()
899        .copied()
900        .map(|week_start| WeeklyClosureCount {
901            week_start,
902            closed: 0,
903        })
904        .collect::<Vec<_>>();
905
906    for issue in issues {
907        *by_status.entry(issue.normalized_status()).or_default() += 1;
908        *by_priority.entry(issue.priority).or_default() += 1;
909        *by_type.entry(issue.issue_type.clone()).or_default() += 1;
910
911        if issue.is_open_like() && issue.normalized_status() == "in_progress" {
912            in_progress_count = in_progress_count.saturating_add(1);
913        }
914
915        if !issue.is_closed_like() {
916            continue;
917        }
918
919        closed_count = closed_count.saturating_add(1);
920
921        let Some(closed_at) = issue.closed_at.or(issue.updated_at) else {
922            continue;
923        };
924
925        if closed_at >= now - Duration::days(7) {
926            closed_last_7_days = closed_last_7_days.saturating_add(1);
927        }
928        if closed_at >= now - Duration::days(30) {
929            closed_last_30_days = closed_last_30_days.saturating_add(1);
930        }
931        if let Some(created_at) = issue.created_at {
932            total_close_days += (closed_at - created_at).num_hours() as f64 / 24.0;
933            close_samples = close_samples.saturating_add(1);
934        }
935
936        for bucket in &mut weekly {
937            let week_end = bucket.week_start + Duration::days(7);
938            if closed_at >= bucket.week_start && closed_at < week_end {
939                bucket.closed = bucket.closed.saturating_add(1);
940                break;
941            }
942        }
943    }
944
945    let blocked_count = total_open.saturating_sub(actionable_count);
946    let node_count = graph.node_count();
947    let edge_count = graph.edge_count();
948    let density = if node_count <= 1 {
949        0.0
950    } else {
951        edge_count as f64 / (node_count * (node_count - 1)) as f64
952    };
953
954    ProjectHealth {
955        counts: ProjectHealthCounts {
956            total: issues.len(),
957            open: total_open,
958            closed: closed_count,
959            actionable: actionable_count,
960            blocked: blocked_count,
961            by_status,
962            by_priority,
963            by_type,
964        },
965        graph: ProjectHealthGraph {
966            node_count,
967            edge_count,
968            density,
969            has_cycles: !metrics.cycles.is_empty(),
970            cycle_count: metrics.cycles.len(),
971            phase2_ready: triage_phase2_ready(metrics),
972        },
973        velocity: ProjectHealthVelocity {
974            closed_last_7_days,
975            closed_last_30_days,
976            avg_days_to_close: if close_samples == 0 {
977                0.0
978            } else {
979                total_close_days / close_samples as f64
980            },
981            weekly,
982        },
983    }
984}
985
986fn triage_phase2_ready(metrics: &GraphMetrics) -> bool {
987    !metrics
988        .skipped_metrics
989        .iter()
990        .any(|metric| metric.metric == "Betweenness")
991}
992
993fn recent_week_starts(now: chrono::DateTime<Utc>, count: usize) -> Vec<chrono::DateTime<Utc>> {
994    let weekday_offset = match now.weekday() {
995        Weekday::Mon => 0,
996        Weekday::Tue => 1,
997        Weekday::Wed => 2,
998        Weekday::Thu => 3,
999        Weekday::Fri => 4,
1000        Weekday::Sat => 5,
1001        Weekday::Sun => 6,
1002    };
1003    let start_of_week = now.date_naive().and_hms_opt(0, 0, 0).unwrap_or_default()
1004        - Duration::days(i64::from(weekday_offset));
1005
1006    (0..count)
1007        .map(|index| {
1008            chrono::DateTime::<Utc>::from_naive_utc_and_offset(
1009                start_of_week - Duration::days((index * 7) as i64),
1010                Utc,
1011            )
1012        })
1013        .collect()
1014}
1015
1016fn compute_blockers_to_clear(
1017    issues: &[Issue],
1018    metrics: &GraphMetrics,
1019    actionable: &HashSet<String>,
1020    lookups: &TriageLookupCache<'_>,
1021) -> Vec<BlockerToClear> {
1022    let mut blockers = Vec::<BlockerToClear>::new();
1023
1024    for issue in issues
1025        .iter()
1026        .filter(|issue| issue.is_open_like() && !actionable.contains(&issue.id))
1027    {
1028        let unblocks = metrics
1029            .blocks_count
1030            .get(&issue.id)
1031            .copied()
1032            .unwrap_or_default();
1033        if unblocks == 0 {
1034            continue;
1035        }
1036        if lookups
1037            .open_blocker_count_by_issue
1038            .get(issue.id.as_str())
1039            .copied()
1040            .unwrap_or_default()
1041            == 0
1042        {
1043            continue;
1044        }
1045
1046        blockers.push(BlockerToClear {
1047            id: issue.id.clone(),
1048            title: issue.title.clone(),
1049            status: issue.status.clone(),
1050            unblocks,
1051        });
1052    }
1053
1054    blockers.sort_by(|left, right| {
1055        right
1056            .unblocks
1057            .cmp(&left.unblocks)
1058            .then_with(|| left.id.cmp(&right.id))
1059    });
1060    blockers.truncate(15);
1061    blockers
1062}
1063
1064fn compute_recommendations_by_track(
1065    graph: &IssueGraph,
1066    recommendations: &[Recommendation],
1067) -> Vec<RecommendationsByTrack> {
1068    let component_lookup = graph.connected_open_components();
1069    let rec_by_id: HashMap<&str, &Recommendation> = recommendations
1070        .iter()
1071        .map(|rec| (rec.id.as_str(), rec))
1072        .collect();
1073
1074    let mut by_track = Vec::<RecommendationsByTrack>::new();
1075
1076    for (index, component) in component_lookup.iter().enumerate() {
1077        let mut items: Vec<&Recommendation> = component
1078            .iter()
1079            .filter_map(|id| rec_by_id.get(id.as_str()).copied())
1080            .collect();
1081
1082        items.sort_by(|left, right| {
1083            right
1084                .score
1085                .total_cmp(&left.score)
1086                .then_with(|| left.id.cmp(&right.id))
1087        });
1088
1089        if items.is_empty() {
1090            continue;
1091        }
1092
1093        by_track.push(RecommendationsByTrack {
1094            track_id: format!("track-{}", index + 1),
1095            top_pick: items.first().map(|item| (*item).clone()),
1096            item_ids: items.into_iter().map(|item| item.id.clone()).collect(),
1097        });
1098    }
1099
1100    by_track
1101}
1102
1103fn compute_recommendations_by_label(
1104    recommendations: &[Recommendation],
1105) -> Vec<RecommendationsByLabel> {
1106    let mut groups: BTreeMap<String, Vec<Recommendation>> = BTreeMap::new();
1107
1108    for rec in recommendations {
1109        for label in &rec.labels {
1110            groups.entry(label.clone()).or_default().push(rec.clone());
1111        }
1112    }
1113
1114    let mut out = Vec::<RecommendationsByLabel>::new();
1115    for (label, mut recs) in groups {
1116        recs.sort_by(|left, right| {
1117            right
1118                .score
1119                .total_cmp(&left.score)
1120                .then_with(|| left.id.cmp(&right.id))
1121        });
1122
1123        out.push(RecommendationsByLabel {
1124            label,
1125            top_pick: recs.first().cloned(),
1126            item_ids: recs.into_iter().map(|rec| rec.id).collect(),
1127        });
1128    }
1129
1130    out
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use std::collections::HashMap;
1136
1137    use crate::analysis::graph::{GraphMetrics, IssueGraph};
1138    use crate::model::Issue;
1139
1140    use super::{TriageOptions, TriageScoringOptions, WeightPreset, compute_triage};
1141
1142    fn test_lookups<'a>(
1143        issues: &'a [Issue],
1144        graph: &IssueGraph,
1145        metrics: &'a GraphMetrics,
1146    ) -> super::TriageLookupCache<'a> {
1147        super::TriageLookupCache::new(issues, graph, metrics)
1148    }
1149
1150    #[test]
1151    fn triage_produces_recommendations() {
1152        let issues = vec![
1153            Issue {
1154                id: "A".to_string(),
1155                title: "Root".to_string(),
1156                status: "open".to_string(),
1157                issue_type: "task".to_string(),
1158                priority: 1,
1159                ..Issue::default()
1160            },
1161            Issue {
1162                id: "B".to_string(),
1163                title: "Depends on A".to_string(),
1164                status: "blocked".to_string(),
1165                issue_type: "task".to_string(),
1166                priority: 2,
1167                dependencies: vec![crate::model::Dependency {
1168                    depends_on_id: "A".to_string(),
1169                    dep_type: "blocks".to_string(),
1170                    ..crate::model::Dependency::default()
1171                }],
1172                ..Issue::default()
1173            },
1174        ];
1175
1176        let graph = IssueGraph::build(&issues);
1177        let metrics = graph.compute_metrics();
1178
1179        let triage = compute_triage(
1180            &issues,
1181            &graph,
1182            &metrics,
1183            &TriageOptions {
1184                group_by_track: true,
1185                group_by_label: true,
1186                max_recommendations: 10,
1187                ..TriageOptions::default()
1188            },
1189        );
1190
1191        assert_eq!(triage.result.quick_ref.total_open, 2);
1192        assert_eq!(triage.result.quick_ref.total_actionable, 1);
1193        assert_eq!(triage.result.quick_ref.open_count, 2);
1194        assert_eq!(triage.result.quick_ref.actionable_count, 1);
1195        assert_eq!(triage.result.quick_ref.blocked_count, 1);
1196        assert_eq!(triage.result.quick_ref.in_progress_count, 0);
1197        assert_eq!(triage.result.recommendations.len(), 1);
1198        assert_eq!(triage.result.recommendations[0].id, "A");
1199        assert_eq!(triage.result.project_health.counts.total, 2);
1200        assert_eq!(triage.result.project_health.counts.open, 2);
1201        assert_eq!(triage.result.project_health.counts.blocked, 1);
1202        assert_eq!(triage.result.project_health.graph.node_count, 2);
1203        assert_eq!(triage.result.project_health.graph.edge_count, 1);
1204        assert!(triage.result.project_health.graph.phase2_ready);
1205    }
1206
1207    #[test]
1208    fn triage_empty_issues_produces_zero_recommendations() {
1209        let issues: Vec<Issue> = vec![];
1210        let graph = IssueGraph::build(&issues);
1211        let metrics = graph.compute_metrics();
1212        let triage = compute_triage(
1213            &issues,
1214            &graph,
1215            &metrics,
1216            &TriageOptions {
1217                group_by_track: false,
1218                group_by_label: false,
1219                max_recommendations: 10,
1220                ..TriageOptions::default()
1221            },
1222        );
1223        assert_eq!(triage.result.quick_ref.total_open, 0);
1224        assert_eq!(triage.result.quick_ref.total_actionable, 0);
1225        assert_eq!(triage.result.quick_ref.open_count, 0);
1226        assert_eq!(triage.result.quick_ref.actionable_count, 0);
1227        assert_eq!(triage.result.quick_ref.blocked_count, 0);
1228        assert_eq!(triage.result.quick_ref.in_progress_count, 0);
1229        assert!(triage.result.recommendations.is_empty());
1230        assert!(triage.result.blockers_to_clear.is_empty());
1231        assert_eq!(triage.result.project_health.counts.total, 0);
1232        assert_eq!(triage.result.project_health.velocity.weekly.len(), 8);
1233    }
1234
1235    #[test]
1236    fn triage_all_closed_produces_no_actionable() {
1237        let issues = vec![
1238            Issue {
1239                id: "A".to_string(),
1240                title: "Done".to_string(),
1241                status: "closed".to_string(),
1242                issue_type: "task".to_string(),
1243                ..Issue::default()
1244            },
1245            Issue {
1246                id: "B".to_string(),
1247                title: "Also done".to_string(),
1248                status: "tombstone".to_string(),
1249                issue_type: "task".to_string(),
1250                ..Issue::default()
1251            },
1252        ];
1253        let graph = IssueGraph::build(&issues);
1254        let metrics = graph.compute_metrics();
1255        let triage = compute_triage(
1256            &issues,
1257            &graph,
1258            &metrics,
1259            &TriageOptions {
1260                group_by_track: false,
1261                group_by_label: false,
1262                max_recommendations: 10,
1263                ..TriageOptions::default()
1264            },
1265        );
1266        assert_eq!(triage.result.quick_ref.total_open, 0);
1267        assert_eq!(triage.result.quick_ref.open_count, 0);
1268        assert!(triage.result.recommendations.is_empty());
1269    }
1270
1271    #[test]
1272    fn triage_includes_actionable_in_progress_items() {
1273        let issues = vec![
1274            Issue {
1275                id: "A".to_string(),
1276                title: "Already claimed blocker".to_string(),
1277                status: "in_progress".to_string(),
1278                issue_type: "task".to_string(),
1279                priority: 1,
1280                ..Issue::default()
1281            },
1282            Issue {
1283                id: "B".to_string(),
1284                title: "Blocked by A".to_string(),
1285                status: "blocked".to_string(),
1286                issue_type: "task".to_string(),
1287                priority: 2,
1288                dependencies: vec![crate::model::Dependency {
1289                    depends_on_id: "A".to_string(),
1290                    dep_type: "blocks".to_string(),
1291                    ..crate::model::Dependency::default()
1292                }],
1293                ..Issue::default()
1294            },
1295        ];
1296        let graph = IssueGraph::build(&issues);
1297        let metrics = graph.compute_metrics();
1298        let triage = compute_triage(
1299            &issues,
1300            &graph,
1301            &metrics,
1302            &TriageOptions {
1303                max_recommendations: 10,
1304                ..TriageOptions::default()
1305            },
1306        );
1307
1308        assert_eq!(triage.result.recommendations.len(), 1);
1309        assert_eq!(triage.result.recommendations[0].id, "A");
1310        assert_eq!(triage.result.recommendations[0].status, "in_progress");
1311        // top_picks excludes in_progress issues (already being worked) to match
1312        // legacy behavior where recommendations surface new work to pick up.
1313        assert!(
1314            triage.result.quick_ref.top_picks.is_empty(),
1315            "top_picks should exclude in_progress items"
1316        );
1317    }
1318
1319    #[test]
1320    fn triage_max_recommendations_limits_output() {
1321        let issues: Vec<Issue> = (0..20)
1322            .map(|i| Issue {
1323                id: format!("X-{i}"),
1324                title: format!("Issue {i}"),
1325                status: "open".to_string(),
1326                issue_type: "task".to_string(),
1327                priority: 1,
1328                ..Issue::default()
1329            })
1330            .collect();
1331        let graph = IssueGraph::build(&issues);
1332        let metrics = graph.compute_metrics();
1333        let triage = compute_triage(
1334            &issues,
1335            &graph,
1336            &metrics,
1337            &TriageOptions {
1338                group_by_track: false,
1339                group_by_label: false,
1340                max_recommendations: 5,
1341                ..TriageOptions::default()
1342            },
1343        );
1344        assert!(triage.result.recommendations.len() <= 5);
1345    }
1346
1347    #[test]
1348    fn triage_scores_are_sorted_descending() {
1349        let issues: Vec<Issue> = (0..5)
1350            .map(|i| Issue {
1351                id: format!("P-{i}"),
1352                title: format!("Task {i}"),
1353                status: "open".to_string(),
1354                issue_type: "task".to_string(),
1355                priority: i + 1,
1356                ..Issue::default()
1357            })
1358            .collect();
1359        let graph = IssueGraph::build(&issues);
1360        let metrics = graph.compute_metrics();
1361        let triage = compute_triage(
1362            &issues,
1363            &graph,
1364            &metrics,
1365            &TriageOptions {
1366                group_by_track: false,
1367                group_by_label: false,
1368                max_recommendations: 10,
1369                ..TriageOptions::default()
1370            },
1371        );
1372        let scores: Vec<f64> = triage
1373            .result
1374            .recommendations
1375            .iter()
1376            .map(|r| r.score)
1377            .collect();
1378        for w in scores.windows(2) {
1379            assert!(
1380                w[0] >= w[1],
1381                "scores should be descending: {} >= {}",
1382                w[0],
1383                w[1]
1384            );
1385        }
1386    }
1387
1388    #[test]
1389    fn triage_blockers_to_clear_identifies_chained_blockers() {
1390        // Chain: A (open, actionable) blocks B (open, blocked by A, blocks C+D)
1391        // B is not actionable (blocked by A) but blocks C+D => should be in blockers_to_clear
1392        let issues = vec![
1393            Issue {
1394                id: "A".to_string(),
1395                title: "Root".to_string(),
1396                status: "open".to_string(),
1397                issue_type: "task".to_string(),
1398                priority: 1,
1399                ..Issue::default()
1400            },
1401            Issue {
1402                id: "B".to_string(),
1403                title: "Middle".to_string(),
1404                status: "open".to_string(),
1405                issue_type: "task".to_string(),
1406                priority: 2,
1407                dependencies: vec![crate::model::Dependency {
1408                    depends_on_id: "A".to_string(),
1409                    dep_type: "blocks".to_string(),
1410                    ..crate::model::Dependency::default()
1411                }],
1412                ..Issue::default()
1413            },
1414            Issue {
1415                id: "C".to_string(),
1416                title: "Leaf 1".to_string(),
1417                status: "open".to_string(),
1418                issue_type: "task".to_string(),
1419                priority: 3,
1420                dependencies: vec![crate::model::Dependency {
1421                    depends_on_id: "B".to_string(),
1422                    dep_type: "blocks".to_string(),
1423                    ..crate::model::Dependency::default()
1424                }],
1425                ..Issue::default()
1426            },
1427            Issue {
1428                id: "D".to_string(),
1429                title: "Leaf 2".to_string(),
1430                status: "open".to_string(),
1431                issue_type: "task".to_string(),
1432                priority: 3,
1433                dependencies: vec![crate::model::Dependency {
1434                    depends_on_id: "B".to_string(),
1435                    dep_type: "blocks".to_string(),
1436                    ..crate::model::Dependency::default()
1437                }],
1438                ..Issue::default()
1439            },
1440        ];
1441        let graph = IssueGraph::build(&issues);
1442        let metrics = graph.compute_metrics();
1443        let triage = compute_triage(
1444            &issues,
1445            &graph,
1446            &metrics,
1447            &TriageOptions {
1448                group_by_track: false,
1449                group_by_label: false,
1450                max_recommendations: 10,
1451                ..TriageOptions::default()
1452            },
1453        );
1454        // B should be in blockers_to_clear (blocked by A, blocks C+D)
1455        let b_blocker = triage.result.blockers_to_clear.iter().find(|b| b.id == "B");
1456        assert!(
1457            b_blocker.is_some(),
1458            "B should be in blockers_to_clear (got {:?})",
1459            triage.result.blockers_to_clear
1460        );
1461        assert!(
1462            b_blocker.unwrap().unblocks >= 2,
1463            "B should unblock 2+ issues"
1464        );
1465    }
1466
1467    #[test]
1468    fn triage_group_by_label_produces_label_groups() {
1469        let issues = vec![
1470            Issue {
1471                id: "A".to_string(),
1472                title: "UI fix".to_string(),
1473                status: "open".to_string(),
1474                issue_type: "task".to_string(),
1475                labels: vec!["ui".to_string()],
1476                ..Issue::default()
1477            },
1478            Issue {
1479                id: "B".to_string(),
1480                title: "API fix".to_string(),
1481                status: "open".to_string(),
1482                issue_type: "task".to_string(),
1483                labels: vec!["api".to_string()],
1484                ..Issue::default()
1485            },
1486        ];
1487        let graph = IssueGraph::build(&issues);
1488        let metrics = graph.compute_metrics();
1489        let triage = compute_triage(
1490            &issues,
1491            &graph,
1492            &metrics,
1493            &TriageOptions {
1494                group_by_track: false,
1495                group_by_label: true,
1496                max_recommendations: 10,
1497                ..TriageOptions::default()
1498            },
1499        );
1500        assert!(
1501            !triage.result.recommendations_by_label.is_empty(),
1502            "should group by label"
1503        );
1504    }
1505
1506    #[test]
1507    fn triage_omits_top_commands_when_no_actionable_items_exist() {
1508        let issues = vec![Issue {
1509            id: "A".to_string(),
1510            title: "Closed".to_string(),
1511            status: "closed".to_string(),
1512            issue_type: "task".to_string(),
1513            ..Issue::default()
1514        }];
1515        let graph = IssueGraph::build(&issues);
1516        let metrics = graph.compute_metrics();
1517        let triage = compute_triage(&issues, &graph, &metrics, &TriageOptions::default());
1518
1519        assert!(triage.result.commands.claim_top.is_none());
1520        assert!(triage.result.commands.show_top.is_none());
1521        assert_eq!(triage.result.commands.list_ready, "CI=1 br ready --json");
1522    }
1523
1524    // -- ImpactScore tests --
1525
1526    #[test]
1527    fn impact_score_has_8_components() {
1528        let issues = vec![Issue {
1529            id: "A".to_string(),
1530            title: "Root".to_string(),
1531            status: "open".to_string(),
1532            issue_type: "task".to_string(),
1533            priority: 1,
1534            ..Issue::default()
1535        }];
1536        let graph = IssueGraph::build(&issues);
1537        let metrics = graph.compute_metrics();
1538        let ctx = super::ScoringContext::from_metrics(&metrics, 1);
1539        let lookups = test_lookups(&issues, &graph, &metrics);
1540        let score =
1541            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1542
1543        assert_eq!(score.breakdown.len(), 8);
1544        assert_eq!(score.breakdown[0].name, "PageRank");
1545        assert_eq!(score.breakdown[1].name, "Betweenness");
1546        assert_eq!(score.breakdown[2].name, "BlockerRatio");
1547        assert_eq!(score.breakdown[3].name, "Staleness");
1548        assert_eq!(score.breakdown[4].name, "PriorityBoost");
1549        assert_eq!(score.breakdown[5].name, "TimeToImpact");
1550        assert_eq!(score.breakdown[6].name, "Urgency");
1551        assert_eq!(score.breakdown[7].name, "Risk");
1552    }
1553
1554    #[test]
1555    fn impact_score_weights_sum_to_one() {
1556        let total: f64 = [
1557            super::W_PAGERANK,
1558            super::W_BETWEENNESS,
1559            super::W_BLOCKER_RATIO,
1560            super::W_STALENESS,
1561            super::W_PRIORITY_BOOST,
1562            super::W_TIME_TO_IMPACT,
1563            super::W_URGENCY,
1564            super::W_RISK,
1565        ]
1566        .iter()
1567        .sum();
1568        assert!(
1569            (total - 1.0).abs() < 1e-9,
1570            "weights should sum to 1.0, got {total}"
1571        );
1572    }
1573
1574    #[test]
1575    fn impact_score_bounded_zero_one() {
1576        let issues = vec![
1577            Issue {
1578                id: "A".to_string(),
1579                title: "Root".to_string(),
1580                status: "open".to_string(),
1581                issue_type: "task".to_string(),
1582                priority: 1,
1583                ..Issue::default()
1584            },
1585            Issue {
1586                id: "B".to_string(),
1587                title: "Leaf".to_string(),
1588                status: "open".to_string(),
1589                issue_type: "task".to_string(),
1590                priority: 5,
1591                dependencies: vec![crate::model::Dependency {
1592                    depends_on_id: "A".to_string(),
1593                    dep_type: "blocks".to_string(),
1594                    ..crate::model::Dependency::default()
1595                }],
1596                ..Issue::default()
1597            },
1598        ];
1599        let graph = IssueGraph::build(&issues);
1600        let metrics = graph.compute_metrics();
1601        let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1602        let lookups = test_lookups(&issues, &graph, &metrics);
1603
1604        for issue in &issues {
1605            let score =
1606                super::compute_impact_score(issue, &metrics, &ctx, &lookups, &HashMap::new());
1607            assert!(
1608                score.score >= 0.0 && score.score <= 1.0,
1609                "score for {} should be in [0,1], got {}",
1610                issue.id,
1611                score.score
1612            );
1613            for comp in &score.breakdown {
1614                assert!(
1615                    comp.normalized >= 0.0 && comp.normalized <= 1.0,
1616                    "normalized {} for {} should be in [0,1], got {}",
1617                    comp.name,
1618                    issue.id,
1619                    comp.normalized
1620                );
1621            }
1622        }
1623    }
1624
1625    #[test]
1626    fn impact_score_empty_graph() {
1627        let issues: Vec<Issue> = Vec::new();
1628        let graph = IssueGraph::build(&issues);
1629        let metrics = graph.compute_metrics();
1630        let triage = compute_triage(&issues, &graph, &metrics, &TriageOptions::default());
1631        assert!(triage.result.recommendations.is_empty());
1632    }
1633
1634    #[test]
1635    fn impact_score_single_node() {
1636        let issues = vec![Issue {
1637            id: "A".to_string(),
1638            title: "Only".to_string(),
1639            status: "open".to_string(),
1640            issue_type: "task".to_string(),
1641            priority: 2,
1642            ..Issue::default()
1643        }];
1644        let graph = IssueGraph::build(&issues);
1645        let metrics = graph.compute_metrics();
1646        let ctx = super::ScoringContext::from_metrics(&metrics, 1);
1647        let lookups = test_lookups(&issues, &graph, &metrics);
1648        let score =
1649            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1650
1651        // Single node: PageRank=1.0 (normalized to 1.0), no blockers, no risk.
1652        assert!(score.score > 0.0);
1653        assert_eq!(score.breakdown.len(), 8);
1654    }
1655
1656    #[test]
1657    fn impact_score_blocker_scores_higher_than_leaf() {
1658        let issues = vec![
1659            Issue {
1660                id: "A".to_string(),
1661                title: "Root blocker".to_string(),
1662                status: "open".to_string(),
1663                issue_type: "task".to_string(),
1664                priority: 1,
1665                ..Issue::default()
1666            },
1667            Issue {
1668                id: "B".to_string(),
1669                title: "Blocked leaf".to_string(),
1670                status: "open".to_string(),
1671                issue_type: "task".to_string(),
1672                priority: 4,
1673                dependencies: vec![crate::model::Dependency {
1674                    depends_on_id: "A".to_string(),
1675                    dep_type: "blocks".to_string(),
1676                    ..crate::model::Dependency::default()
1677                }],
1678                ..Issue::default()
1679            },
1680        ];
1681        let graph = IssueGraph::build(&issues);
1682        let metrics = graph.compute_metrics();
1683        let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1684        let lookups = test_lookups(&issues, &graph, &metrics);
1685
1686        let score_a =
1687            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1688        let score_b =
1689            super::compute_impact_score(&issues[1], &metrics, &ctx, &lookups, &HashMap::new());
1690
1691        assert!(
1692            score_a.score > score_b.score,
1693            "root blocker ({}) should score higher than blocked leaf ({})",
1694            score_a.score,
1695            score_b.score
1696        );
1697    }
1698
1699    #[test]
1700    fn impact_score_breakdown_included_in_recommendations() {
1701        let issues = vec![Issue {
1702            id: "A".to_string(),
1703            title: "Task".to_string(),
1704            status: "open".to_string(),
1705            issue_type: "task".to_string(),
1706            priority: 2,
1707            ..Issue::default()
1708        }];
1709        let graph = IssueGraph::build(&issues);
1710        let metrics = graph.compute_metrics();
1711        let triage = compute_triage(
1712            &issues,
1713            &graph,
1714            &metrics,
1715            &TriageOptions {
1716                max_recommendations: 10,
1717                ..TriageOptions::default()
1718            },
1719        );
1720
1721        assert_eq!(triage.result.recommendations.len(), 1);
1722        let rec = &triage.result.recommendations[0];
1723        assert!(rec.breakdown.is_some(), "breakdown should be present");
1724        let bd = rec.breakdown.as_ref().unwrap();
1725        assert_eq!(bd.len(), 8);
1726    }
1727
1728    #[test]
1729    fn impact_score_risk_penalizes_cyclic_issues() {
1730        let issues = vec![
1731            Issue {
1732                id: "A".to_string(),
1733                title: "In cycle".to_string(),
1734                status: "open".to_string(),
1735                issue_type: "task".to_string(),
1736                priority: 2,
1737                dependencies: vec![crate::model::Dependency {
1738                    depends_on_id: "B".to_string(),
1739                    dep_type: "blocks".to_string(),
1740                    ..crate::model::Dependency::default()
1741                }],
1742                ..Issue::default()
1743            },
1744            Issue {
1745                id: "B".to_string(),
1746                title: "In cycle".to_string(),
1747                status: "open".to_string(),
1748                issue_type: "task".to_string(),
1749                priority: 2,
1750                dependencies: vec![crate::model::Dependency {
1751                    depends_on_id: "A".to_string(),
1752                    dep_type: "blocks".to_string(),
1753                    ..crate::model::Dependency::default()
1754                }],
1755                ..Issue::default()
1756            },
1757        ];
1758        let graph = IssueGraph::build(&issues);
1759        let metrics = graph.compute_metrics();
1760        let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1761        let lookups = test_lookups(&issues, &graph, &metrics);
1762        let score =
1763            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1764
1765        // Risk component should be non-zero (cycle detected).
1766        let risk = &score.breakdown[7];
1767        assert_eq!(risk.name, "Risk");
1768        assert!(
1769            risk.raw > 0.0,
1770            "risk raw should be > 0 for cyclic issue, got {}",
1771            risk.raw
1772        );
1773        assert!(risk.explanation.is_some());
1774    }
1775
1776    #[test]
1777    fn impact_score_large_graph_correctness() {
1778        let mut issues: Vec<Issue> = (0..100)
1779            .map(|i| Issue {
1780                id: format!("N-{i}"),
1781                title: format!("Node {i}"),
1782                status: "open".to_string(),
1783                issue_type: "task".to_string(),
1784                priority: (i % 4) + 1,
1785                ..Issue::default()
1786            })
1787            .collect();
1788        // Add a dependency chain: N-1 depends on N-0, N-2 on N-1, etc. for first 20.
1789        for i in 1..20 {
1790            issues[i].dependencies.push(crate::model::Dependency {
1791                issue_id: format!("N-{i}"),
1792                depends_on_id: format!("N-{}", i - 1),
1793                dep_type: "blocks".to_string(),
1794                ..crate::model::Dependency::default()
1795            });
1796        }
1797
1798        let graph = IssueGraph::build(&issues);
1799        let metrics = graph.compute_metrics();
1800        let ctx = super::ScoringContext::from_metrics(&metrics, issues.len());
1801        let lookups = test_lookups(&issues, &graph, &metrics);
1802
1803        for issue in &issues {
1804            let score =
1805                super::compute_impact_score(issue, &metrics, &ctx, &lookups, &HashMap::new());
1806            assert!(
1807                score.score >= 0.0 && score.score <= 1.0,
1808                "score for {} out of range: {}",
1809                issue.id,
1810                score.score
1811            );
1812            assert_eq!(score.breakdown.len(), 8);
1813            // Weighted sum should match the score (within float tolerance).
1814            let sum: f64 = score.breakdown.iter().map(|c| c.weighted).sum();
1815            assert!(
1816                (sum - score.score).abs() < 1e-9,
1817                "breakdown sum ({sum}) != score ({}) for {}",
1818                score.score,
1819                issue.id,
1820            );
1821        }
1822    }
1823
1824    // -----------------------------------------------------------------------
1825    // TriageScoringOptions tests
1826    // -----------------------------------------------------------------------
1827
1828    #[test]
1829    fn scoring_options_defaults_match_go() {
1830        let opts = TriageScoringOptions::default();
1831        assert!((opts.base_score_weight - 0.70).abs() < 1e-9);
1832        assert!((opts.unblock_boost_weight - 0.15).abs() < 1e-9);
1833        assert!((opts.quick_win_weight - 0.15).abs() < 1e-9);
1834        assert_eq!(opts.unblock_threshold, 5);
1835        assert_eq!(opts.quick_win_max_depth, 2);
1836        assert!(!opts.enable_label_health);
1837        assert!(!opts.enable_claim_penalty);
1838        assert!(!opts.enable_attention_score);
1839        assert!(opts.claimed_by_agent.is_none());
1840        // Weights must sum to 1.0
1841        let sum = opts.base_score_weight + opts.unblock_boost_weight + opts.quick_win_weight;
1842        assert!(
1843            (sum - 1.0).abs() < 1e-9,
1844            "weights should sum to 1.0, got {sum}"
1845        );
1846    }
1847
1848    #[test]
1849    fn scoring_options_serializes_to_json() {
1850        let opts = TriageScoringOptions::default();
1851        let json = serde_json::to_value(&opts).unwrap();
1852        assert_eq!(json["base_score_weight"], 0.7);
1853        assert_eq!(json["unblock_boost_weight"], 0.15);
1854        assert_eq!(json["quick_win_weight"], 0.15);
1855        assert_eq!(json["unblock_threshold"], 5);
1856        assert_eq!(json["quick_win_max_depth"], 2);
1857        assert_eq!(json["enable_label_health"], false);
1858        // claimed_by_agent should be absent (skip_serializing_if)
1859        assert!(json.get("claimed_by_agent").is_none());
1860    }
1861
1862    #[test]
1863    fn scoring_options_unblock_boost_increases_score() {
1864        // Issue A blocks 6 issues (above threshold of 5) → should get full unblock boost.
1865        // Issue B blocks nothing → no unblock boost.
1866        let mut issues = vec![
1867            Issue {
1868                id: "A".to_string(),
1869                title: "Blocker".to_string(),
1870                status: "open".to_string(),
1871                issue_type: "task".to_string(),
1872                priority: 2,
1873                ..Issue::default()
1874            },
1875            Issue {
1876                id: "B".to_string(),
1877                title: "Leaf".to_string(),
1878                status: "open".to_string(),
1879                issue_type: "task".to_string(),
1880                priority: 2,
1881                ..Issue::default()
1882            },
1883        ];
1884
1885        // Create 6 issues that depend on A.
1886        for i in 0..6 {
1887            issues.push(Issue {
1888                id: format!("D{i}"),
1889                title: format!("Dep {i}"),
1890                status: "blocked".to_string(),
1891                issue_type: "task".to_string(),
1892                dependencies: vec![crate::model::Dependency {
1893                    depends_on_id: "A".to_string(),
1894                    dep_type: "blocks".to_string(),
1895                    ..crate::model::Dependency::default()
1896                }],
1897                ..Issue::default()
1898            });
1899        }
1900
1901        let graph = IssueGraph::build(&issues);
1902        let metrics = graph.compute_metrics();
1903        let triage = compute_triage(
1904            &issues,
1905            &graph,
1906            &metrics,
1907            &TriageOptions {
1908                max_recommendations: 10,
1909                ..TriageOptions::default()
1910            },
1911        );
1912
1913        let rec_a = triage.result.recommendations.iter().find(|r| r.id == "A");
1914        let rec_b = triage.result.recommendations.iter().find(|r| r.id == "B");
1915        assert!(rec_a.is_some(), "A should be recommended");
1916        assert!(rec_b.is_some(), "B should be recommended");
1917        assert!(
1918            rec_a.unwrap().score > rec_b.unwrap().score,
1919            "A (blocker with unblock boost) should score higher than B"
1920        );
1921    }
1922
1923    #[test]
1924    fn scoring_options_custom_weights_change_ranking() {
1925        let issues = vec![
1926            Issue {
1927                id: "A".to_string(),
1928                title: "High priority quick win".to_string(),
1929                status: "open".to_string(),
1930                issue_type: "task".to_string(),
1931                priority: 1,
1932                estimated_minutes: Some(60),
1933                ..Issue::default()
1934            },
1935            Issue {
1936                id: "B".to_string(),
1937                title: "Low priority".to_string(),
1938                status: "open".to_string(),
1939                issue_type: "task".to_string(),
1940                priority: 4,
1941                ..Issue::default()
1942            },
1943        ];
1944
1945        let graph = IssueGraph::build(&issues);
1946        let metrics = graph.compute_metrics();
1947
1948        // Heavy quick-win weighting should strongly favor A.
1949        let triage_qw = compute_triage(
1950            &issues,
1951            &graph,
1952            &metrics,
1953            &TriageOptions {
1954                max_recommendations: 10,
1955                scoring: TriageScoringOptions {
1956                    base_score_weight: 0.2,
1957                    unblock_boost_weight: 0.0,
1958                    quick_win_weight: 0.8,
1959                    ..TriageScoringOptions::default()
1960                },
1961                ..TriageOptions::default()
1962            },
1963        );
1964
1965        let a_score = triage_qw
1966            .result
1967            .recommendations
1968            .iter()
1969            .find(|r| r.id == "A")
1970            .map(|r| r.score)
1971            .unwrap_or(0.0);
1972        let b_score = triage_qw
1973            .result
1974            .recommendations
1975            .iter()
1976            .find(|r| r.id == "B")
1977            .map(|r| r.score)
1978            .unwrap_or(0.0);
1979
1980        assert!(
1981            a_score > b_score,
1982            "A (quick win) should score much higher than B with heavy quick-win weight"
1983        );
1984    }
1985
1986    #[test]
1987    fn scoring_options_empty_graph_no_panic() {
1988        let issues: Vec<Issue> = vec![];
1989        let graph = IssueGraph::build(&issues);
1990        let metrics = graph.compute_metrics();
1991        let triage = compute_triage(
1992            &issues,
1993            &graph,
1994            &metrics,
1995            &TriageOptions {
1996                scoring: TriageScoringOptions {
1997                    base_score_weight: 0.5,
1998                    unblock_boost_weight: 0.3,
1999                    quick_win_weight: 0.2,
2000                    ..TriageScoringOptions::default()
2001                },
2002                ..TriageOptions::default()
2003            },
2004        );
2005        assert!(triage.result.recommendations.is_empty());
2006    }
2007
2008    #[test]
2009    fn scoring_options_single_node() {
2010        let issues = vec![Issue {
2011            id: "A".to_string(),
2012            title: "Solo".to_string(),
2013            status: "open".to_string(),
2014            issue_type: "task".to_string(),
2015            priority: 1,
2016            ..Issue::default()
2017        }];
2018
2019        let graph = IssueGraph::build(&issues);
2020        let metrics = graph.compute_metrics();
2021        let triage = compute_triage(
2022            &issues,
2023            &graph,
2024            &metrics,
2025            &TriageOptions {
2026                max_recommendations: 10,
2027                ..TriageOptions::default()
2028            },
2029        );
2030
2031        assert_eq!(triage.result.recommendations.len(), 1);
2032        let rec = &triage.result.recommendations[0];
2033        assert!(rec.score >= 0.0 && rec.score <= 1.0);
2034        // impact_score should still be present.
2035        assert!(rec.impact_score >= 0.0 && rec.impact_score <= 1.0);
2036    }
2037
2038    #[test]
2039    fn feedback_weight_adjustments_shift_scores() {
2040        let issues = vec![
2041            Issue {
2042                id: "A".to_string(),
2043                title: "High centrality".to_string(),
2044                status: "open".to_string(),
2045                issue_type: "task".to_string(),
2046                priority: 1,
2047                ..Issue::default()
2048            },
2049            Issue {
2050                id: "B".to_string(),
2051                title: "Depends on A".to_string(),
2052                status: "blocked".to_string(),
2053                issue_type: "task".to_string(),
2054                priority: 2,
2055                dependencies: vec![crate::model::Dependency {
2056                    issue_id: "B".to_string(),
2057                    depends_on_id: "A".to_string(),
2058                    dep_type: "blocks".to_string(),
2059                    ..crate::model::Dependency::default()
2060                }],
2061                ..Issue::default()
2062            },
2063        ];
2064
2065        let graph = IssueGraph::build(&issues);
2066        let metrics = graph.compute_metrics();
2067        let ctx = super::ScoringContext::from_metrics(&metrics, 2);
2068        let lookups = test_lookups(&issues, &graph, &metrics);
2069
2070        // Baseline: no adjustments
2071        let baseline =
2072            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
2073
2074        // With PageRank boosted to 2x
2075        let mut adjustments = HashMap::new();
2076        adjustments.insert("PageRank".to_string(), 2.0);
2077        let boosted =
2078            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &adjustments);
2079
2080        // The PageRank component weight should be higher in the boosted version.
2081        let baseline_pr_weight = baseline.breakdown[0].weight;
2082        let boosted_pr_weight = boosted.breakdown[0].weight;
2083        assert!(
2084            boosted_pr_weight > baseline_pr_weight,
2085            "boosted PageRank weight {boosted_pr_weight} should exceed baseline {baseline_pr_weight}"
2086        );
2087
2088        // Scores remain in valid range.
2089        assert!(boosted.score >= 0.0 && boosted.score <= 1.0);
2090    }
2091
2092    #[test]
2093    fn feedback_empty_adjustments_match_baseline() {
2094        let issues = vec![Issue {
2095            id: "A".to_string(),
2096            title: "Solo".to_string(),
2097            status: "open".to_string(),
2098            issue_type: "task".to_string(),
2099            priority: 1,
2100            ..Issue::default()
2101        }];
2102        let graph = IssueGraph::build(&issues);
2103        let metrics = graph.compute_metrics();
2104        let ctx = super::ScoringContext::from_metrics(&metrics, 1);
2105        let lookups = test_lookups(&issues, &graph, &metrics);
2106
2107        let no_adj =
2108            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
2109        let empty_map: HashMap<String, f64> = HashMap::new();
2110        let with_empty =
2111            super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &empty_map);
2112
2113        assert!(
2114            (no_adj.score - with_empty.score).abs() < 1e-12,
2115            "empty adjustments should produce identical scores"
2116        );
2117    }
2118
2119    #[test]
2120    fn feedback_adjustments_renormalize_weights() {
2121        let issues = vec![Issue {
2122            id: "A".to_string(),
2123            title: "Test".to_string(),
2124            status: "open".to_string(),
2125            issue_type: "task".to_string(),
2126            priority: 2,
2127            ..Issue::default()
2128        }];
2129        let graph = IssueGraph::build(&issues);
2130        let metrics = graph.compute_metrics();
2131        let ctx = super::ScoringContext::from_metrics(&metrics, 1);
2132        let lookups = test_lookups(&issues, &graph, &metrics);
2133
2134        // All weights doubled → should renormalize to sum=1.0
2135        let mut adjustments = HashMap::new();
2136        for name in &[
2137            "PageRank",
2138            "Betweenness",
2139            "BlockerRatio",
2140            "Staleness",
2141            "PriorityBoost",
2142            "TimeToImpact",
2143            "Urgency",
2144            "Risk",
2145        ] {
2146            adjustments.insert(name.to_string(), 2.0);
2147        }
2148        let score = super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &adjustments);
2149
2150        let weight_sum: f64 = score.breakdown.iter().map(|c| c.weight).sum();
2151        assert!(
2152            (weight_sum - 1.0).abs() < 1e-9,
2153            "adjusted weights should sum to 1.0, got {weight_sum}"
2154        );
2155    }
2156
2157    #[test]
2158    fn weight_preset_from_name_roundtrips() {
2159        for name in WeightPreset::ALL {
2160            assert!(
2161                WeightPreset::from_name(name).is_some(),
2162                "preset {name} should parse"
2163            );
2164        }
2165        assert!(WeightPreset::from_name("nonexistent").is_none());
2166    }
2167
2168    #[test]
2169    fn weight_preset_default_is_empty() {
2170        assert!(WeightPreset::Default.adjustments().is_empty());
2171    }
2172
2173    #[test]
2174    fn weight_preset_adjustments_are_within_clamp_range() {
2175        // All preset multipliers must be within the 0.5–2.0 clamp range
2176        // enforced by compute_impact_score, otherwise they'd be silently clamped.
2177        for name in WeightPreset::ALL {
2178            let preset = WeightPreset::from_name(name).unwrap();
2179            for (component, value) in preset.adjustments() {
2180                assert!(
2181                    (0.5..=2.0).contains(&value),
2182                    "preset {name}: {component} adjustment {value} is outside 0.5–2.0 clamp range"
2183                );
2184            }
2185        }
2186    }
2187
2188    #[test]
2189    fn weight_preset_graph_heavy_boosts_structural_signals() {
2190        let adj = WeightPreset::GraphHeavy.adjustments();
2191        assert!(adj["PageRank"] > 1.0);
2192        assert!(adj["Betweenness"] > 1.0);
2193        assert!(adj["PriorityBoost"] < 1.0);
2194    }
2195}