Skip to main content

software_engineering/
satisfaction_metrics.rs

1//! # Satisfaction and Well-Being Metrics
2//!
3//! **Satisfaction and well-being**, the S in SPACE (chapter 3.1), is the
4//! dimension no system telemetry can observe directly. Whether an
5//! engineer finds their work meaningful, whether they feel supported by
6//! their team, whether they are heading toward burnout — none of this
7//! leaves a trace in a version control log or a CI pipeline. It has to
8//! be asked, deliberately and well, with genuine anonymity as
9//! non-negotiable. This dimension is a leading indicator: declining
10//! satisfaction predicts attrition before an exit interview does, and
11//! rising burnout risk predicts a quality collapse before the defect
12//! rate shows it.
13//!
14//! ## Formula
15//!
16//! ```text
17//! Satisfaction net score = ((promoters - detractors) / total_respondents) × 100
18//!     (an employee Net Promoter-style score, ranging roughly -100 to +100)
19//!
20//! Declining  when current_score < previous_score - decline_threshold
21//! ```
22//!
23//! ## Why it matters
24//!
25//! Any perceived link between an honest answer and a personal
26//! consequence destroys the signal almost immediately: satisfaction data
27//! used to understand and improve team conditions is valuable and
28//! low-risk, but the same data used to rank teams or, worse,
29//! individuals against each other corrupts the survey instrument the
30//! moment people suspect the answer will be used against them or their
31//! team. This module computes an aggregate score and a trend signal only
32//! — it has no concept of an individual respondent, and callers must
33//! guarantee genuine anonymity in how they collect the inputs.
34//!
35//! ## Example
36//!
37//! ```rust
38//! use software_engineering::satisfaction_metrics::{
39//!     satisfaction_net_score, is_satisfaction_declining,
40//! };
41//!
42//! // Of 50 respondents, 30 are promoters and 10 are detractors: a net
43//! // score of +40.
44//! let score = satisfaction_net_score(30.0, 10.0, 50.0).unwrap();
45//! assert!((score - 40.0).abs() < 1e-9);
46//!
47//! // A drop from +40 to +15 (25 points) past a 10-point threshold is a
48//! // leading-indicator warning worth investigating before it shows up
49//! // as attrition.
50//! assert!(is_satisfaction_declining(40.0, 15.0, 10.0));
51//! ```
52//!
53//! ## Pitfalls
54//!
55//! - **Breaking anonymity, even accidentally** — a single incident where
56//!   individual responses can be traced back to a person destroys trust
57//!   in every future survey, especially in small teams where response
58//!   patterns could otherwise be inferable.
59//! - **Using satisfaction data to rank teams or individuals** — corrupts
60//!   the signal almost immediately once people suspect the answer will
61//!   be used against them.
62//! - **Reading a single reading in isolation** — this dimension is a
63//!   leading indicator; track the trend over time, not one snapshot.
64//! - **Ad hoc, unvalidated survey questions** — produces data of unclear
65//!   meaning that resists honest interpretation.
66//!
67//! ## Sources
68//!
69//! - Chapter 3.2, Satisfaction and well-being metrics.
70//!
71//! Topic doc: software-engineering-metrics/locales/en-001/chapters/03-02-satisfaction-and-well-being-metrics.md
72
73/// Employee-satisfaction Net Promoter-style score: the percentage of
74/// promoters minus the percentage of detractors among survey
75/// respondents.
76///
77/// `((promoters - detractors) / total_respondents) × 100`. Ranges
78/// roughly from -100 (every respondent a detractor) to +100 (every
79/// respondent a promoter). This aggregate score has no concept of any
80/// individual respondent — genuine anonymity in collecting the inputs is
81/// the caller's responsibility, per the chapter's central recommendation.
82///
83/// # Arguments
84///
85/// * `promoters` — count of respondents classified as promoters.
86/// * `detractors` — count of respondents classified as detractors.
87/// * `total_respondents` — total count of respondents (promoters,
88///   passives, and detractors combined).
89///
90/// # Returns
91///
92/// The net score, or `None` if `total_respondents` is zero.
93///
94/// # Examples
95///
96/// ```rust
97/// use software_engineering::satisfaction_metrics::satisfaction_net_score;
98///
99/// // 30 promoters, 10 detractors, out of 50 respondents: net +40.
100/// assert_eq!(satisfaction_net_score(30.0, 10.0, 50.0), Some(40.0));
101/// // More detractors than promoters yields a negative score.
102/// assert_eq!(satisfaction_net_score(5.0, 20.0, 50.0), Some(-30.0));
103/// assert_eq!(satisfaction_net_score(1.0, 1.0, 0.0), None);
104/// ```
105#[must_use]
106pub fn satisfaction_net_score(promoters: f64, detractors: f64, total_respondents: f64) -> Option<f64> {
107    if total_respondents == 0.0 {
108        return None;
109    }
110    Some(((promoters - detractors) / total_respondents) * 100.0)
111}
112
113/// Whether a satisfaction score has declined enough between two
114/// measurement periods to warrant treating it as an early
115/// attrition/burnout warning, per the chapter's framing of this
116/// dimension as a leading indicator rather than a lagging one.
117///
118/// True iff `current_score < previous_score - decline_threshold`.
119///
120/// # Arguments
121///
122/// * `previous_score` — the satisfaction net score from an earlier
123///   measurement period.
124/// * `current_score` — the satisfaction net score from the current
125///   period.
126/// * `decline_threshold` — how many points of decline (a positive
127///   number) is treated as meaningful rather than ordinary noise.
128///
129/// # Returns
130///
131/// `true` if the decline exceeds `decline_threshold`.
132///
133/// # Examples
134///
135/// ```rust
136/// use software_engineering::satisfaction_metrics::is_satisfaction_declining;
137///
138/// // A 25-point drop past a 10-point threshold: a real warning.
139/// assert!(is_satisfaction_declining(40.0, 15.0, 10.0));
140/// // A 3-point drop within a 10-point threshold: ordinary noise.
141/// assert!(!is_satisfaction_declining(40.0, 37.0, 10.0));
142/// // A rise is never a decline.
143/// assert!(!is_satisfaction_declining(40.0, 55.0, 10.0));
144/// ```
145#[must_use]
146pub fn is_satisfaction_declining(previous_score: f64, current_score: f64, decline_threshold: f64) -> bool {
147    current_score < previous_score - decline_threshold
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    // "Whether an engineer finds their work meaningful... has to be
155    // asked" — the net score aggregates exactly that survey data.
156    #[test]
157    fn net_score_is_positive_when_promoters_outnumber_detractors() {
158        let score = satisfaction_net_score(30.0, 10.0, 50.0).unwrap();
159        assert!((score - 40.0).abs() < 1e-9);
160    }
161
162    #[test]
163    fn net_score_is_negative_when_detractors_outnumber_promoters() {
164        let score = satisfaction_net_score(5.0, 20.0, 50.0).unwrap();
165        assert!((score - (-30.0)).abs() < 1e-9);
166    }
167
168    #[test]
169    fn net_score_is_none_for_zero_respondents() {
170        assert_eq!(satisfaction_net_score(1.0, 1.0, 0.0), None);
171    }
172
173    // "This dimension is a leading indicator, not a lagging one. It
174    // predicts [attrition and burnout] before they show up elsewhere."
175    #[test]
176    fn a_drop_past_the_threshold_is_flagged_as_declining() {
177        assert!(is_satisfaction_declining(40.0, 15.0, 10.0));
178    }
179
180    #[test]
181    fn a_small_drop_within_the_threshold_is_not_flagged() {
182        assert!(!is_satisfaction_declining(40.0, 37.0, 10.0));
183    }
184
185    #[test]
186    fn a_rise_is_never_flagged_as_declining() {
187        assert!(!is_satisfaction_declining(40.0, 55.0, 10.0));
188    }
189}