Skip to main content

logicaffeine_language/
lexicon.rs

1//! Lexicon: Vocabulary lookup functions
2//!
3//! This module includes the compile-time generated lexicon lookup code
4//! from build.rs. It provides ~56 lookup functions for classifying words.
5
6// Include the generated lexicon lookup functions
7include!(concat!(env!("OUT_DIR"), "/lexicon_data.rs"));
8
9// Re-export types from lexicon crate that aren't defined in generated code
10// Note: Polarity, CanonicalMapping are defined in lexicon_data.rs
11pub use logicaffeine_lexicon::{
12    Aspect, Case, Definiteness, Feature, Gender, Number, Sort, Time, VerbClass,
13    AdjectiveMetadata, MorphologicalRule, NounMetadata, VerbEntry, VerbMetadata,
14};
15
16/// Get canonical verb form and whether it's lexically negative.
17/// Used at parse time to transform "lacks" → ("Have", true).
18/// Returns (canonical_lemma, is_negative).
19pub fn get_canonical_verb(lemma: &str) -> Option<(&'static str, bool)> {
20    lookup_canonical(lemma).map(|m| (m.lemma, m.polarity == Polarity::Negative))
21}
22
23/// Lexicon trait for abstracting over static and dynamic lexicons
24pub trait LexiconTrait {
25    fn lookup_verb(&self, word: &str) -> Option<VerbMetadata>;
26    fn lookup_noun(&self, word: &str) -> Option<NounMetadata>;
27    fn lookup_adjective(&self, word: &str) -> Option<AdjectiveMetadata>;
28}
29
30/// Static lexicon implementation using compile-time generated data
31pub struct StaticLexicon;
32
33impl LexiconTrait for StaticLexicon {
34    fn lookup_verb(&self, word: &str) -> Option<VerbMetadata> {
35        lookup_verb_db(word)
36    }
37
38    fn lookup_noun(&self, word: &str) -> Option<NounMetadata> {
39        lookup_noun_db(word)
40    }
41
42    fn lookup_adjective(&self, word: &str) -> Option<AdjectiveMetadata> {
43        lookup_adjective_db(word)
44    }
45}
46
47/// Lexicon struct for verb lookup with inflection handling
48pub struct Lexicon {}
49
50impl Lexicon {
51    pub fn new() -> Self {
52        Lexicon {}
53    }
54
55    pub fn lookup_verb(&self, word: &str) -> Option<VerbEntry> {
56        let lower = word.to_lowercase();
57
58        if let Some(entry) = lookup_irregular_verb(&lower) {
59            return Some(entry);
60        }
61
62        if lower.ends_with("ing") {
63            let stem = self.strip_ing(&lower);
64            let lemma = Self::capitalize(&stem);
65            let class = self.lookup_verb_class(&lemma.to_lowercase());
66            return Some(VerbEntry {
67                lemma,
68                time: Time::None,
69                aspect: Aspect::Progressive,
70                class,
71            });
72        }
73
74        if lower.ends_with("ed") {
75            let stem = self.strip_ed(&lower);
76            // Only treat as verb if the stem is a known base verb
77            // This prevents "doomed" → "Doom" when "doom" isn't in lexicon
78            if !is_base_verb(&stem) {
79                return None;
80            }
81            let lemma = Self::capitalize(&stem);
82            let class = self.lookup_verb_class(&lemma.to_lowercase());
83            return Some(VerbEntry {
84                lemma,
85                time: Time::Past,
86                aspect: Aspect::Simple,
87                class,
88            });
89        }
90
91        let is_third_person = if lower.ends_with("es") && lower.len() > 2 {
92            true
93        } else if lower.ends_with("s") && !lower.ends_with("ss") && lower.len() > 2 {
94            true
95        } else {
96            false
97        };
98
99        if is_third_person {
100            if is_stemming_exception(&lower) {
101                return None;
102            }
103
104            let stem = Self::third_person_stem(&lower)?;
105            let lemma = Self::capitalize(&stem);
106            let class = self.lookup_verb_class(&lemma.to_lowercase());
107            return Some(VerbEntry {
108                lemma,
109                time: Time::Present,
110                aspect: Aspect::Simple,
111                class,
112            });
113        }
114
115        // Check if this is a base verb form
116        if is_base_verb(&lower) {
117            let lemma = Self::capitalize(&lower);
118            let class = self.lookup_verb_class(&lower);
119            return Some(VerbEntry {
120                lemma,
121                time: Time::Present,
122                aspect: Aspect::Simple,
123                class,
124            });
125        }
126
127        None
128    }
129
130    fn lookup_verb_class(&self, lemma: &str) -> VerbClass {
131        lookup_verb_class(lemma)
132    }
133
134    fn strip_ing(&self, word: &str) -> String {
135        let base = &word[..word.len() - 3];
136
137        if base.len() >= 2 {
138            let chars: Vec<char> = base.chars().collect();
139            let last = chars[chars.len() - 1];
140            let second_last = chars[chars.len() - 2];
141
142            if last == second_last && !"aeiou".contains(last) {
143                return base[..base.len() - 1].to_string();
144            }
145        }
146
147        if needs_e_ing(base) {
148            return format!("{}e", base);
149        }
150
151        base.to_string()
152    }
153
154    fn strip_ed(&self, word: &str) -> String {
155        let base = &word[..word.len() - 2];
156
157        if base.ends_with("i") {
158            // Silent-e verbs ending in -ie add only "d": "lied" → "lie",
159            // "died" → "die", "tied" → "tie". Only then the y-restoring
160            // rule: "studied" → "study".
161            let with_e = format!("{}e", base);
162            if is_base_verb(&with_e) {
163                return with_e;
164            }
165            return format!("{}y", &base[..base.len() - 1]);
166        }
167
168        if base.len() >= 2 {
169            let chars: Vec<char> = base.chars().collect();
170            let last = chars[chars.len() - 1];
171            let second_last = chars[chars.len() - 2];
172
173            // Doubled consonant handling for verbs like "stopped" → "stop"
174            // BUT: first check if the base WITH doubled consonant is already a verb
175            // This handles words like "passed" → "pass" (natural double 's')
176            if last == second_last && !"aeiou".contains(last) {
177                // First try the base as-is (handles "pass", "miss", "kiss", etc.)
178                if is_base_verb(base) {
179                    return base.to_string();
180                }
181                // Otherwise strip the doubled consonant (handles "stopped" → "stop")
182                return base[..base.len() - 1].to_string();
183            }
184
185            // Consonant clusters that typically come from silent-e verbs:
186            // "tabled" → "tabl" needs "e", "googled" → "googl" needs "e"
187            // Pattern: consonant + l/r at end, with vowel before the consonant
188            if (last == 'l' || last == 'r') && !"aeiou".contains(second_last) {
189                if chars.len() >= 3 && "aeiou".contains(chars[chars.len() - 3]) {
190                    return format!("{}e", base);
191                }
192            }
193        }
194
195        if needs_e_ed(base) {
196            return format!("{}e", base);
197        }
198
199        // Fallback: try adding 'e' and check if that's a valid verb
200        // This handles all silent-e verbs not explicitly in needs_e_ed
201        // e.g., "escaped" → "escap" → "escape" (valid verb)
202        let with_e = format!("{}e", base);
203        if is_base_verb(&with_e) {
204            return with_e;
205        }
206
207        base.to_string()
208    }
209
210    /// The generative third-person-singular rule: sibilant and -o stems take
211    /// "es" ("push" → "pushes", "go" → "goes"), consonant+y becomes "ies"
212    /// ("study" → "studies"), everything else takes "s" ("plan" → "plans").
213    fn third_person_of(stem: &str) -> String {
214        if stem.ends_with('s')
215            || stem.ends_with('x')
216            || stem.ends_with('z')
217            || stem.ends_with("ch")
218            || stem.ends_with("sh")
219            || stem.ends_with('o')
220        {
221            format!("{stem}es")
222        } else if stem.ends_with('y')
223            && !stem.ends_with("ay")
224            && !stem.ends_with("ey")
225            && !stem.ends_with("oy")
226            && !stem.ends_with("uy")
227        {
228            format!("{}ies", &stem[..stem.len() - 1])
229        } else {
230            format!("{stem}s")
231        }
232    }
233
234    /// Inverse of [`Self::third_person_of`]: the stem is whichever known base
235    /// verb regenerates the surface form under the forward rule. Deriving the
236    /// inverse from the generative rule means the two can never disagree —
237    /// "pushes" → "push" and "dies" → "die" resolve, while "planes" stays a
238    /// plural noun because "plan" forms "plans", never "planes".
239    fn third_person_stem(word: &str) -> Option<String> {
240        let mut candidates = vec![word[..word.len() - 1].to_string()];
241        if word.ends_with("es") {
242            candidates.push(word[..word.len() - 2].to_string());
243        }
244        if word.ends_with("ies") {
245            candidates.push(format!("{}y", &word[..word.len() - 3]));
246        }
247        candidates
248            .into_iter()
249            .find(|stem| is_base_verb(stem) && Self::third_person_of(stem) == word)
250    }
251
252    fn capitalize(s: &str) -> String {
253        let mut chars = s.chars();
254        match chars.next() {
255            None => String::new(),
256            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
257        }
258    }
259}
260
261impl Default for Lexicon {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267/// Result of smart word analysis for derivational morphology
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum WordAnalysis {
270    /// A dictionary entry (exact match or derived plural)
271    Noun(NounMetadata),
272    /// A word derived via morphological rules (agentive nouns like "blogger")
273    DerivedNoun {
274        lemma: String,
275        number: Number,
276    },
277}
278
279/// Smart word analysis with derivational morphology support.
280///
281/// Three-step resolution:
282/// 1. **Exact Match** - Check if word exists in lexicon (handles irregulars like "mice")
283/// 2. **Plural Derivation** - Strip 's'/'es' and check if stem exists (farmers → farmer)
284/// 3. **Morphological Rules** - Apply suffix rules for unknown agentive nouns
285pub fn analyze_word(word: &str) -> Option<WordAnalysis> {
286    let lower = word.to_lowercase();
287
288    // 1. EXACT MATCH (Fast Path)
289    // Handles explicit entries like "farmer", "mice", "children"
290    if let Some(meta) = lookup_noun_db(&lower) {
291        return Some(WordAnalysis::Noun(meta));
292    }
293
294    // 2. PLURAL DERIVATION (Smart Path)
295    // "farmers" → stem "farmer" → lookup
296    if lower.ends_with('s') && lower.len() > 2 {
297        // Try simple 's' stripping: "farmers" -> "farmer"
298        let stem = &lower[..lower.len() - 1];
299        if let Some(meta) = lookup_noun_db(stem) {
300            // Found the singular base - return as plural
301            return Some(WordAnalysis::Noun(NounMetadata {
302                lemma: meta.lemma,
303                number: Number::Plural,
304                features: meta.features,
305            }));
306        }
307
308        // Try 'es' stripping: "boxes" -> "box", "churches" -> "church"
309        if lower.ends_with("es") && lower.len() > 3 {
310            let stem_es = &lower[..lower.len() - 2];
311            if let Some(meta) = lookup_noun_db(stem_es) {
312                return Some(WordAnalysis::Noun(NounMetadata {
313                    lemma: meta.lemma,
314                    number: Number::Plural,
315                    features: meta.features,
316                }));
317            }
318        }
319
320        // Try 'ies' -> 'y': "cities" -> "city"
321        if lower.ends_with("ies") && lower.len() > 4 {
322            let stem_ies = format!("{}y", &lower[..lower.len() - 3]);
323            if let Some(meta) = lookup_noun_db(&stem_ies) {
324                return Some(WordAnalysis::Noun(NounMetadata {
325                    lemma: meta.lemma,
326                    number: Number::Plural,
327                    features: meta.features,
328                }));
329            }
330        }
331    }
332
333    // 3. MORPHOLOGICAL RULES (Data-driven from lexicon.json)
334    // Handle agentive nouns like "blogger", "vlogger" even if not in lexicon
335    for rule in get_morphological_rules() {
336        // Check plural form first (e.g., "vloggers" -> "vlogger" -> rule match)
337        let (is_plural, check_word) = if lower.ends_with('s') && !rule.suffix.ends_with('s') {
338            (true, &lower[..lower.len() - 1])
339        } else {
340            (false, lower.as_str())
341        };
342
343        if check_word.ends_with(rule.suffix) {
344            return Some(WordAnalysis::DerivedNoun {
345                lemma: check_word.to_string(),
346                number: if is_plural { Number::Plural } else { Number::Singular },
347            });
348        }
349    }
350
351    None
352}
353
354/// Check if a word is a known common noun or derivable from one.
355/// This is used for sentence-initial capitalization disambiguation.
356pub fn is_derivable_noun(word: &str) -> bool {
357    analyze_word(word).is_some()
358}
359
360/// Check if a word is a proper name (has Feature::Proper in the lexicon).
361/// Used to distinguish "Socrates fears death" from "Birds fly" (bare plurals).
362/// Names like "Socrates", "James", "Chris" end in 's' but aren't plural nouns.
363pub fn is_proper_name(word: &str) -> bool {
364    let lower = word.to_lowercase();
365    if let Some(meta) = lookup_noun_db(&lower) {
366        return meta.features.contains(&Feature::Proper);
367    }
368    false
369}
370
371/// Get the canonical lemma for a noun.
372///
373/// Maps inflected forms to their dictionary headword:
374/// - "men" → "Man"
375/// - "children" → "Child"
376/// - "farmers" → "Farmer"
377///
378/// This is used for predicate canonicalization in the proof engine,
379/// ensuring "All men are mortal" and "Socrates is a man" produce
380/// matching predicates.
381pub fn get_canonical_noun(word: &str) -> Option<&'static str> {
382    match analyze_word(word) {
383        Some(WordAnalysis::Noun(meta)) => Some(meta.lemma),
384        Some(WordAnalysis::DerivedNoun { .. }) => {
385            // Derived nouns (e.g., "blogger") return owned Strings,
386            // so we can't return a static reference.
387            // Fall back to raw word handling in the caller.
388            None
389        }
390        _ => None,
391    }
392}