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;