Skip to main content

bvr/analysis/
forecast.rs

1use std::collections::HashSet;
2
3use chrono::{DateTime, Duration, Utc};
4use serde::Serialize;
5
6use crate::analysis::graph::{GraphMetrics, IssueGraph};
7use crate::model::Issue;
8
9const DEFAULT_ESTIMATED_MINUTES: i64 = 60;
10const I64_MAX_F64: f64 = 9_223_372_036_854_775_807.0;
11const I64_MIN_F64: f64 = -9_223_372_036_854_775_808.0;
12
13#[derive(Debug, Clone, Serialize)]
14pub struct ForecastItem {
15    pub id: String,
16    pub title: String,
17    pub status: String,
18    pub confidence: f64,
19    pub eta_minutes: i64,
20    pub estimated_days: f64,
21    pub eta_date: String,
22    pub eta_date_low: String,
23    pub eta_date_high: String,
24    pub velocity_minutes_per_day: f64,
25    pub agents: usize,
26    pub factors: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct ForecastSummary {
31    pub generated_at: String,
32    pub count: usize,
33    pub avg_eta_minutes: i64,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ForecastOutput {
38    pub summary: ForecastSummary,
39    pub forecasts: Vec<ForecastItem>,
40}
41
42#[derive(Debug, Clone)]
43pub struct EtaEstimate {
44    pub estimated_minutes: i64,
45    pub estimated_days: f64,
46    pub eta_date: String,
47    pub eta_date_low: String,
48    pub eta_date_high: String,
49    pub confidence: f64,
50    pub velocity_minutes_per_day: f64,
51    pub agents: usize,
52    pub factors: Vec<String>,
53}
54
55#[must_use]
56pub fn estimate_forecast(
57    issues: &[Issue],
58    graph: &IssueGraph,
59    metrics: &GraphMetrics,
60    issue_id_or_all: &str,
61    label_filter: Option<&str>,
62    agents: usize,
63) -> ForecastOutput {
64    let now = Utc::now();
65    let mut forecasts = Vec::<ForecastItem>::new();
66
67    let target_all = issue_id_or_all.eq_ignore_ascii_case("all");
68
69    for issue in issues {
70        if !issue.is_open_like() {
71            continue;
72        }
73        if !target_all && issue.id != issue_id_or_all {
74            continue;
75        }
76        if label_filter.is_some_and(|label| !has_label(&issue.labels, label)) {
77            continue;
78        }
79
80        let Some(eta) = estimate_eta_for_issue(issues, graph, metrics, &issue.id, agents, now)
81        else {
82            continue;
83        };
84
85        forecasts.push(ForecastItem {
86            id: issue.id.clone(),
87            title: issue.title.clone(),
88            status: issue.status.clone(),
89            confidence: eta.confidence,
90            eta_minutes: eta.estimated_minutes,
91            estimated_days: eta.estimated_days,
92            eta_date: eta.eta_date,
93            eta_date_low: eta.eta_date_low,
94            eta_date_high: eta.eta_date_high,
95            velocity_minutes_per_day: eta.velocity_minutes_per_day,
96            agents: eta.agents,
97            factors: eta.factors,
98        });
99    }
100
101    let avg_eta_minutes = if forecasts.is_empty() {
102        0
103    } else {
104        forecasts.iter().map(|item| item.eta_minutes).sum::<i64>()
105            / i64::try_from(forecasts.len()).unwrap_or(1)
106    };
107
108    ForecastOutput {
109        summary: ForecastSummary {
110            generated_at: now.to_rfc3339(),
111            count: forecasts.len(),
112            avg_eta_minutes,
113        },
114        forecasts,
115    }
116}
117
118#[must_use]
119pub fn estimate_eta_for_issue(
120    issues: &[Issue],
121    graph: &IssueGraph,
122    metrics: &GraphMetrics,
123    issue_id: &str,
124    agents: usize,
125    now: DateTime<Utc>,
126) -> Option<EtaEstimate> {
127    let mut active = HashSet::<String>::new();
128    estimate_eta_for_issue_inner(issues, graph, metrics, issue_id, agents, now, &mut active)
129}
130
131fn estimate_eta_for_issue_inner(
132    issues: &[Issue],
133    graph: &IssueGraph,
134    metrics: &GraphMetrics,
135    issue_id: &str,
136    agents: usize,
137    now: DateTime<Utc>,
138    active: &mut HashSet<String>,
139) -> Option<EtaEstimate> {
140    if !active.insert(issue_id.to_string()) {
141        return None;
142    }
143
144    let Some(issue) = issues.iter().find(|issue| issue.id == issue_id) else {
145        active.remove(issue_id);
146        return None;
147    };
148    let agents = agents.max(1);
149
150    let median_minutes = compute_median_estimated_minutes(issues);
151    let (complexity_minutes, mut factors) =
152        estimate_complexity_minutes(issue, metrics, median_minutes);
153    let (mut velocity_per_day, velocity_samples, velocity_factors) =
154        estimate_velocity_minutes_per_day(issues, issue, now, median_minutes);
155
156    if velocity_per_day <= 0.0 {
157        velocity_per_day = (median_minutes as f64) / 5.0;
158        if velocity_per_day <= 0.0 {
159            velocity_per_day = 60.0;
160        }
161        factors.extend(velocity_factors);
162        factors.push("velocity: no recent closures; using default".to_string());
163    } else {
164        factors.extend(velocity_factors);
165    }
166
167    let capacity_per_day = velocity_per_day * (agents as f64);
168    let mut estimated_days = if capacity_per_day > 0.0 {
169        (complexity_minutes as f64) / capacity_per_day
170    } else {
171        0.0
172    };
173    if estimated_days.is_sign_negative() {
174        estimated_days = 0.0;
175    }
176
177    let blocker_wait_days = graph
178        .open_blockers(issue_id)
179        .into_iter()
180        .filter_map(|blocker_id| {
181            estimate_eta_for_issue_inner(issues, graph, metrics, &blocker_id, agents, now, active)
182        })
183        .map(|eta| eta.estimated_days)
184        .fold(0.0_f64, f64::max);
185    if blocker_wait_days > 0.0 {
186        estimated_days += blocker_wait_days;
187        factors.push(format!(
188            "blocked: waits {:.1}d on dependencies",
189            blocker_wait_days
190        ));
191    }
192
193    let confidence = estimate_eta_confidence(issue, velocity_samples);
194    let delta_days = 0.5_f64.max(estimated_days * (1.0 - confidence) * 0.8);
195
196    let eta = now + duration_days(estimated_days);
197    let eta_low = now + duration_days((estimated_days - delta_days).max(0.0));
198    let eta_high = now + duration_days(estimated_days + delta_days);
199
200    factors.push(format!("agents: {agents}"));
201    if factors.len() > 8 {
202        factors.truncate(8);
203    }
204
205    let estimate = EtaEstimate {
206        estimated_minutes: complexity_minutes,
207        estimated_days,
208        eta_date: eta.to_rfc3339(),
209        eta_date_low: eta_low.to_rfc3339(),
210        eta_date_high: eta_high.to_rfc3339(),
211        confidence,
212        velocity_minutes_per_day: velocity_per_day,
213        agents,
214        factors,
215    };
216    active.remove(issue_id);
217    Some(estimate)
218}
219
220fn estimate_complexity_minutes(
221    issue: &Issue,
222    metrics: &GraphMetrics,
223    median_minutes: i64,
224) -> (i64, Vec<String>) {
225    let mut factors = Vec::<String>::new();
226
227    let explicit = issue.estimated_minutes.unwrap_or(0) > 0;
228    let mut base_minutes = if explicit {
229        i64::from(issue.estimated_minutes.unwrap_or(0))
230    } else {
231        median_minutes
232    };
233
234    let estimate_source = if explicit {
235        "explicit"
236    } else if base_minutes > 0 {
237        "median"
238    } else {
239        "default"
240    };
241    if base_minutes <= 0 {
242        base_minutes = DEFAULT_ESTIMATED_MINUTES;
243    }
244    factors.push(format!("estimate: {estimate_source} ({base_minutes}m)"));
245
246    let issue_type = issue.issue_type.trim().to_ascii_lowercase();
247    let type_weight = match issue_type.as_str() {
248        "chore" => 0.8,
249        "feature" => 1.3,
250        "epic" => 2.0,
251        _ => 1.0,
252    };
253    factors.push(format!("type: {issue_type}×{type_weight:.1}"));
254
255    let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0) as f64;
256    let depth_factor = 1.0 + (depth / 10.0).min(1.0);
257    factors.push(format!("depth: {depth:.0}×{depth_factor:.2}"));
258
259    let desc_runes = issue.description.chars().count();
260    let desc_factor = 1.0 + ((desc_runes as f64) / 2000.0).min(1.0);
261    if desc_runes > 0 {
262        factors.push(format!("desc: {desc_runes}r×{desc_factor:.2}"));
263    } else {
264        factors.push("desc: empty×1.00".to_string());
265    }
266
267    let derived =
268        truncate_f64_to_i64((base_minutes as f64) * type_weight * depth_factor * desc_factor)
269            .unwrap_or(base_minutes);
270    (derived.max(1), factors)
271}
272
273fn estimate_velocity_minutes_per_day(
274    issues: &[Issue],
275    issue: &Issue,
276    now: DateTime<Utc>,
277    median_minutes: i64,
278) -> (f64, usize, Vec<String>) {
279    let since = now - Duration::days(30);
280    if issue.labels.is_empty() {
281        let (velocity, samples) =
282            velocity_minutes_per_day_for_label(issues, None, since, median_minutes);
283        return (
284            velocity,
285            samples,
286            vec![format!("velocity: global ({samples} samples/30d)")],
287        );
288    }
289
290    // Pick the most conservative (lowest) label velocity for pessimistic ETA estimation.
291    // Using the slowest label avoids overoptimistic forecasts.
292    let mut slowest_label = String::new();
293    let mut slowest_velocity = 0.0;
294    let mut slowest_samples = 0usize;
295
296    for label in &issue.labels {
297        let (velocity, samples) =
298            velocity_minutes_per_day_for_label(issues, Some(label), since, median_minutes);
299        if samples == 0 || velocity <= 0.0 {
300            continue;
301        }
302
303        if slowest_velocity == 0.0
304            || velocity < slowest_velocity
305            || ((velocity - slowest_velocity).abs() < f64::EPSILON
306                && label.to_ascii_lowercase() < slowest_label.to_ascii_lowercase())
307        {
308            slowest_label.clone_from(label);
309            slowest_velocity = velocity;
310            slowest_samples = samples;
311        }
312    }
313
314    if slowest_velocity > 0.0 {
315        return (
316            slowest_velocity,
317            slowest_samples,
318            vec![format!(
319                "velocity: label={slowest_label} ({slowest_velocity:.0} min/day, {slowest_samples} samples/30d)"
320            )],
321        );
322    }
323
324    let (velocity, samples) =
325        velocity_minutes_per_day_for_label(issues, None, since, median_minutes);
326    (
327        velocity,
328        samples,
329        vec![format!("velocity: global ({samples} samples/30d)")],
330    )
331}
332
333fn velocity_minutes_per_day_for_label(
334    issues: &[Issue],
335    label: Option<&str>,
336    since: DateTime<Utc>,
337    median_minutes: i64,
338) -> (f64, usize) {
339    let mut total_minutes = 0_i64;
340    let mut samples = 0usize;
341
342    for issue in issues {
343        if !issue.is_closed_like() {
344            continue;
345        }
346
347        let closed_at = issue.closed_at.or(issue.updated_at);
348        let Some(closed_at) = closed_at else {
349            continue;
350        };
351        if closed_at < since {
352            continue;
353        }
354
355        if label.is_some_and(|needle| !has_label(&issue.labels, needle)) {
356            continue;
357        }
358
359        let minutes = i64::from(issue.estimated_minutes.unwrap_or(0)).max(0);
360        total_minutes += if minutes > 0 {
361            minutes
362        } else if median_minutes > 0 {
363            median_minutes
364        } else {
365            DEFAULT_ESTIMATED_MINUTES
366        };
367        samples = samples.saturating_add(1);
368    }
369
370    if samples == 0 {
371        (0.0, 0)
372    } else {
373        (total_minutes as f64 / 30.0, samples)
374    }
375}
376
377fn has_label(labels: &[String], target: &str) -> bool {
378    let target = target.to_ascii_lowercase();
379    labels
380        .iter()
381        .any(|label| label.to_ascii_lowercase() == target)
382}
383
384fn estimate_eta_confidence(issue: &Issue, velocity_samples: usize) -> f64 {
385    let mut confidence = 0.25_f64;
386
387    if issue.estimated_minutes.unwrap_or(0) > 0 {
388        confidence += 0.25;
389    }
390
391    confidence += if velocity_samples >= 15 {
392        0.30
393    } else if velocity_samples >= 5 {
394        0.20
395    } else if velocity_samples >= 1 {
396        0.10
397    } else {
398        -0.05
399    };
400
401    if issue.labels.is_empty() {
402        confidence -= 0.05;
403    }
404
405    clamp(confidence, 0.10, 0.90)
406}
407
408fn compute_median_estimated_minutes(issues: &[Issue]) -> i64 {
409    let mut estimates = issues
410        .iter()
411        .filter_map(|issue| issue.estimated_minutes)
412        .map(i64::from)
413        .filter(|minutes| *minutes > 0)
414        .collect::<Vec<_>>();
415
416    if estimates.is_empty() {
417        return DEFAULT_ESTIMATED_MINUTES;
418    }
419
420    estimates.sort_unstable();
421    let mid = estimates.len() / 2;
422    if estimates.len() % 2 == 0 {
423        (estimates[mid - 1] + estimates[mid]) / 2
424    } else {
425        estimates[mid]
426    }
427}
428
429fn duration_days(days: f64) -> Duration {
430    if days <= 0.0 || !days.is_finite() {
431        return Duration::zero();
432    }
433
434    const NANOS_PER_DAY: f64 = 86_400.0 * 1_000_000_000.0;
435    let nanos = truncate_f64_to_i64(days * NANOS_PER_DAY)
436        .unwrap_or(i64::MAX)
437        .max(0);
438    Duration::nanoseconds(nanos)
439}
440
441fn clamp(value: f64, min: f64, max: f64) -> f64 {
442    value.max(min).min(max)
443}
444
445fn truncate_f64_to_i64(value: f64) -> Option<i64> {
446    if !value.is_finite() {
447        return None;
448    }
449
450    if value >= I64_MAX_F64 {
451        return Some(i64::MAX);
452    }
453    if value <= I64_MIN_F64 {
454        return Some(i64::MIN);
455    }
456
457    #[allow(clippy::cast_possible_truncation)]
458    Some(value.trunc() as i64)
459}
460
461#[cfg(test)]
462mod tests {
463    use chrono::Utc;
464
465    use crate::analysis::graph::IssueGraph;
466    use crate::model::Issue;
467
468    use super::{estimate_eta_for_issue, estimate_forecast, velocity_minutes_per_day_for_label};
469
470    #[test]
471    fn forecast_for_all_open_issues() {
472        let issues = vec![
473            Issue {
474                id: "A".to_string(),
475                title: "A".to_string(),
476                status: "open".to_string(),
477                issue_type: "task".to_string(),
478                estimated_minutes: Some(90),
479                ..Issue::default()
480            },
481            Issue {
482                id: "B".to_string(),
483                title: "B".to_string(),
484                status: "open".to_string(),
485                issue_type: "task".to_string(),
486                estimated_minutes: Some(30),
487                ..Issue::default()
488            },
489            Issue {
490                id: "C".to_string(),
491                title: "C".to_string(),
492                status: "closed".to_string(),
493                issue_type: "task".to_string(),
494                ..Issue::default()
495            },
496        ];
497
498        let graph = IssueGraph::build(&issues);
499        let metrics = graph.compute_metrics();
500        let output = estimate_forecast(&issues, &graph, &metrics, "all", None, 1);
501        assert_eq!(output.summary.count, 2);
502        assert_eq!(output.forecasts[0].id, "A");
503        assert_eq!(output.forecasts[1].id, "B");
504        assert!(output.forecasts[0].estimated_days >= 0.0);
505    }
506
507    #[test]
508    fn eta_includes_bounds_and_normalizes_agents() {
509        let issues = vec![Issue {
510            id: "A".to_string(),
511            title: "A".to_string(),
512            status: "open".to_string(),
513            issue_type: "task".to_string(),
514            ..Issue::default()
515        }];
516
517        let graph = IssueGraph::build(&issues);
518        let metrics = graph.compute_metrics();
519        let eta = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 0, Utc::now())
520            .expect("eta should be computed");
521
522        assert_eq!(eta.agents, 1);
523        assert!(!eta.eta_date.is_empty());
524        assert!(!eta.eta_date_low.is_empty());
525        assert!(!eta.eta_date_high.is_empty());
526    }
527
528    #[test]
529    fn velocity_counts_tombstone_as_closed_like() {
530        let now = Utc::now();
531        let issues = vec![
532            Issue {
533                id: "A".to_string(),
534                title: "A".to_string(),
535                status: "closed".to_string(),
536                issue_type: "task".to_string(),
537                estimated_minutes: Some(120),
538                closed_at: Some(now - chrono::Duration::days(1)),
539                ..Issue::default()
540            },
541            Issue {
542                id: "B".to_string(),
543                title: "B".to_string(),
544                status: "tombstone".to_string(),
545                issue_type: "task".to_string(),
546                estimated_minutes: Some(60),
547                closed_at: Some(now - chrono::Duration::days(2)),
548                ..Issue::default()
549            },
550        ];
551
552        let (velocity, samples) =
553            velocity_minutes_per_day_for_label(&issues, None, now - chrono::Duration::days(30), 60);
554
555        assert_eq!(samples, 2);
556        assert!((velocity - 6.0).abs() < 0.001);
557    }
558
559    // ── compute_median_estimated_minutes ─────────────────────────────
560
561    #[test]
562    fn median_odd_count() {
563        let issues = vec![
564            Issue {
565                estimated_minutes: Some(30),
566                ..Issue::default()
567            },
568            Issue {
569                estimated_minutes: Some(60),
570                ..Issue::default()
571            },
572            Issue {
573                estimated_minutes: Some(120),
574                ..Issue::default()
575            },
576        ];
577        assert_eq!(super::compute_median_estimated_minutes(&issues), 60);
578    }
579
580    #[test]
581    fn median_even_count() {
582        let issues = vec![
583            Issue {
584                estimated_minutes: Some(30),
585                ..Issue::default()
586            },
587            Issue {
588                estimated_minutes: Some(90),
589                ..Issue::default()
590            },
591        ];
592        // (30 + 90) / 2 = 60
593        assert_eq!(super::compute_median_estimated_minutes(&issues), 60);
594    }
595
596    #[test]
597    fn median_empty_returns_default() {
598        assert_eq!(
599            super::compute_median_estimated_minutes(&[]),
600            super::DEFAULT_ESTIMATED_MINUTES
601        );
602    }
603
604    #[test]
605    fn median_filters_zero_and_none() {
606        let issues = vec![
607            Issue {
608                estimated_minutes: Some(0),
609                ..Issue::default()
610            },
611            Issue {
612                estimated_minutes: None,
613                ..Issue::default()
614            },
615            Issue {
616                estimated_minutes: Some(120),
617                ..Issue::default()
618            },
619        ];
620        assert_eq!(super::compute_median_estimated_minutes(&issues), 120);
621    }
622
623    // ── estimate_complexity_minutes ──────────────────────────────────
624
625    #[test]
626    fn complexity_uses_explicit_estimate() {
627        let graph = IssueGraph::build(&[]);
628        let metrics = graph.compute_metrics();
629        let issue = Issue {
630            id: "A".to_string(),
631            estimated_minutes: Some(120),
632            issue_type: "task".to_string(),
633            ..Issue::default()
634        };
635        let (minutes, factors) = super::estimate_complexity_minutes(&issue, &metrics, 60);
636        // task type_weight=1.0, depth=0 → depth_factor=1.0, empty desc → desc_factor=1.0
637        // 120 * 1.0 * 1.0 * 1.0 = 120
638        assert_eq!(minutes, 120);
639        assert!(factors.iter().any(|f| f.contains("explicit")));
640    }
641
642    #[test]
643    fn complexity_uses_median_fallback() {
644        let graph = IssueGraph::build(&[]);
645        let metrics = graph.compute_metrics();
646        let issue = Issue {
647            id: "A".to_string(),
648            issue_type: "task".to_string(),
649            ..Issue::default()
650        };
651        let (minutes, factors) = super::estimate_complexity_minutes(&issue, &metrics, 90);
652        // No explicit estimate → uses median=90, task×1.0, depth=0, empty desc
653        assert_eq!(minutes, 90);
654        assert!(factors.iter().any(|f| f.contains("median")));
655    }
656
657    #[test]
658    fn complexity_type_weight_feature() {
659        let graph = IssueGraph::build(&[]);
660        let metrics = graph.compute_metrics();
661        let issue = Issue {
662            id: "A".to_string(),
663            estimated_minutes: Some(100),
664            issue_type: "feature".to_string(),
665            ..Issue::default()
666        };
667        let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
668        // 100 * 1.3 (feature) * 1.0 * 1.0 = 130
669        assert_eq!(minutes, 130);
670    }
671
672    #[test]
673    fn complexity_type_weight_epic() {
674        let graph = IssueGraph::build(&[]);
675        let metrics = graph.compute_metrics();
676        let issue = Issue {
677            id: "A".to_string(),
678            estimated_minutes: Some(100),
679            issue_type: "epic".to_string(),
680            ..Issue::default()
681        };
682        let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
683        // 100 * 2.0 (epic) * 1.0 * 1.0 = 200
684        assert_eq!(minutes, 200);
685    }
686
687    #[test]
688    fn complexity_description_scales_estimate() {
689        let graph = IssueGraph::build(&[]);
690        let metrics = graph.compute_metrics();
691        let long_desc = "x".repeat(2000);
692        let issue = Issue {
693            id: "A".to_string(),
694            estimated_minutes: Some(100),
695            issue_type: "task".to_string(),
696            description: long_desc,
697            ..Issue::default()
698        };
699        let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
700        // 100 * 1.0 * 1.0 * (1.0 + 2000/2000) = 100 * 2.0 = 200
701        assert_eq!(minutes, 200);
702    }
703
704    // ── estimate_eta_confidence ──────────────────────────────────────
705
706    #[test]
707    fn confidence_base_no_estimate_no_velocity() {
708        let issue = Issue::default();
709        let confidence = super::estimate_eta_confidence(&issue, 0);
710        // 0.25 (base) + (-0.05) (no velocity) + (-0.05) (no labels) = 0.15
711        assert!((confidence - 0.15).abs() < 0.01);
712    }
713
714    #[test]
715    fn confidence_with_explicit_estimate() {
716        let issue = Issue {
717            estimated_minutes: Some(60),
718            ..Issue::default()
719        };
720        let confidence = super::estimate_eta_confidence(&issue, 0);
721        // 0.25 + 0.25 (estimate) + (-0.05) (no velocity) + (-0.05) (no labels) = 0.40
722        assert!((confidence - 0.40).abs() < 0.01);
723    }
724
725    #[test]
726    fn confidence_high_velocity_samples() {
727        let issue = Issue {
728            estimated_minutes: Some(60),
729            labels: vec!["backend".to_string()],
730            ..Issue::default()
731        };
732        let confidence = super::estimate_eta_confidence(&issue, 20);
733        // 0.25 + 0.25 (estimate) + 0.30 (>=15 samples) + 0.0 (has labels) = 0.80
734        assert!((confidence - 0.80).abs() < 0.01);
735    }
736
737    // ── velocity with label filter ──────────────────────────────────
738
739    #[test]
740    fn velocity_label_filter() {
741        let now = Utc::now();
742        let issues = vec![
743            Issue {
744                id: "A".to_string(),
745                status: "closed".to_string(),
746                labels: vec!["backend".to_string()],
747                estimated_minutes: Some(120),
748                closed_at: Some(now - chrono::Duration::days(5)),
749                ..Issue::default()
750            },
751            Issue {
752                id: "B".to_string(),
753                status: "closed".to_string(),
754                labels: vec!["frontend".to_string()],
755                estimated_minutes: Some(60),
756                closed_at: Some(now - chrono::Duration::days(3)),
757                ..Issue::default()
758            },
759        ];
760
761        let (vel_backend, samples_backend) = velocity_minutes_per_day_for_label(
762            &issues,
763            Some("backend"),
764            now - chrono::Duration::days(30),
765            60,
766        );
767        assert_eq!(samples_backend, 1);
768        assert!((vel_backend - 4.0).abs() < 0.01); // 120/30
769
770        let (vel_frontend, samples_frontend) = velocity_minutes_per_day_for_label(
771            &issues,
772            Some("frontend"),
773            now - chrono::Duration::days(30),
774            60,
775        );
776        assert_eq!(samples_frontend, 1);
777        assert!((vel_frontend - 2.0).abs() < 0.01); // 60/30
778    }
779
780    #[test]
781    fn velocity_ignores_old_closures() {
782        let now = Utc::now();
783        let issues = vec![Issue {
784            id: "A".to_string(),
785            status: "closed".to_string(),
786            estimated_minutes: Some(120),
787            closed_at: Some(now - chrono::Duration::days(60)),
788            ..Issue::default()
789        }];
790        let (velocity, samples) =
791            velocity_minutes_per_day_for_label(&issues, None, now - chrono::Duration::days(30), 60);
792        assert_eq!(samples, 0);
793        assert_eq!(velocity, 0.0);
794    }
795
796    // ── agents scaling ──────────────────────────────────────────────
797
798    #[test]
799    fn more_agents_reduces_eta() {
800        let issues = vec![Issue {
801            id: "A".to_string(),
802            title: "A".to_string(),
803            status: "open".to_string(),
804            issue_type: "task".to_string(),
805            estimated_minutes: Some(240),
806            ..Issue::default()
807        }];
808        let graph = IssueGraph::build(&issues);
809        let metrics = graph.compute_metrics();
810        let now = Utc::now();
811
812        let eta1 = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 1, now).unwrap();
813        let eta3 = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 3, now).unwrap();
814        assert!(
815            eta3.estimated_days < eta1.estimated_days || eta1.estimated_days == 0.0,
816            "3 agents should complete faster than 1"
817        );
818    }
819
820    #[test]
821    fn blocked_issue_eta_includes_blocker_wait_time() {
822        let issues = vec![
823            Issue {
824                id: "BLOCKER".to_string(),
825                title: "Blocker".to_string(),
826                status: "open".to_string(),
827                issue_type: "task".to_string(),
828                estimated_minutes: Some(240),
829                ..Issue::default()
830            },
831            Issue {
832                id: "BLOCKED".to_string(),
833                title: "Blocked".to_string(),
834                status: "blocked".to_string(),
835                issue_type: "task".to_string(),
836                estimated_minutes: Some(60),
837                dependencies: vec![crate::model::Dependency {
838                    issue_id: "BLOCKED".to_string(),
839                    depends_on_id: "BLOCKER".to_string(),
840                    dep_type: "blocks".to_string(),
841                    ..crate::model::Dependency::default()
842                }],
843                ..Issue::default()
844            },
845        ];
846        let graph = IssueGraph::build(&issues);
847        let metrics = graph.compute_metrics();
848        let now = Utc::now();
849
850        let blocker_eta =
851            estimate_eta_for_issue(&issues, &graph, &metrics, "BLOCKER", 1, now).unwrap();
852        let dependent_eta =
853            estimate_eta_for_issue(&issues, &graph, &metrics, "BLOCKED", 1, now).unwrap();
854
855        assert!(
856            dependent_eta.estimated_days > blocker_eta.estimated_days,
857            "blocked work should include at least the blocker wait plus its own work"
858        );
859        assert!(
860            dependent_eta
861                .factors
862                .iter()
863                .any(|factor| factor.contains("blocked: waits")),
864            "forecast factors should explain blocker wait time"
865        );
866    }
867
868    // ── forecast label filter ───────────────────────────────────────
869
870    #[test]
871    fn forecast_respects_label_filter() {
872        let issues = vec![
873            Issue {
874                id: "A".to_string(),
875                title: "A".to_string(),
876                status: "open".to_string(),
877                issue_type: "task".to_string(),
878                labels: vec!["backend".to_string()],
879                ..Issue::default()
880            },
881            Issue {
882                id: "B".to_string(),
883                title: "B".to_string(),
884                status: "open".to_string(),
885                issue_type: "task".to_string(),
886                labels: vec!["frontend".to_string()],
887                ..Issue::default()
888            },
889        ];
890        let graph = IssueGraph::build(&issues);
891        let metrics = graph.compute_metrics();
892        let output = estimate_forecast(&issues, &graph, &metrics, "all", Some("backend"), 1);
893        assert_eq!(output.summary.count, 1);
894        assert_eq!(output.forecasts[0].id, "A");
895    }
896
897    // ── edge case helpers ───────────────────────────────────────────
898
899    #[test]
900    fn duration_days_handles_zero_and_negative() {
901        assert_eq!(super::duration_days(0.0), chrono::Duration::zero());
902        assert_eq!(super::duration_days(-5.0), chrono::Duration::zero());
903    }
904
905    #[test]
906    fn truncate_f64_to_i64_edge_cases() {
907        assert_eq!(super::truncate_f64_to_i64(f64::NAN), None);
908        assert_eq!(super::truncate_f64_to_i64(f64::INFINITY), None);
909        assert_eq!(super::truncate_f64_to_i64(42.9), Some(42));
910        assert_eq!(super::truncate_f64_to_i64(-3.7), Some(-3));
911        assert_eq!(super::truncate_f64_to_i64(1e19), Some(i64::MAX));
912        assert_eq!(super::truncate_f64_to_i64(-1e19), Some(i64::MIN));
913    }
914
915    #[test]
916    fn clamp_works_correctly() {
917        assert_eq!(super::clamp(0.5, 0.1, 0.9), 0.5);
918        assert_eq!(super::clamp(-0.5, 0.1, 0.9), 0.1);
919        assert_eq!(super::clamp(1.5, 0.1, 0.9), 0.9);
920    }
921
922    #[test]
923    fn forecast_single_issue_by_id() {
924        let issues = vec![
925            Issue {
926                id: "A".to_string(),
927                title: "A".to_string(),
928                status: "open".to_string(),
929                issue_type: "task".to_string(),
930                ..Issue::default()
931            },
932            Issue {
933                id: "B".to_string(),
934                title: "B".to_string(),
935                status: "open".to_string(),
936                issue_type: "task".to_string(),
937                ..Issue::default()
938            },
939        ];
940        let graph = IssueGraph::build(&issues);
941        let metrics = graph.compute_metrics();
942        let output = estimate_forecast(&issues, &graph, &metrics, "B", None, 1);
943        assert_eq!(output.summary.count, 1);
944        assert_eq!(output.forecasts[0].id, "B");
945    }
946}