Skip to main content

speccoder_core/
shape.rs

1//! Shape: what a writing assistant changed in a person's words, decided by
2//! code. The model proposes a requirement; this module computes which
3//! words of the proposal are the person's and which were added, which of
4//! the person's words were dropped, which sentences landed nowhere, which
5//! added numbers and terms no assumption explains, and what in the text
6//! makes it hard to test. Nothing here asks the model or believes it.
7
8use serde::{Deserialize, Serialize};
9
10/// Where a stretch of text came from.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum Origin {
14    /// The person wrote it (in the proposal), or it was kept (in theirs).
15    Yours,
16    /// The assistant added it.
17    Added,
18    /// The person wrote it and the proposal left it out.
19    Dropped,
20}
21
22/// A stretch of text and its origin; the stretches of one text, in order,
23/// are that text exactly.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25pub struct Span {
26    /// The text, separators included.
27    pub text: String,
28    /// Where it came from.
29    pub origin: Origin,
30}
31
32/// The proposal's stretches (yours or added) and the person's (yours or
33/// dropped).
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35pub struct Diff {
36    /// Over the proposal.
37    pub proposal: Vec<Span>,
38    /// Over the person's text.
39    pub original: Vec<Span>,
40}
41
42/// Longest texts compared word by word; longer ones are marked whole.
43const MAX_WORDS: usize = 3000;
44
45/// A token: a word (letters and digits) or what lies between words.
46fn tokens(text: &str) -> Vec<(&str, bool)> {
47    let mut out = Vec::new();
48    let mut start = 0;
49    let mut word: Option<bool> = None;
50    for (i, c) in text.char_indices() {
51        let w = c.is_alphanumeric();
52        match word {
53            Some(prev) if prev != w => {
54                out.push((&text[start..i], prev));
55                start = i;
56            }
57            _ => {}
58        }
59        word = Some(w);
60    }
61    if start < text.len() {
62        out.push((&text[start..], word.unwrap_or(false)));
63    }
64    out
65}
66
67/// A word as the diff compares it: lower case, a plural `s` dropped
68/// (`tables` is `table`), so rewording does not read as rewriting.
69fn stem(word: &str) -> String {
70    let w = word.to_lowercase();
71    match w.strip_suffix('s') {
72        Some(base) if base.chars().count() > 2 && !base.ends_with('s') => base.to_owned(),
73        _ => w,
74    }
75}
76
77fn push(spans: &mut Vec<Span>, text: &str, origin: Origin) {
78    match spans.last_mut() {
79        Some(last) if last.origin == origin => last.text.push_str(text),
80        _ if text.is_empty() => {}
81        _ => spans.push(Span {
82            text: text.to_owned(),
83            origin,
84        }),
85    }
86}
87
88/// Which words of `proposal` are `original`'s (the longest common
89/// subsequence of their words, case-insensitive) and which were added;
90/// which of `original`'s words were dropped. Separators follow the word
91/// before them.
92#[must_use]
93pub fn diff(original: &str, proposal: &str) -> Diff {
94    let a = tokens(original);
95    let b = tokens(proposal);
96    let aw: Vec<(usize, String)> = a
97        .iter()
98        .enumerate()
99        .filter(|(_, (_, w))| *w)
100        .map(|(i, (t, _))| (i, stem(t)))
101        .collect();
102    let bw: Vec<(usize, String)> = b
103        .iter()
104        .enumerate()
105        .filter(|(_, (_, w))| *w)
106        .map(|(i, (t, _))| (i, stem(t)))
107        .collect();
108    let mut kept_a = vec![false; a.len()];
109    let mut kept_b = vec![false; b.len()];
110    if aw.len() <= MAX_WORDS && bw.len() <= MAX_WORDS {
111        let (n, m) = (aw.len(), bw.len());
112        let mut lcs = vec![0u16; (n + 1) * (m + 1)];
113        let at = |i: usize, j: usize| i * (m + 1) + j;
114        for i in (0..n).rev() {
115            for j in (0..m).rev() {
116                lcs[at(i, j)] = if aw[i].1 == bw[j].1 {
117                    lcs[at(i + 1, j + 1)] + 1
118                } else {
119                    lcs[at(i + 1, j)].max(lcs[at(i, j + 1)])
120                };
121            }
122        }
123        let (mut i, mut j) = (0, 0);
124        while i < n && j < m {
125            if aw[i].1 == bw[j].1 {
126                kept_a[aw[i].0] = true;
127                kept_b[bw[j].0] = true;
128                i += 1;
129                j += 1;
130            } else if lcs[at(i + 1, j)] >= lcs[at(i, j + 1)] {
131                i += 1;
132            } else {
133                j += 1;
134            }
135        }
136    }
137    let spans = |toks: &[(&str, bool)], kept: &[bool], lost: Origin| {
138        let mut out = Vec::new();
139        let mut last = Origin::Yours;
140        for (k, (t, w)) in toks.iter().enumerate() {
141            let origin = if *w {
142                last = if kept[k] { Origin::Yours } else { lost };
143                last
144            } else {
145                last
146            };
147            push(&mut out, t, origin);
148        }
149        out
150    };
151    Diff {
152        proposal: spans(&b, &kept_b, Origin::Added),
153        original: spans(&a, &kept_a, Origin::Dropped),
154    }
155}
156
157/// Words too common to tell where a sentence went.
158const STOP: &[&str] = &[
159    "the", "and", "for", "that", "this", "with", "from", "are", "was", "but", "not", "can",
160    "should", "must", "will", "shall", "have", "has", "they", "them", "its", "into", "when",
161    "then", "also", "just", "some", "any", "all", "our", "your", "you", "who", "what", "which",
162    "there", "their", "would", "could", "maybe", "probably", "like", "stuff", "thing", "things",
163    "user", "users", "system", "dont", "don", "doesnt", "doesn", "isnt", "isn", "cant", "wont",
164    "won", "shouldnt", "make", "sure", "able",
165];
166
167fn content(text: &str) -> Vec<String> {
168    tokens(text)
169        .into_iter()
170        .filter(|(t, w)| *w && t.chars().count() > 2)
171        .map(|(t, _)| t.to_lowercase())
172        .filter(|t| !STOP.contains(&t.as_str()))
173        .collect()
174}
175
176/// The sentences of a person's text: split at `.`, `!`, `?` and line
177/// breaks; list markers dropped; empty ones skipped.
178#[must_use]
179pub fn sentences(text: &str) -> Vec<String> {
180    let mut out = Vec::new();
181    let mut cur = String::new();
182    let mut flush = |cur: &mut String| {
183        let s = cur
184            .trim()
185            .trim_start_matches(['-', '*', '•'])
186            .trim()
187            .to_owned();
188        if s.chars().any(char::is_alphanumeric) {
189            out.push(s);
190        }
191        cur.clear();
192    };
193    let chars: Vec<char> = text.chars().collect();
194    for (i, c) in chars.iter().enumerate() {
195        if *c == '\n' {
196            flush(&mut cur);
197            continue;
198        }
199        cur.push(*c);
200        let ends =
201            matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
202        if ends {
203            flush(&mut cur);
204        }
205    }
206    flush(&mut cur);
207    out
208}
209
210/// Where one of the person's sentences went.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
212pub struct Coverage {
213    /// The sentence, as the person wrote it.
214    pub sentence: String,
215    /// The proposal's lines (0-based) that carry its words.
216    pub lines: Vec<usize>,
217    /// No line carries it.
218    pub dropped: bool,
219}
220
221/// Each sentence of `original` and the lines of `proposal` sharing at
222/// least half of its content words (at least one); a sentence with no
223/// content words (a greeting) is never dropped.
224#[must_use]
225pub fn coverage(original: &str, proposal: &str) -> Vec<Coverage> {
226    let lines: Vec<Vec<String>> = proposal.lines().map(content).collect();
227    sentences(original)
228        .into_iter()
229        .map(|sentence| {
230            let words = content(&sentence);
231            let need = words.len().div_ceil(2).max(1);
232            let hits: Vec<usize> = lines
233                .iter()
234                .enumerate()
235                .filter(|(_, l)| words.iter().filter(|w| l.contains(w)).count() >= need)
236                .map(|(i, _)| i)
237                .collect();
238            Coverage {
239                dropped: !words.is_empty() && hits.is_empty(),
240                lines: hits,
241                sentence,
242            }
243        })
244        .collect()
245}
246
247/// What the assistant says it introduced.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
249pub struct Assumption {
250    /// The proposal's words that carry it (`up to 10 000 rows`).
251    pub words: String,
252    /// What the person wrote that it reads into, when anything (`fast`).
253    #[serde(default)]
254    pub from: String,
255    /// The question to ask instead of assuming it.
256    pub question: String,
257}
258
259/// Added numbers, quantities and quoted terms that no assumption's words
260/// contain: what the assistant introduced without saying so.
261#[must_use]
262pub fn unexplained(diff: &Diff, assumptions: &[Assumption]) -> Vec<String> {
263    // Compared without case, spaces or punctuation: "10,000 rows" is
264    // "10 000 rows".
265    let bare = |t: &str| -> String {
266        t.chars()
267            .filter(|c| c.is_alphanumeric())
268            .flat_map(char::to_lowercase)
269            .collect()
270    };
271    let covered = |term: &str| {
272        let t = bare(term);
273        assumptions.iter().any(|a| bare(&a.words).contains(&t))
274    };
275    let mut out: Vec<String> = Vec::new();
276    for s in diff.proposal.iter().filter(|s| s.origin == Origin::Added) {
277        let text = s.text.as_str();
278        let words: Vec<(usize, usize)> = tokens(text)
279            .into_iter()
280            .filter(|(_, w)| *w)
281            .map(|(t, _)| {
282                let at = t.as_ptr() as usize - text.as_ptr() as usize;
283                (at, at + t.len())
284            })
285            .collect();
286        let gap = |a: (usize, usize), b: (usize, usize)| &text[a.1..b.0];
287        let mut terms: Vec<String> = Vec::new();
288        // A number as written, with the word after it (`2 seconds`,
289        // `10,000 rows`, `1.5 s`): the exact text, so it can be found.
290        let mut k = 0;
291        while k < words.len() {
292            let w = &text[words[k].0..words[k].1];
293            if w.chars().any(|c| c.is_ascii_digit()) {
294                let start = words[k].0;
295                let mut end = words[k].1;
296                while k + 1 < words.len()
297                    && text[words[k + 1].0..words[k + 1].1]
298                        .chars()
299                        .all(|c| c.is_ascii_digit())
300                    && matches!(
301                        gap(words[k], words[k + 1]),
302                        " " | "," | "." | "\u{a0}" | "\u{202f}"
303                    )
304                {
305                    k += 1;
306                    end = words[k].1;
307                }
308                if k + 1 < words.len()
309                    && text[words[k + 1].0..words[k + 1].1]
310                        .chars()
311                        .all(char::is_alphabetic)
312                    && matches!(gap(words[k], words[k + 1]), " " | "-" | "\u{a0}")
313                {
314                    k += 1;
315                    end = words[k].1;
316                }
317                terms.push(text[start..end].to_owned());
318            }
319            k += 1;
320        }
321        // A quoted or code term (`"admin"`, `` `--force` ``).
322        for q in ['"', '`', '“'] {
323            let close = if q == '“' { '”' } else { q };
324            let mut rest = text;
325            while let Some(i) = rest.find(q) {
326                let after = &rest[i + q.len_utf8()..];
327                let Some(j) = after.find(close) else { break };
328                if j > 0 {
329                    terms.push(after[..j].to_owned());
330                }
331                rest = &after[j + close.len_utf8()..];
332            }
333        }
334        // A number inside a quoted term is that term (`utf-8`, not `8`).
335        let whole = terms.clone();
336        terms.retain(|t| !whole.iter().any(|o| o != t && o.contains(t.as_str())));
337        for t in terms {
338            if !covered(&t) && !out.contains(&t) {
339                out.push(t);
340            }
341        }
342    }
343    out
344}
345
346/// A writing-check hit.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
348pub struct Finding {
349    /// `vague`, `escape_clause`, `open_ended`, `no_obligation`,
350    /// `when_without_then`, `no_failure_path`.
351    pub rule: String,
352    /// The words it is about (empty for the whole text).
353    pub words: String,
354    /// Why it makes the requirement hard to test, in a line.
355    pub reason: String,
356    /// What to write instead.
357    pub fix: String,
358}
359
360/// Vague words: each with the question that makes it testable.
361const VAGUE: &[(&str, &str)] = &[
362    ("fast", "how fast? a number and a unit (\"within 2 s\")"),
363    ("quick", "how quick? a number and a unit"),
364    ("quickly", "how quickly? a number and a unit"),
365    ("slow", "how slow is too slow? a number and a unit"),
366    ("large", "how large? a size or a count"),
367    ("big", "how big? a size or a count"),
368    ("small", "how small? a size or a count"),
369    ("many", "how many? a number"),
370    ("few", "how few? a number"),
371    ("several", "how many? a number"),
372    ("some", "which ones, or how many?"),
373    ("easy", "easy for whom, measured how? name the steps"),
374    ("simple", "simple how? name what it does and does not do"),
375    ("intuitive", "measured how? name the behaviour"),
376    ("user-friendly", "name the behaviour a person sees"),
377    ("friendly", "name the behaviour a person sees"),
378    (
379        "robust",
380        "robust against what? name the failure and the response",
381    ),
382    (
383        "secure",
384        "secure against what? name the threat and the behaviour",
385    ),
386    ("efficient", "how efficient? a number and a unit"),
387    ("flexible", "flexible how? name the variations"),
388    ("appropriate", "which, exactly? name it"),
389    ("reasonable", "what is reasonable? a number or a rule"),
390    ("normally", "and otherwise? name both cases"),
391    ("usually", "and otherwise? name both cases"),
392    ("probably", "decide it, or ask it as an open question"),
393    ("maybe", "decide it, or ask it as an open question"),
394    ("stuff", "name the things"),
395    ("things", "name the things"),
396    ("better", "better than what, by how much?"),
397];
398
399const ESCAPE: &[&str] = &[
400    "as appropriate",
401    "where possible",
402    "if possible",
403    "if practicable",
404    "as needed",
405    "as required",
406    "if necessary",
407];
408
409const OPEN_ENDED: &[&str] = &[
410    "etc",
411    "and so on",
412    "and so forth",
413    "including but not limited to",
414    "or similar",
415];
416
417/// Words that make a scenario a failure path.
418const FAILURE: &[&str] = &[
419    "fail",
420    "fails",
421    "failed",
422    "failure",
423    "error",
424    "errors",
425    "invalid",
426    "reject",
427    "rejects",
428    "rejected",
429    "refuse",
430    "refuses",
431    "refused",
432    "missing",
433    "empty",
434    "cannot",
435    "not",
436    "denied",
437    "unavailable",
438    "timeout",
439    "malformed",
440    "exceeds",
441    "without",
442];
443
444fn find_phrase(lower: &str, phrase: &str) -> bool {
445    lower.match_indices(phrase).any(|(i, _)| {
446        let before = lower[..i].chars().next_back();
447        let after = lower[i + phrase.len()..].chars().next();
448        before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
449    })
450}
451
452/// The writing check of `text`, a requirement (with its scenarios) when
453/// `requirement`: vague words, escape clauses, open-ended lists; for a
454/// requirement also a missing obligation word (`MUST`/`SHALL`), a *When*
455/// without a *Then*, and scenarios with no failure path. A fixed list,
456/// model-free.
457#[must_use]
458pub fn check(text: &str, requirement: bool) -> Vec<Finding> {
459    let lower = text.to_lowercase();
460    let mut out = Vec::new();
461    for (word, fix) in VAGUE {
462        if find_phrase(&lower, word) {
463            out.push(Finding {
464                rule: "vague".to_owned(),
465                words: (*word).to_owned(),
466                reason: "cannot be tested as written".to_owned(),
467                fix: (*fix).to_owned(),
468            });
469        }
470    }
471    for p in ESCAPE {
472        if find_phrase(&lower, p) {
473            out.push(Finding {
474                rule: "escape_clause".to_owned(),
475                words: (*p).to_owned(),
476                reason: "lets any behaviour pass".to_owned(),
477                fix: "say when it applies, or drop it".to_owned(),
478            });
479        }
480    }
481    for p in OPEN_ENDED {
482        if find_phrase(&lower, p) {
483            out.push(Finding {
484                rule: "open_ended".to_owned(),
485                words: (*p).to_owned(),
486                reason: "leaves the list open: the rest cannot be tested".to_owned(),
487                fix: "name every item".to_owned(),
488            });
489        }
490    }
491    if !requirement {
492        return out;
493    }
494    let obliges = ["must", "shall"].iter().any(|w| find_phrase(&lower, w));
495    if !obliges {
496        out.push(Finding {
497            rule: "no_obligation".to_owned(),
498            words: String::new(),
499            reason: "no MUST or SHALL: it reads as a wish, not a rule".to_owned(),
500            fix: "say who MUST do what (\"The export MUST …\")".to_owned(),
501        });
502    }
503    let scenario_lines: Vec<&str> = lower
504        .lines()
505        .filter(|l| find_phrase(l, "when") || find_phrase(l, "then") || l.contains("scenario"))
506        .collect();
507    let whens = lower.lines().filter(|l| find_phrase(l, "when")).count();
508    let thens = lower.lines().filter(|l| find_phrase(l, "then")).count();
509    if whens > thens {
510        out.push(Finding {
511            rule: "when_without_then".to_owned(),
512            words: "When".to_owned(),
513            reason: "a When without a Then: nothing to check".to_owned(),
514            fix: "add what must happen: \"Then …\"".to_owned(),
515        });
516    }
517    let failure = scenario_lines
518        .iter()
519        .any(|l| FAILURE.iter().any(|w| find_phrase(l, w)));
520    if thens > 0 && !failure {
521        out.push(Finding {
522            rule: "no_failure_path".to_owned(),
523            words: String::new(),
524            reason: "every scenario is a success path".to_owned(),
525            fix: "add one: what if it fails, or the input is wrong?".to_owned(),
526        });
527    }
528    out
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    fn text(spans: &[Span], origin: Origin) -> String {
536        spans
537            .iter()
538            .filter(|s| s.origin == origin)
539            .map(|s| s.text.trim())
540            .collect::<Vec<_>>()
541            .join("|")
542    }
543
544    #[test]
545    fn the_diff_marks_added_and_dropped_words_and_keeps_every_byte() {
546        let original = "users should be able to export stuff fast, csv probably";
547        let proposal = "Users MUST be able to export the table as CSV within 2 seconds.";
548        let d = diff(original, proposal);
549        assert_eq!(
550            d.proposal
551                .iter()
552                .map(|s| s.text.as_str())
553                .collect::<String>(),
554            proposal
555        );
556        assert_eq!(
557            d.original
558                .iter()
559                .map(|s| s.text.as_str())
560                .collect::<String>(),
561            original
562        );
563        assert_eq!(
564            text(&d.proposal, Origin::Added),
565            "MUST|the table as|within 2 seconds."
566        );
567        assert_eq!(
568            text(&d.original, Origin::Dropped),
569            "should|stuff fast,|probably"
570        );
571    }
572
573    #[test]
574    fn every_sentence_lands_somewhere_or_is_dropped() {
575        let original = "Export the table to CSV. Keep the header row!\nAlso it must never delete the source file.";
576        let proposal =
577            "The export MUST write the table as CSV.\nThe header row MUST be the first line.";
578        let c = coverage(original, proposal);
579        assert_eq!(c.len(), 3);
580        assert_eq!(c[0].lines, [0]);
581        assert_eq!(c[1].lines, [1]);
582        assert!(c[2].dropped, "{c:?}");
583        // Said differently but carried: a negation is not what it is about.
584        let c = coverage(
585            "dont break the header thing",
586            "The tool MUST preserve the header row.",
587        );
588        assert!(!c[0].dropped, "{c:?}");
589    }
590
591    #[test]
592    fn an_added_number_or_quoted_term_needs_an_assumption() {
593        let d = diff(
594            "export it fast",
595            "The export MUST finish within 2 seconds for up to 10 000 rows as \"utf-8\".",
596        );
597        let declared = [Assumption {
598            words: "up to 10 000 rows".to_owned(),
599            from: String::new(),
600            question: "How many rows?".to_owned(),
601        }];
602        assert_eq!(unexplained(&d, &declared), ["2 seconds", "utf-8"]);
603        assert!(unexplained(&diff("finish in 2 seconds", "finish in 2 seconds"), &[]).is_empty());
604        // As written, so it can be found and replaced; covered across
605        // punctuation.
606        let d = diff(
607            "export",
608            "export up to 10,000 rows in 1.5 s with a 2-second timeout",
609        );
610        assert_eq!(unexplained(&d, &[]), ["10,000 rows", "1.5 s", "2-second"]);
611        let declared = [Assumption {
612            words: "up to 10 000 rows".to_owned(),
613            from: String::new(),
614            question: String::new(),
615        }];
616        assert_eq!(unexplained(&d, &declared), ["1.5 s", "2-second"]);
617    }
618
619    #[test]
620    fn the_check_names_vague_words_and_what_a_requirement_lacks() {
621        let f = check(
622            "The export should be fast and handle large files etc.",
623            true,
624        );
625        let rules: Vec<(&str, &str)> = f
626            .iter()
627            .map(|f| (f.rule.as_str(), f.words.as_str()))
628            .collect();
629        assert_eq!(
630            rules,
631            [
632                ("vague", "fast"),
633                ("vague", "large"),
634                ("open_ended", "etc"),
635                ("no_obligation", "")
636            ]
637        );
638        // Not inside other words: "breakfast" is not "fast".
639        assert!(check("A breakfast menu MUST be shown.", true).is_empty());
640        let happy = "The export MUST write CSV.\nWhen the person exports\nThen a file is written";
641        assert_eq!(
642            check(happy, true)
643                .iter()
644                .map(|f| f.rule.as_str())
645                .collect::<Vec<_>>(),
646            ["no_failure_path"]
647        );
648        let both = "The export MUST write CSV.\nWhen the person exports\nThen a file is written\nWhen the table is empty\nThen the export fails with a message";
649        assert!(check(both, true).is_empty());
650        assert_eq!(
651            check("When it runs", true)
652                .iter()
653                .filter(|f| f.rule == "when_without_then")
654                .count(),
655            1
656        );
657        // A note is not a requirement: only the words are checked.
658        assert!(check("try the faster path first", false).is_empty());
659    }
660}