Skip to main content

pma_core/
complexity.rs

1//! Deterministic complexity, 1 to 5, from features a scan and a dispatch
2//! already know. No model is called.
3//!
4//! The value drives routing in phase 4, so both the features and the rule
5//! that reads them are versioned and snapshotted on the run. Replaying a
6//! recorded decision means re-running the rule named there, not the current
7//! one.
8
9use crate::class::Class;
10
11/// Bumped whenever `estimate` changes what it returns for the same features.
12/// A run records the version it was dispatched under.
13pub const ESTIMATOR: &str = "v1";
14
15/// What the rule reads. Every field is measured, not judged.
16#[derive(Debug, Clone, Copy, PartialEq, Default)]
17pub struct Features {
18    /// The task text or its description names a path or a symbol, so the
19    /// work has somewhere to start.
20    pub names_a_place: bool,
21    pub text_words: i64,
22    pub description_lines: i64,
23    /// Files tracked in the repository.
24    pub repo_files: i64,
25    /// How long `verify` took at the base, or `None` when there is none.
26    pub verify_seconds: Option<i64>,
27    /// Decided runs in this project, and how many of those were accepted.
28    pub prior_decided: i64,
29    pub prior_accepted: i64,
30}
31
32/// A repository large enough that a change is likely to reach further than
33/// the task says. Measured in tracked files.
34const LARGE_REPO: i64 = 500;
35
36/// Below this, a task's text cannot carry an acceptance criterion.
37const TERSE: i64 = 4;
38
39impl Features {
40    /// The stored shape, written out field by field rather than derived, so
41    /// a replay years later reads the same names this file names.
42    pub fn to_json(self) -> String {
43        serde_json::json!({
44            "names_a_place": self.names_a_place,
45            "text_words": self.text_words,
46            "description_lines": self.description_lines,
47            "repo_files": self.repo_files,
48            "verify_seconds": self.verify_seconds,
49            "prior_decided": self.prior_decided,
50            "prior_accepted": self.prior_accepted,
51        })
52        .to_string()
53    }
54
55    /// A missing or unreadable field reads as its default, so an older row
56    /// replays rather than failing.
57    pub fn from_json(text: &str) -> Option<Features> {
58        let v: serde_json::Value = serde_json::from_str(text).ok()?;
59        let int = |k: &str| v[k].as_i64().unwrap_or(0);
60        Some(Features {
61            names_a_place: v["names_a_place"].as_bool().unwrap_or(false),
62            text_words: int("text_words"),
63            description_lines: int("description_lines"),
64            repo_files: int("repo_files"),
65            verify_seconds: v["verify_seconds"].as_i64(),
66            prior_decided: int("prior_decided"),
67            prior_accepted: int("prior_accepted"),
68        })
69    }
70
71    /// Reads the text and description. The other fields come from the
72    /// repository and the store, so the caller fills them in.
73    pub fn of(text: &str, description: &str) -> Features {
74        Features {
75            names_a_place: names_a_place(text) || names_a_place(description),
76            text_words: text.split_whitespace().count() as i64,
77            description_lines: description.lines().filter(|l| !l.trim().is_empty()).count() as i64,
78            ..Features::default()
79        }
80    }
81}
82
83/// A word that looks like a path or a symbol: `src/scan.rs`, `TODO.md`,
84/// `glob_match()`, `Store::open`. Deliberately crude, and only ever one
85/// point of five.
86fn names_a_place(text: &str) -> bool {
87    text.split_whitespace().any(|word| {
88        let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != ')' && c != '/');
89        word.ends_with("()")
90            || word.contains("::")
91            || (word.contains('/') && !word.contains("://") && is_path(word))
92            || is_file(word)
93    })
94}
95
96/// `docs/dev/`, `src/scan.rs`, `a/b/c`; not `and/or`.
97fn is_path(word: &str) -> bool {
98    word.ends_with('/')
99        || word.matches('/').count() >= 2
100        || word.rsplit('/').next().is_some_and(is_file)
101}
102
103/// `TODO.md`, `scan.rs:42`; not `e.g`, `i.e` or a product such as `Node.js`.
104fn is_file(word: &str) -> bool {
105    let word = match word.split_once(':') {
106        Some((file, at)) if at.chars().all(|c| c.is_ascii_digit() || c == '-') => file,
107        _ => word,
108    };
109    word.rsplit_once('.').is_some_and(|(stem, ext)| {
110        stem.len() >= 2
111            && (1..=4).contains(&ext.len())
112            && ext.chars().all(|c| c.is_ascii_alphabetic())
113            && !(ext == "js" && stem.starts_with(|c: char| c.is_ascii_uppercase()))
114    })
115}
116
117/// 1 is a mechanical change with a check that decides it; 5 is judgment work
118/// no gate can accept. The rule is a sum of named adjustments so a recorded
119/// estimate can be argued with.
120pub fn estimate(class: Class, f: &Features) -> i64 {
121    let mut score = match class {
122        // A change bounded to manifests, accepted by the existing suite.
123        Class::Mechanical => 2,
124        Class::Privileged => 3,
125        Class::Specified => 3,
126        Class::Judgment | Class::Never => 5,
127    };
128    if class == Class::Judgment || class == Class::Never {
129        return score;
130    }
131    if f.names_a_place {
132        score -= 1;
133    }
134    if f.description_lines >= 3 {
135        score -= 1;
136    }
137    if f.text_words < TERSE && f.description_lines == 0 {
138        score += 1;
139    }
140    if f.repo_files > LARGE_REPO {
141        score += 1;
142    }
143    if f.verify_seconds.is_none() {
144        score += 1;
145    }
146    // Two decided runs is a thin basis, but it is the project's own record
147    // rather than a guess about the task.
148    if f.prior_decided >= 2 && f.prior_accepted * 2 < f.prior_decided {
149        score += 1;
150    }
151    score.clamp(1, 5)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn specified() -> Features {
159        Features {
160            text_words: 8,
161            repo_files: 50,
162            verify_seconds: Some(10),
163            ..Features::default()
164        }
165    }
166
167    #[test]
168    fn a_path_or_a_symbol_counts_as_a_starting_point() {
169        for text in [
170            "fix src/scan.rs",
171            "glob_match() rejects **",
172            "Store::open leaks",
173            "update README.md",
174            "document docs/dev/",
175            "move the check to src/dispatch.rs:42",
176        ] {
177            assert!(names_a_place(text), "{text}");
178        }
179        for text in [
180            "make the parser faster",
181            "see https://example.com/x",
182            "decide on the api",
183            "keep and/or drop it",
184            "a flag, e.g. --all, or i.e. none",
185            "support Node.js and Vue.js",
186        ] {
187            assert!(!names_a_place(text), "{text}");
188        }
189    }
190
191    #[test]
192    fn a_bounded_class_with_a_check_is_simple() {
193        assert_eq!(estimate(Class::Mechanical, &specified()), 2);
194        let located = Features {
195            names_a_place: true,
196            ..specified()
197        };
198        assert_eq!(estimate(Class::Mechanical, &located), 1);
199        assert_eq!(estimate(Class::Specified, &located), 2);
200    }
201
202    #[test]
203    fn a_one_liner_in_a_large_repository_without_a_check_is_hard() {
204        let f = Features {
205            text_words: 2,
206            repo_files: 900,
207            verify_seconds: None,
208            ..Features::default()
209        };
210        assert_eq!(estimate(Class::Specified, &f), 5);
211    }
212
213    /// Detail is the one input the user controls, and the largest gap in the
214    /// portfolio: 2 of 160 items carry a description.
215    #[test]
216    fn a_description_lowers_the_estimate() {
217        let mut f = specified();
218        assert_eq!(estimate(Class::Specified, &f), 3);
219        f.description_lines = 3;
220        assert_eq!(estimate(Class::Specified, &f), 2);
221    }
222
223    /// The project's own record, not a guess about the task.
224    #[test]
225    fn a_project_that_rejects_this_work_raises_it() {
226        let mut f = specified();
227        f.prior_decided = 4;
228        f.prior_accepted = 1;
229        assert_eq!(estimate(Class::Specified, &f), 4);
230        f.prior_accepted = 2;
231        assert_eq!(estimate(Class::Specified, &f), 3, "half is not worse");
232        f.prior_decided = 1;
233        f.prior_accepted = 0;
234        assert_eq!(estimate(Class::Specified, &f), 3, "one run decides nothing");
235    }
236
237    #[test]
238    fn judgment_work_is_five_whatever_else_is_true() {
239        let f = Features {
240            names_a_place: true,
241            description_lines: 10,
242            ..specified()
243        };
244        assert_eq!(estimate(Class::Judgment, &f), 5);
245        assert_eq!(estimate(Class::Never, &f), 5);
246    }
247
248    #[test]
249    fn features_round_trip_through_their_stored_shape() {
250        let f = Features {
251            names_a_place: true,
252            text_words: 9,
253            description_lines: 2,
254            repo_files: 120,
255            verify_seconds: Some(31),
256            prior_decided: 5,
257            prior_accepted: 3,
258        };
259        assert_eq!(Features::from_json(&f.to_json()), Some(f));
260        // A row written before a field existed replays as its default.
261        assert_eq!(
262            Features::from_json(r#"{"text_words":4}"#),
263            Some(Features {
264                text_words: 4,
265                ..Features::default()
266            })
267        );
268        assert_eq!(Features::from_json("not json"), None);
269    }
270
271    #[test]
272    fn features_read_the_text_and_the_description() {
273        let f = Features::of("fix the parser", "it drops src/a.rs\n\nand b\n");
274        assert_eq!(
275            (f.names_a_place, f.text_words, f.description_lines),
276            (true, 3, 2)
277        );
278    }
279}