Skip to main content

software_engineering/
activity_metrics.rs

1//! # Activity Metrics and Their Limits
2//!
3//! **Activity**, the A in SPACE (chapter 3.1), counts the volume of
4//! engineering work observable from system telemetry: commits, pull
5//! requests opened, lines of code changed. It is the easiest SPACE
6//! dimension to measure, because every one of these events is already
7//! logged automatically — and that ease of measurement is exactly what
8//! makes this dimension the most dangerous one to over-weight. Activity
9//! measures motion, not value: a commit count does not distinguish
10//! between a commit that solved a hard problem elegantly and a commit
11//! that split one meaningful change into five to look more productive.
12//!
13//! ## Formula
14//!
15//! ```text
16//! Commit substitution gaming signal =
17//!     commit_count rose AND average_commit_size shrank
18//!
19//! Activity rate = commits / engineers / weeks
20//!     (a contextual signal only, never a standalone productivity proxy)
21//! ```
22//!
23//! ## Why it matters
24//!
25//! This is the single most historically misused metric family in
26//! software engineering measurement. Once activity becomes an
27//! incentivized individual metric, gaming follows almost immediately:
28//! padding commits, splitting changes trivially, avoiding deep,
29//! unglamorous work that produces few visible events. This module exists
30//! to detect that specific gaming pattern and to compute an aggregate
31//! rate for context — never to rank or score an individual. Commit
32//! count, lines of code, and pull request count should never appear in
33//! an individual performance review, a comparative ranking, or any
34//! context where an engineer's compensation, standing, or reputation
35//! depends on the number.
36//!
37//! ## Example
38//!
39//! ```rust
40//! use software_engineering::activity_metrics::{
41//!     is_commit_substitution_gaming_signal, commits_per_engineer_per_week,
42//! };
43//!
44//! // A team's commit count rose 40% while average commit size fell by
45//! // more than half — meaningful changes were likely split into many
46//! // trivial ones to inflate the count.
47//! assert!(is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 50.0));
48//!
49//! // Both count and size rising together is ordinary growth, not gaming.
50//! assert!(!is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 140.0));
51//!
52//! // Read only in aggregate, alongside the other SPACE dimensions —
53//! // never as a standalone verdict on any one person or team.
54//! let rate = commits_per_engineer_per_week(120.0, 6.0, 4.0).unwrap();
55//! assert!((rate - 5.0).abs() < 1e-9);
56//! ```
57//!
58//! ## Pitfalls
59//!
60//! - **Ranking or evaluating individuals by raw activity counts** — the
61//!   single hardest, most important rule this chapter states; the moment
62//!   activity becomes an incentivized individual metric, gaming follows
63//!   almost immediately.
64//! - **Reading an activity number in isolation** — a sharp drop in
65//!   team-level commit activity alongside a rise in satisfaction might
66//!   mean the team finally had breathing room to pay down technical
67//!   debt, a positive pattern that looks alarming without that context.
68//! - **Treating raw volume as a quality-adjacent signal** — prefer size
69//!   relative to review depth, or the ratio of new code to code removed,
70//!   over a bare count.
71//! - **Missing the substitution-gaming pattern** — rising frequency
72//!   alongside sharply falling change size is the clearest sign activity
73//!   is being inflated rather than genuinely increasing.
74//!
75//! ## Sources
76//!
77//! - Chapter 3.4, Activity metrics and their limits.
78//!
79//! Topic doc: software-engineering-metrics/locales/en-001/chapters/03-04-activity-metrics-and-their-limits.md
80
81/// Whether a change in commit count and average commit size matches the
82/// chapter's named substitution-gaming pattern: splitting genuinely
83/// meaningful work into many small, trivial commits to inflate a count.
84///
85/// True iff `commit_count_after > commit_count_before` (the count rose)
86/// **and** `average_commit_size_after < average_commit_size_before` (the
87/// average size shrank). Either condition alone is ordinary variation;
88/// both together are the specific pattern this chapter warns about.
89///
90/// # Arguments
91///
92/// * `commit_count_before` — commit count in the earlier period.
93/// * `commit_count_after` — commit count in the later period.
94/// * `average_commit_size_before` — average commit size (e.g. lines
95///   changed) in the earlier period.
96/// * `average_commit_size_after` — average commit size in the later
97///   period.
98///
99/// # Returns
100///
101/// `true` if the count rose while the average size shrank.
102///
103/// # Examples
104///
105/// ```rust
106/// use software_engineering::activity_metrics::is_commit_substitution_gaming_signal;
107///
108/// // Count up, size down: the gaming pattern.
109/// assert!(is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 50.0));
110///
111/// // Count up, size also up: ordinary growth, not gaming.
112/// assert!(!is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 140.0));
113///
114/// // Count down: not the gaming pattern, regardless of size.
115/// assert!(!is_commit_substitution_gaming_signal(70.0, 50.0, 50.0, 120.0));
116/// ```
117#[must_use]
118pub fn is_commit_substitution_gaming_signal(
119    commit_count_before: f64,
120    commit_count_after: f64,
121    average_commit_size_before: f64,
122    average_commit_size_after: f64,
123) -> bool {
124    commit_count_after > commit_count_before && average_commit_size_after < average_commit_size_before
125}
126
127/// Commits per engineer per week — a simple activity rate, provided only
128/// as a contextual signal to read alongside the other SPACE dimensions,
129/// never as a standalone productivity proxy or an individual ranking.
130///
131/// `commits / engineers / weeks`.
132///
133/// # Arguments
134///
135/// * `commits` — total commit count across the period.
136/// * `engineers` — number of engineers the commits are spread across.
137/// * `weeks` — length of the observation period, in weeks.
138///
139/// # Returns
140///
141/// The commit rate per engineer per week, or `None` if `engineers` or
142/// `weeks` is zero.
143///
144/// # Examples
145///
146/// ```rust
147/// use software_engineering::activity_metrics::commits_per_engineer_per_week;
148///
149/// // 120 commits across 6 engineers over 4 weeks: 5 commits/engineer/week.
150/// assert_eq!(commits_per_engineer_per_week(120.0, 6.0, 4.0), Some(5.0));
151/// assert_eq!(commits_per_engineer_per_week(120.0, 0.0, 4.0), None);
152/// assert_eq!(commits_per_engineer_per_week(120.0, 6.0, 0.0), None);
153/// ```
154#[must_use]
155pub fn commits_per_engineer_per_week(commits: f64, engineers: f64, weeks: f64) -> Option<f64> {
156    if engineers == 0.0 || weeks == 0.0 {
157        return None;
158    }
159    Some(commits / engineers / weeks)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    // "The most common way activity metrics get gamed is exactly chapter
167    // 1.2's substitution pattern: splitting genuinely meaningful work
168    // into many small, trivial events to inflate a count."
169    #[test]
170    fn rising_count_with_shrinking_size_is_a_gaming_signal() {
171        assert!(is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 50.0));
172    }
173
174    #[test]
175    fn rising_count_with_rising_size_is_not_a_gaming_signal() {
176        assert!(!is_commit_substitution_gaming_signal(50.0, 70.0, 120.0, 140.0));
177    }
178
179    #[test]
180    fn falling_count_is_not_a_gaming_signal_regardless_of_size() {
181        assert!(!is_commit_substitution_gaming_signal(70.0, 50.0, 50.0, 120.0));
182    }
183
184    // "Activity data becomes genuinely useful when aggregated at the
185    // team level and read alongside the other SPACE dimensions."
186    #[test]
187    fn commit_rate_divides_across_engineers_and_weeks() {
188        let rate = commits_per_engineer_per_week(120.0, 6.0, 4.0).unwrap();
189        assert!((rate - 5.0).abs() < 1e-9);
190    }
191
192    #[test]
193    fn commit_rate_is_none_for_zero_engineers() {
194        assert_eq!(commits_per_engineer_per_week(120.0, 0.0, 4.0), None);
195    }
196
197    #[test]
198    fn commit_rate_is_none_for_zero_weeks() {
199        assert_eq!(commits_per_engineer_per_week(120.0, 6.0, 0.0), None);
200    }
201}