Skip to main content

big_code_analysis/vcs/
trend.rs

1//! Historical metric trend: the change-history (VCS) metrics computed at
2//! several points in time, so a consumer can see whether a file's risk is
3//! improving or degrading rather than only its risk *now* (issue #333).
4//!
5//! This module is backend-agnostic. It owns the time-point math
6//! ([`timestamps`]), the parameter validation ([`validate_points`]), the
7//! assembled [`Trend`] container, and the improving/regressing
8//! [`delta summary`](Trend::deltas). The actual repeated history walks
9//! live in the backend (`git::build_trend`), which resolves the
10//! repository tip at each point in time and folds the per-snapshot
11//! [`Stats`] into a [`Trend`] via [`Trend::from_snapshots`].
12
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16use super::error::Error;
17use super::stats::Stats;
18
19/// Output-shape version for the trend document. Bumped on any
20/// shape-breaking change to the serialized time series so consumers can
21/// detect an incompatible schema (separate from the per-file
22/// [`VCS_SCHEMA_VERSION`](super::stats::VCS_SCHEMA_VERSION), which
23/// versions each point's metric block).
24pub const TREND_SCHEMA_VERSION: u32 = 1;
25
26/// Minimum number of time points. A trend needs at least two snapshots to
27/// express a direction; a single point is just `bca vcs --as-of`.
28pub const MIN_TREND_POINTS: usize = 2;
29
30/// Maximum number of time points. Each point is a full history walk, so
31/// the count is capped to bound the worst-case runtime on deep histories
32/// (issue #333's "very deep histories: cap point count"). 120 points is a
33/// decade of monthly snapshots — far beyond any practical trend chart.
34pub const MAX_TREND_POINTS: usize = 120;
35
36/// Validate a requested point count against the [`MIN_TREND_POINTS`] /
37/// [`MAX_TREND_POINTS`] bounds. The single source of truth shared by
38/// every front end so the accepted range cannot drift between them.
39///
40/// # Errors
41///
42/// Returns [`Error::InvalidTrend`] when `points` is below the minimum or
43/// above the maximum.
44pub fn validate_points(points: usize) -> Result<usize, Error> {
45    if points < MIN_TREND_POINTS {
46        return Err(Error::InvalidTrend(format!(
47            "at least {MIN_TREND_POINTS} points are required for a trend (got {points})"
48        )));
49    }
50    if points > MAX_TREND_POINTS {
51        return Err(Error::InvalidTrend(format!(
52            "at most {MAX_TREND_POINTS} points are supported (got {points})"
53        )));
54    }
55    Ok(points)
56}
57
58/// The `points` Unix-second timestamps a trend samples, evenly spaced
59/// across `span_secs` and ending at `end` (the most recent point is
60/// exactly `end`, the oldest is `end - span_secs`). Returned oldest-first.
61///
62/// Integer division places the interior points; the two endpoints are
63/// exact regardless of rounding. `points` is assumed already validated by
64/// [`validate_points`] (`>= MIN_TREND_POINTS`), so the `points - 1`
65/// divisor is always positive. This function does **not** re-check that
66/// precondition: a direct caller passing an arbitrarily large `points`
67/// allocates a `Vec<i64>` of that length (the [`MAX_TREND_POINTS`] upper
68/// bound is enforced only by [`validate_points`], not here).
69#[must_use]
70pub fn timestamps(end: i64, span_secs: i64, points: usize) -> Vec<i64> {
71    // Defensive: a single (or zero) point degenerates to just the
72    // endpoint, side-stepping a divide-by-zero even though the public
73    // entry points validate `points >= 2` first.
74    if points <= 1 {
75        return vec![end];
76    }
77    let start = end.saturating_sub(span_secs);
78    // `points` is an unvalidated caller-supplied count (this helper is
79    // `pub` and does not call `validate_points`); the
80    // `try_from(...).unwrap_or(i64::MAX)` fallback alone keeps the
81    // conversion total without assuming any upper bound. `points - 1`
82    // cannot underflow here: the `points <= 1` early return above
83    // guarantees `points >= 2`.
84    let divisor = i64::try_from(points - 1).unwrap_or(i64::MAX);
85    (0..points)
86        .map(|i| {
87            // `span_secs * i / divisor`: 0 at i=0 (→ start) and span_secs
88            // at i=divisor (→ end), so both endpoints are hit exactly.
89            let i = i64::try_from(i).unwrap_or(i64::MAX);
90            start + span_secs.saturating_mul(i) / divisor
91        })
92        .collect()
93}
94
95/// The change-history metrics of a set of files sampled at several points
96/// in time (issue #333).
97///
98/// Each file maps to a vector aligned 1:1 with [`as_of_points`](Self::as_of_points):
99/// element `i` is `Some(stats)` when the file existed and was tracked at
100/// point `i`, or `None` when it did not exist yet (or was untracked /
101/// binary) at that point.
102///
103/// # Rename limitation
104///
105/// Each snapshot keys files by their path *at that snapshot's tip*, with
106/// renames followed only *within* that snapshot's walk. A file renamed
107/// *between* two sampled points therefore appears as two separate path
108/// series (its old name, then its new name), not one continuous series.
109/// Cross-snapshot rename stitching is deferred; document this for
110/// consumers reading the series.
111#[derive(Clone, Debug)]
112pub struct Trend {
113    as_of_points: Vec<i64>,
114    files: HashMap<PathBuf, Vec<Option<Stats>>>,
115    long_window_days: u32,
116    recent_window_days: u32,
117    truncated_shallow_clone: bool,
118}
119
120impl Trend {
121    /// Assemble a [`Trend`] from one [`HistoryIndex`](super::HistoryIndex)
122    /// per time point.
123    ///
124    /// `as_of_points` are the sampled timestamps (oldest-first).
125    /// `per_point` is the matching per-file `Stats` map for each point, in
126    /// the same order; a point that resolved to no commit (the repository
127    /// did not exist yet) contributes an empty map, leaving every file
128    /// `None` there. The two slices must be the same length — a backend
129    /// invariant, so a mismatch is a programming error rather than a
130    /// user-facing one.
131    #[must_use]
132    pub fn from_snapshots(
133        as_of_points: Vec<i64>,
134        per_point: Vec<HashMap<PathBuf, Stats>>,
135        long_window_days: u32,
136        recent_window_days: u32,
137        truncated_shallow_clone: bool,
138    ) -> Self {
139        let n = as_of_points.len();
140        // Backend invariant: one snapshot map per sampled point.
141        debug_assert_eq!(per_point.len(), n, "one snapshot per as-of point");
142        let mut files: HashMap<PathBuf, Vec<Option<Stats>>> = HashMap::new();
143        for (i, snapshot) in per_point.into_iter().enumerate().take(n) {
144            for (path, stats) in snapshot {
145                // `i < n` (enumerate over a `take(n)`) and the seeded
146                // vector is length `n`, so the slot always exists.
147                if let Some(slot) = files
148                    .entry(path)
149                    .or_insert_with(|| vec![None; n])
150                    .get_mut(i)
151                {
152                    *slot = Some(stats);
153                }
154            }
155        }
156        Self {
157            as_of_points,
158            files,
159            long_window_days,
160            recent_window_days,
161            truncated_shallow_clone,
162        }
163    }
164
165    /// The sampled timestamps, oldest-first, aligned 1:1 with each file's
166    /// point vector.
167    #[must_use]
168    pub fn as_of_points(&self) -> &[i64] {
169        &self.as_of_points
170    }
171
172    /// Iterate `(repo-relative path, per-point stats)` pairs. Each inner
173    /// slice is aligned with [`as_of_points`](Self::as_of_points);
174    /// elements are `None` where the file did not exist.
175    pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &[Option<Stats>])> {
176        self.files.iter().map(|(path, points)| (path, &points[..]))
177    }
178
179    /// Number of files tracked across the whole trend (the union over all
180    /// points).
181    #[must_use]
182    pub fn len(&self) -> usize {
183        self.files.len()
184    }
185
186    /// Whether the trend tracked no files at any point.
187    #[must_use]
188    pub fn is_empty(&self) -> bool {
189        self.files.is_empty()
190    }
191
192    /// Long window length, in days (constant across all points).
193    #[must_use]
194    pub fn long_window_days(&self) -> u32 {
195        self.long_window_days
196    }
197
198    /// Recent window length, in days (constant across all points).
199    #[must_use]
200    pub fn recent_window_days(&self) -> u32 {
201        self.recent_window_days
202    }
203
204    /// `true` when any sampled snapshot came from a shallow clone, so the
205    /// per-point counts are lower bounds.
206    #[must_use]
207    pub fn truncated_shallow_clone(&self) -> bool {
208        self.truncated_shallow_clone
209    }
210
211    /// The most-improved and most-regressed files by change in
212    /// `risk_score` between each file's earliest and latest *present*
213    /// points.
214    ///
215    /// A file is only eligible when it is present at two or more points
216    /// (otherwise there is no direction to report). `delta = last_risk -
217    /// first_risk`: negative means risk fell (improved), positive means it
218    /// rose (regressed). `top` truncates each list (`0` keeps all). Ties
219    /// break on the file path so the output is deterministic.
220    #[must_use]
221    pub fn deltas(&self, top: usize) -> TrendDeltas {
222        let mut improved: Vec<TrendDelta> = Vec::new();
223        let mut regressed: Vec<TrendDelta> = Vec::new();
224        for (path, points) in &self.files {
225            let Some(delta) = self.file_delta(path, points) else {
226                continue;
227            };
228            if delta.delta < 0.0 {
229                improved.push(delta);
230            } else if delta.delta > 0.0 {
231                regressed.push(delta);
232            }
233            // A zero delta is neither improving nor regressing; omit it.
234        }
235        // Improved: most-negative first. Regressed: most-positive first.
236        improved.sort_by(|a, b| {
237            a.delta
238                .partial_cmp(&b.delta)
239                .unwrap_or(std::cmp::Ordering::Equal)
240                .then_with(|| a.path.cmp(&b.path))
241        });
242        regressed.sort_by(|a, b| {
243            b.delta
244                .partial_cmp(&a.delta)
245                .unwrap_or(std::cmp::Ordering::Equal)
246                .then_with(|| a.path.cmp(&b.path))
247        });
248        if top > 0 {
249            improved.truncate(top);
250            regressed.truncate(top);
251        }
252        TrendDeltas {
253            improved,
254            regressed,
255        }
256    }
257
258    /// Compute one file's risk-score delta between its earliest and latest
259    /// present points, or `None` when it is present at fewer than two
260    /// points.
261    fn file_delta(&self, path: &std::path::Path, points: &[Option<Stats>]) -> Option<TrendDelta> {
262        let mut present = points
263            .iter()
264            .enumerate()
265            .filter_map(|(i, stats)| stats.as_ref().map(|s| (i, s)));
266        let (first_i, first) = present.next()?;
267        // `next_back` (not `last`) avoids re-walking the whole iterator;
268        // it yields `None` when only the first point was present.
269        let (last_i, last) = present.next_back()?;
270        Some(TrendDelta {
271            path: path.to_path_buf(),
272            first_as_of: self.as_of_points[first_i],
273            last_as_of: self.as_of_points[last_i],
274            first_risk_score: first.risk_score,
275            last_risk_score: last.risk_score,
276            delta: last.risk_score - first.risk_score,
277        })
278    }
279}
280
281/// One file's risk-score movement across the trend (see
282/// [`Trend::deltas`]).
283#[derive(Clone, Debug, PartialEq)]
284pub struct TrendDelta {
285    /// Repository-relative path.
286    pub path: PathBuf,
287    /// Timestamp of the file's earliest present point.
288    pub first_as_of: i64,
289    /// Timestamp of the file's latest present point.
290    pub last_as_of: i64,
291    /// `risk_score` at the earliest present point.
292    pub first_risk_score: f64,
293    /// `risk_score` at the latest present point.
294    pub last_risk_score: f64,
295    /// `last_risk_score - first_risk_score`; negative = improved.
296    pub delta: f64,
297}
298
299/// The improving / regressing split returned by [`Trend::deltas`].
300#[derive(Clone, Debug, Default, PartialEq)]
301pub struct TrendDeltas {
302    /// Files whose risk fell, most-improved first.
303    pub improved: Vec<TrendDelta>,
304    /// Files whose risk rose, most-regressed first.
305    pub regressed: Vec<TrendDelta>,
306}
307
308#[cfg(test)]
309#[path = "trend_tests.rs"]
310mod tests;