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}