Skip to main content

speccoder_core/
similar.rs

1//! Is a new feature's description close to a requirement the project
2//! already has? A deterministic text similarity — normalised tokens, a
3//! fixed stop-word list, a suffix stemmer, TF-IDF weights over the
4//! project's requirements and cosine — so the answer is the same on every
5//! run and explains itself by the terms it shares. A hint: it never
6//! blocks, and no model produces or suppresses a match.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11use unicode_normalization::UnicodeNormalization;
12
13/// A score at or above this is *close*.
14pub const CLOSE: f64 = 0.3;
15/// A score at or above this is *very close*.
16pub const VERY_CLOSE: f64 = 0.6;
17/// At most this many matches.
18pub const MAX_MATCHES: usize = 3;
19/// At most this many shared terms per match.
20pub const MAX_SHARED: usize = 4;
21/// The corpus is limited to the requirements of this many units, the most
22/// recently changed first.
23pub const MAX_UNITS: usize = 2000;
24
25/// Words that carry no meaning of their own here: English function words
26/// and the words every requirement uses.
27const STOP: &[&str] = &[
28    "a",
29    "about",
30    "above",
31    "after",
32    "again",
33    "against",
34    "all",
35    "also",
36    "am",
37    "an",
38    "and",
39    "any",
40    "are",
41    "as",
42    "at",
43    "be",
44    "because",
45    "been",
46    "before",
47    "being",
48    "below",
49    "between",
50    "both",
51    "but",
52    "by",
53    "can",
54    "cannot",
55    "could",
56    "did",
57    "do",
58    "does",
59    "doing",
60    "down",
61    "during",
62    "each",
63    "either",
64    "else",
65    "even",
66    "ever",
67    "every",
68    "few",
69    "for",
70    "from",
71    "further",
72    "get",
73    "gets",
74    "given",
75    "had",
76    "has",
77    "have",
78    "having",
79    "he",
80    "her",
81    "here",
82    "hers",
83    "him",
84    "his",
85    "how",
86    "if",
87    "in",
88    "into",
89    "is",
90    "it",
91    "its",
92    "itself",
93    "just",
94    "let",
95    "lets",
96    "may",
97    "me",
98    "might",
99    "more",
100    "most",
101    "much",
102    "my",
103    "neither",
104    "no",
105    "nor",
106    "not",
107    "now",
108    "of",
109    "off",
110    "on",
111    "once",
112    "one",
113    "only",
114    "or",
115    "other",
116    "our",
117    "ours",
118    "out",
119    "over",
120    "own",
121    "per",
122    "same",
123    "shall",
124    "she",
125    "so",
126    "some",
127    "such",
128    "than",
129    "that",
130    "the",
131    "their",
132    "theirs",
133    "them",
134    "then",
135    "there",
136    "these",
137    "they",
138    "this",
139    "those",
140    "through",
141    "to",
142    "too",
143    "under",
144    "until",
145    "up",
146    "upon",
147    "us",
148    "very",
149    "via",
150    "was",
151    "we",
152    "were",
153    "what",
154    "when",
155    "where",
156    "whether",
157    "which",
158    "while",
159    "who",
160    "whom",
161    "why",
162    "will",
163    "with",
164    "within",
165    "without",
166    "would",
167    "yet",
168    "you",
169    "your",
170    "yours",
171    "able",
172    "allow",
173    "allows",
174    "allowed",
175    "must",
176    "should",
177    "system",
178    "user",
179    "users",
180    "feature",
181    "requirement",
182    "new",
183    "want",
184    "wants",
185    "need",
186    "needs",
187    "make",
188    "makes",
189    "use",
190    "uses",
191    "using",
192    "way",
193    "ways",
194    "etc",
195    "e",
196    "g",
197    "i",
198    "ie",
199    "eg",
200];
201
202/// The stem of a lowercase word: `ies`→`y`, then one of `es`, `s`,
203/// `ed`, `ing`, `ly` stripped where enough of the word remains.
204fn stem(word: &str) -> String {
205    let n = word.chars().count();
206    if n > 4
207        && let Some(base) = word.strip_suffix("ies")
208    {
209        return format!("{base}y");
210    }
211    for (suffix, keep) in [("ing", 4), ("ed", 3), ("ly", 3), ("es", 3), ("s", 3)] {
212        if let Some(base) = word.strip_suffix(suffix)
213            && base.chars().count() >= keep
214            && !(suffix == "s" && base.ends_with('s'))
215        {
216            // `es` only after a sibilant (`boxes`, `matches`); else `s`.
217            if suffix == "es"
218                && !["s", "x", "z", "ch", "sh"]
219                    .iter()
220                    .any(|e| base.ends_with(e))
221            {
222                continue;
223            }
224            return base.to_owned();
225        }
226    }
227    word.to_owned()
228}
229
230/// The terms of `text`: NFKC-normalised, lowercased runs of letters and
231/// digits of two characters or more, stop words dropped, stemmed.
232#[must_use]
233pub fn tokenize(text: &str) -> Vec<String> {
234    let normal: String = text.nfkc().collect::<String>().to_lowercase();
235    normal
236        .split(|c: char| !c.is_alphanumeric())
237        .filter(|w| w.chars().count() >= 2 && !STOP.contains(w))
238        .map(stem)
239        .filter(|w| w.chars().count() >= 2 && !STOP.contains(&w.as_str()))
240        .collect()
241}
242
243/// One requirement of the corpus.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct Requirement {
246    /// The unit's directory name or change id (`003-reports`).
247    pub unit: String,
248    /// Its identifier (`FR-004`) or the format's requirement title.
249    pub id: String,
250    /// The requirement as written.
251    pub text: String,
252    /// Where it is written, for a comment anchored to it: the document
253    /// (project-relative), the line (1-based) and that line verbatim.
254    pub at: Option<Place>,
255}
256
257/// Where a requirement is written.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259pub struct Place {
260    /// The document, project-relative.
261    pub path: String,
262    /// The line, 1-based.
263    pub line: u32,
264    /// That line, verbatim.
265    pub quote: String,
266}
267
268/// How close a match is.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
270#[serde(rename_all = "snake_case")]
271pub enum SimilarLevel {
272    /// At or above [`CLOSE`].
273    Close,
274    /// At or above [`VERY_CLOSE`].
275    VeryClose,
276}
277
278impl SimilarLevel {
279    /// The word a person reads (`very close`).
280    #[must_use]
281    pub const fn word(self) -> &'static str {
282        match self {
283            Self::Close => "close",
284            Self::VeryClose => "very close",
285        }
286    }
287}
288
289/// A requirement close to the description.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
291pub struct SimilarMatch {
292    /// The unit (`003-reports`).
293    pub unit: String,
294    /// The requirement's identifier (`FR-004`).
295    pub requirement: String,
296    /// The requirement as written.
297    pub text: String,
298    /// Cosine in [0, 1], rounded to two places.
299    pub score: f64,
300    /// `close` or `very_close`.
301    pub level: SimilarLevel,
302    /// The terms it shares with the description, the weightiest first.
303    pub shared: Vec<String>,
304    /// Where it is written.
305    pub at: Option<Place>,
306}
307
308/// The unit's number (`003-reports` → 3), for ordering ties.
309fn unit_number(unit: &str) -> u64 {
310    unit.chars()
311        .take_while(char::is_ascii_digit)
312        .collect::<String>()
313        .parse()
314        .unwrap_or(u64::MAX)
315}
316
317type Vector = BTreeMap<String, f64>;
318
319fn counts(terms: &[String]) -> BTreeMap<String, f64> {
320    let mut m = BTreeMap::new();
321    for t in terms {
322        *m.entry(t.clone()).or_insert(0.0) += 1.0;
323    }
324    m
325}
326
327fn norm(v: &Vector) -> f64 {
328    v.values().map(|w| w * w).sum::<f64>().sqrt()
329}
330
331/// The requirements of `corpus` at or above [`CLOSE`] to `query`, at most
332/// [`MAX_MATCHES`], by score, then unit number, then identifier. The same
333/// input gives the same output; an empty query gives none.
334#[must_use]
335pub fn matches(query: &str, corpus: &[Requirement]) -> Vec<SimilarMatch> {
336    let q_terms = tokenize(query);
337    if q_terms.is_empty() || corpus.is_empty() {
338        return Vec::new();
339    }
340    let docs: Vec<BTreeMap<String, f64>> =
341        corpus.iter().map(|r| counts(&tokenize(&r.text))).collect();
342    let q_counts = counts(&q_terms);
343    // Document frequencies over the corpus and the query.
344    let mut df: BTreeMap<&str, f64> = BTreeMap::new();
345    for d in docs.iter().chain(std::iter::once(&q_counts)) {
346        for t in d.keys() {
347            *df.entry(t.as_str()).or_insert(0.0) += 1.0;
348        }
349    }
350    #[allow(clippy::cast_precision_loss)]
351    let n = (docs.len() + 1) as f64;
352    let idf = |t: &str| ((1.0 + n) / (1.0 + df.get(t).copied().unwrap_or(0.0))).ln() + 1.0;
353    let weigh = |c: &BTreeMap<String, f64>| -> Vector {
354        c.iter().map(|(t, tf)| (t.clone(), tf * idf(t))).collect()
355    };
356    let q = weigh(&q_counts);
357    let qn = norm(&q);
358    let mut out: Vec<(f64, &Requirement, Vec<String>)> = Vec::new();
359    for (r, d) in corpus.iter().zip(&docs) {
360        let v = weigh(d);
361        let vn = norm(&v);
362        if vn == 0.0 || qn == 0.0 {
363            continue;
364        }
365        let mut shared: Vec<(f64, &str)> = q
366            .iter()
367            .filter_map(|(t, w)| v.get(t).map(|dw| (w * dw, t.as_str())))
368            .collect();
369        let dot: f64 = shared.iter().map(|(p, _)| p).sum();
370        let score = (dot / (qn * vn)).clamp(0.0, 1.0);
371        if score < CLOSE {
372            continue;
373        }
374        shared.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
375        let shared = shared
376            .into_iter()
377            .take(MAX_SHARED)
378            .map(|(_, t)| t.to_owned())
379            .collect();
380        out.push((score, r, shared));
381    }
382    out.sort_by(|a, b| {
383        b.0.total_cmp(&a.0)
384            .then_with(|| unit_number(&a.1.unit).cmp(&unit_number(&b.1.unit)))
385            .then_with(|| a.1.unit.cmp(&b.1.unit))
386            .then_with(|| a.1.id.cmp(&b.1.id))
387    });
388    out.into_iter()
389        .take(MAX_MATCHES)
390        .map(|(score, r, shared)| SimilarMatch {
391            unit: r.unit.clone(),
392            requirement: r.id.clone(),
393            text: r.text.clone(),
394            score: (score * 100.0).round() / 100.0,
395            level: if score >= VERY_CLOSE {
396                SimilarLevel::VeryClose
397            } else {
398                SimilarLevel::Close
399            },
400            shared,
401            at: r.at.clone(),
402        })
403        .collect()
404}
405
406/// The terms two texts share, sorted (for a symmetric check).
407#[must_use]
408pub fn shared_terms(a: &str, b: &str) -> BTreeSet<String> {
409    let a: BTreeSet<String> = tokenize(a).into_iter().collect();
410    tokenize(b).into_iter().filter(|t| a.contains(t)).collect()
411}
412
413/// The `similar` payload: what was compared and what is close.
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
415pub struct SimilarReport {
416    /// The description, as given.
417    pub query: String,
418    /// How many requirements it was compared with.
419    pub compared: usize,
420    /// Whether the corpus was limited to the [`MAX_UNITS`] most recently
421    /// changed units.
422    pub limited: bool,
423    /// The close ones, the closest first.
424    pub matches: Vec<SimilarMatch>,
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn stems_fold_the_usual_suffixes() {
433        assert_eq!(stem("reports"), "report");
434        assert_eq!(stem("exporting"), "export");
435        assert_eq!(stem("exported"), "export");
436        assert_eq!(stem("categories"), "category");
437        assert_eq!(stem("boxes"), "box");
438        assert_eq!(stem("class"), "class");
439        assert_eq!(stem("quickly"), "quick");
440        assert_eq!(stem("is"), "is");
441    }
442
443    #[test]
444    fn tokens_are_normalised_and_stop_words_dropped() {
445        assert_eq!(
446            tokenize("Users MUST be able to export Reports as CSV"),
447            vec!["export", "report", "csv"]
448        );
449        assert!(tokenize("the a of").is_empty());
450    }
451}