Skip to main content

prosaic_core/
antonyms.rs

1//! Negation naturalization via an antonym registry.
2//!
3//! Register pairs of (action phrase, positive-framing antonym phrase).
4//! The `{phrase|negated}` template pipe looks up the input phrase in
5//! the registry; if a registered positive form exists it emits that,
6//! otherwise it falls back to inserting "not" at the right spot
7//! ("was modified" → "was not modified") so the output stays grammatical
8//! regardless of whether the caller thought to register an antonym.
9
10#[cfg(not(feature = "std"))]
11use alloc::format;
12#[cfg(not(feature = "std"))]
13use alloc::string::{String, ToString};
14
15use crate::collections::HashMap;
16
17/// Registry of phrase-level antonym substitutions used for positive
18/// framings of negative statements.
19#[derive(Debug, Clone, Default)]
20pub struct AntonymRegistry {
21    map: HashMap<String, String>,
22}
23
24impl AntonymRegistry {
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Register that the negative phrase `negative` should be rendered
30    /// as the positive phrase `positive` when used via `{phrase|negated}`.
31    /// Matching is case-insensitive; the registered positive form keeps
32    /// its original casing.
33    pub fn register(&mut self, negative: &str, positive: &str) {
34        self.map
35            .insert(negative.to_lowercase(), positive.to_string());
36    }
37
38    /// Look up a positive antonym for the given phrase. Returns `None`
39    /// when no antonym has been registered.
40    pub fn lookup(&self, negative: &str) -> Option<&str> {
41        self.map.get(&negative.to_lowercase()).map(|s| s.as_str())
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.map.is_empty()
46    }
47
48    pub fn len(&self) -> usize {
49        self.map.len()
50    }
51}
52
53/// Simple single-word auxiliaries. English negation inserts "not"
54/// after the *first* aux in a verb phrase regardless of what follows,
55/// so for "has been renamed" the "not" slots after "has" ("has not
56/// been renamed"), not after "has been". A first-word match is all we
57/// need.
58const SIMPLE_AUX: &[&str] = &[
59    "is", "are", "was", "were", "has", "have", "had", "will", "would", "could", "should", "may",
60    "might", "must", "can",
61];
62
63/// Fallback negation: split the phrase after its first auxiliary and
64/// insert "not". If the first word isn't a recognizable aux, prepend
65/// "not " — ungrammatical on its own but better than silently losing
66/// the negation (callers should register an antonym for this case).
67///
68/// Examples:
69/// - `"was modified"` → `"was not modified"`
70/// - `"has been renamed"` → `"has not been renamed"`
71/// - `"will break"` → `"will not break"`
72/// - `"broke"` → `"not broke"`
73pub fn insert_not(phrase: &str) -> String {
74    let mut parts = phrase.splitn(2, ' ');
75    let first = match parts.next() {
76        Some(w) => w,
77        None => return phrase.to_string(),
78    };
79    let rest = parts.next().unwrap_or("");
80
81    if SIMPLE_AUX.contains(&first.to_lowercase().as_str()) {
82        if rest.is_empty() {
83            return format!("{first} not");
84        }
85        return format!("{first} not {rest}");
86    }
87
88    format!("not {phrase}")
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn insert_not_after_was() {
97        assert_eq!(insert_not("was modified"), "was not modified");
98    }
99
100    #[test]
101    fn insert_not_after_has_been() {
102        assert_eq!(insert_not("has been renamed"), "has not been renamed");
103    }
104
105    #[test]
106    fn insert_not_after_modal() {
107        assert_eq!(insert_not("will break"), "will not break");
108        assert_eq!(insert_not("must fail"), "must not fail");
109    }
110
111    #[test]
112    fn insert_not_without_aux_falls_back_to_prefix() {
113        // Callers who hit this fallback should register an antonym.
114        assert_eq!(insert_not("broke"), "not broke");
115    }
116
117    #[test]
118    fn registry_lookup_case_insensitive() {
119        let mut r = AntonymRegistry::new();
120        r.register("was modified", "remained unchanged");
121        assert_eq!(r.lookup("Was Modified"), Some("remained unchanged"));
122    }
123
124    #[test]
125    fn registry_unknown_lookup_is_none() {
126        let r = AntonymRegistry::new();
127        assert_eq!(r.lookup("was modified"), None);
128    }
129}