Skip to main content

logicaffeine_lexicon/
runtime.rs

1//! Runtime lexicon loading for development builds.
2//!
3//! This module provides dynamic JSON-based lexicon loading as an alternative
4//! to compile-time code generation. Enable with the `dynamic-lexicon` feature.
5//!
6//! # Architecture
7//!
8//! The runtime lexicon trades compile-time safety for faster iteration during
9//! development. Instead of generating Rust code from `lexicon.json` at build time,
10//! this module embeds the JSON and parses it once at runtime when `LexiconIndex::new()`
11//! is called.
12//!
13//! # When to Use
14//!
15//! - **Development**: Use `dynamic-lexicon` for faster edit-compile cycles when
16//!   modifying the lexicon.
17//! - **Production**: Disable this feature for compile-time validation and
18//!   slightly faster startup.
19//!
20//! # JSON Format
21//!
22//! The lexicon file must contain three top-level arrays:
23//!
24//! - `nouns`: Array of `NounEntry` objects with `lemma`, optional `forms`, `features`, and `sort`
25//! - `verbs`: Array of `VerbEntry` objects with `lemma`, `class`, optional `forms`, and `features`
26//! - `adjectives`: Array of `AdjectiveEntry` objects with `lemma`, `regular`, and `features`
27//!
28//! # Example
29//!
30//! ```
31//! use logicaffeine_lexicon::runtime::LexiconIndex;
32//!
33//! let lexicon = LexiconIndex::new();
34//! let proper_nouns = lexicon.proper_nouns();
35//! assert!(!proper_nouns.is_empty());
36//! ```
37//!
38//! # Type Disambiguation
39//!
40//! This module defines its own `VerbEntry`, `NounEntry`, and `AdjectiveEntry` types
41//! for JSON deserialization. These are distinct from `crate::VerbEntry` and other types
42//! in the parent `crate::types` module, which are used for compile-time generated lookups.
43
44use rand::seq::SliceRandom;
45use serde::Deserialize;
46use std::collections::HashMap;
47
48const LEXICON_JSON: &str = include_str!("../../logicaffeine_language/assets/lexicon.json");
49
50/// Deserialized lexicon data from lexicon.json.
51#[derive(Deserialize, Debug)]
52pub struct LexiconData {
53    /// All noun entries including proper nouns and common nouns.
54    pub nouns: Vec<NounEntry>,
55    /// All verb entries with Vendler class and features.
56    pub verbs: Vec<VerbEntry>,
57    /// All adjective entries with gradability info.
58    pub adjectives: Vec<AdjectiveEntry>,
59}
60
61/// A noun entry from the lexicon database.
62#[derive(Deserialize, Debug, Clone)]
63pub struct NounEntry {
64    /// Base form of the noun (e.g., "dog", "Mary").
65    pub lemma: String,
66    /// Irregular inflected forms: "plural" → "mice", etc.
67    #[serde(default)]
68    pub forms: HashMap<String, String>,
69    /// Grammatical/semantic features: "Animate", "Proper", "Countable".
70    #[serde(default)]
71    pub features: Vec<String>,
72    /// Semantic sort for type checking: "Human", "Physical", "Abstract".
73    #[serde(default)]
74    pub sort: Option<String>,
75}
76
77/// A verb entry from the lexicon database.
78#[derive(Deserialize, Debug, Clone)]
79pub struct VerbEntry {
80    /// Base/infinitive form of the verb (e.g., "run", "give").
81    pub lemma: String,
82    /// Vendler Aktionsart class: "State", "Activity", "Accomplishment", "Achievement".
83    pub class: String,
84    /// Irregular inflected forms: "past" → "ran", "participle" → "run".
85    #[serde(default)]
86    pub forms: HashMap<String, String>,
87    /// Grammatical/semantic features: "Transitive", "Ditransitive", "Control".
88    #[serde(default)]
89    pub features: Vec<String>,
90}
91
92/// An adjective entry from the lexicon database.
93#[derive(Deserialize, Debug, Clone)]
94pub struct AdjectiveEntry {
95    /// Base/positive form of the adjective (e.g., "tall", "happy").
96    pub lemma: String,
97    /// Whether comparative/superlative follow regular -er/-est pattern.
98    #[serde(default)]
99    pub regular: bool,
100    /// Semantic features: "Gradable", "Subsective", "NonIntersective".
101    #[serde(default)]
102    pub features: Vec<String>,
103}
104
105/// Index for querying the lexicon by features, sorts, and classes.
106pub struct LexiconIndex {
107    data: LexiconData,
108}
109
110impl LexiconIndex {
111    /// Load and parse the lexicon from the embedded JSON file.
112    pub fn new() -> Self {
113        Self::from_json(LEXICON_JSON).expect("Failed to parse lexicon.json")
114    }
115
116    /// Parse a lexicon from a caller-supplied JSON document (same format as
117    /// `lexicon.json`). This is how targets that fetch the lexicon at runtime —
118    /// the web app pulls it from `/data/lexicon.json` — build an index without
119    /// carrying the embedded copy in their binary.
120    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
121        let data: LexiconData = serde_json::from_str(json)?;
122        Ok(Self { data })
123    }
124
125    /// Get all nouns marked with the "Proper" feature (names).
126    pub fn proper_nouns(&self) -> Vec<&NounEntry> {
127        self.data.nouns.iter()
128            .filter(|n| n.features.iter().any(|f| f == "Proper"))
129            .collect()
130    }
131
132    /// Get all nouns NOT marked as proper (common nouns).
133    pub fn common_nouns(&self) -> Vec<&NounEntry> {
134        self.data.nouns.iter()
135            .filter(|n| !n.features.iter().any(|f| f == "Proper"))
136            .collect()
137    }
138
139    /// Get all nouns with a specific feature (case-insensitive).
140    pub fn nouns_with_feature(&self, feature: &str) -> Vec<&NounEntry> {
141        self.data.nouns.iter()
142            .filter(|n| n.features.iter().any(|f| f.eq_ignore_ascii_case(feature)))
143            .collect()
144    }
145
146    /// Get all nouns with a specific semantic sort (case-insensitive).
147    pub fn nouns_with_sort(&self, sort: &str) -> Vec<&NounEntry> {
148        self.data.nouns.iter()
149            .filter(|n| n.sort.as_ref().map(|s| s.eq_ignore_ascii_case(sort)).unwrap_or(false))
150            .collect()
151    }
152
153    /// Get all verbs with a specific feature (case-insensitive).
154    pub fn verbs_with_feature(&self, feature: &str) -> Vec<&VerbEntry> {
155        self.data.verbs.iter()
156            .filter(|v| v.features.iter().any(|f| f.eq_ignore_ascii_case(feature)))
157            .collect()
158    }
159
160    /// Get all verbs with a specific Vendler class (case-insensitive).
161    pub fn verbs_with_class(&self, class: &str) -> Vec<&VerbEntry> {
162        self.data.verbs.iter()
163            .filter(|v| v.class.eq_ignore_ascii_case(class))
164            .collect()
165    }
166
167    /// Get all verbs that are intransitive (no Transitive/Ditransitive feature).
168    pub fn intransitive_verbs(&self) -> Vec<&VerbEntry> {
169        self.data.verbs.iter()
170            .filter(|v| {
171                !v.features.iter().any(|f|
172                    f.eq_ignore_ascii_case("Transitive") ||
173                    f.eq_ignore_ascii_case("Ditransitive")
174                )
175            })
176            .collect()
177    }
178
179    /// Returns all verbs that take a direct object.
180    ///
181    /// Includes both transitive verbs (two-place predicates) and ditransitive verbs
182    /// (three-place predicates). Verbs are matched if they have either the `"Transitive"`
183    /// or `"Ditransitive"` feature (case-insensitive).
184    pub fn transitive_verbs(&self) -> Vec<&VerbEntry> {
185        self.data.verbs.iter()
186            .filter(|v| {
187                v.features.iter().any(|f| f.eq_ignore_ascii_case("Transitive")) ||
188                v.features.iter().any(|f| f.eq_ignore_ascii_case("Ditransitive"))
189            })
190            .collect()
191    }
192
193    /// Returns all adjectives with a specific feature (case-insensitive).
194    ///
195    /// Common features include `"Intersective"`, `"Subsective"`, `"NonIntersective"`,
196    /// and `"Gradable"`. See [`crate::Feature`] for the full list.
197    pub fn adjectives_with_feature(&self, feature: &str) -> Vec<&AdjectiveEntry> {
198        self.data.adjectives.iter()
199            .filter(|a| a.features.iter().any(|f| f.eq_ignore_ascii_case(feature)))
200            .collect()
201    }
202
203    /// Returns all adjectives with intersective semantics.
204    ///
205    /// Intersective adjectives combine with nouns via set intersection:
206    /// "red ball" denotes things that are both red and balls. This is a convenience
207    /// method equivalent to `adjectives_with_feature("Intersective")`.
208    pub fn intersective_adjectives(&self) -> Vec<&AdjectiveEntry> {
209        self.adjectives_with_feature("Intersective")
210    }
211
212    /// Selects a random proper noun from the lexicon.
213    ///
214    /// Returns `None` if the lexicon contains no proper nouns.
215    pub fn random_proper_noun(&self, rng: &mut impl rand::Rng) -> Option<&NounEntry> {
216        self.proper_nouns().choose(rng).copied()
217    }
218
219    /// Selects a random common noun from the lexicon.
220    ///
221    /// Returns `None` if the lexicon contains no common nouns.
222    pub fn random_common_noun(&self, rng: &mut impl rand::Rng) -> Option<&NounEntry> {
223        self.common_nouns().choose(rng).copied()
224    }
225
226    /// Selects a random verb from the lexicon.
227    ///
228    /// Returns `None` if the lexicon contains no verbs.
229    pub fn random_verb(&self, rng: &mut impl rand::Rng) -> Option<&VerbEntry> {
230        self.data.verbs.choose(rng)
231    }
232
233    /// Selects a random intransitive verb from the lexicon.
234    ///
235    /// Returns `None` if the lexicon contains no intransitive verbs.
236    pub fn random_intransitive_verb(&self, rng: &mut impl rand::Rng) -> Option<&VerbEntry> {
237        self.intransitive_verbs().choose(rng).copied()
238    }
239
240    /// Selects a random transitive or ditransitive verb from the lexicon.
241    ///
242    /// Returns `None` if the lexicon contains no transitive verbs.
243    pub fn random_transitive_verb(&self, rng: &mut impl rand::Rng) -> Option<&VerbEntry> {
244        self.transitive_verbs().choose(rng).copied()
245    }
246
247    /// Selects a random adjective from the lexicon.
248    ///
249    /// Returns `None` if the lexicon contains no adjectives.
250    pub fn random_adjective(&self, rng: &mut impl rand::Rng) -> Option<&AdjectiveEntry> {
251        self.data.adjectives.choose(rng)
252    }
253
254    /// Selects a random intersective adjective from the lexicon.
255    ///
256    /// Returns `None` if the lexicon contains no intersective adjectives.
257    pub fn random_intersective_adjective(&self, rng: &mut impl rand::Rng) -> Option<&AdjectiveEntry> {
258        self.intersective_adjectives().choose(rng).copied()
259    }
260}
261
262/// Creates a [`LexiconIndex`] by loading and parsing the embedded lexicon JSON.
263///
264/// Equivalent to calling [`LexiconIndex::new()`].
265impl Default for LexiconIndex {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271/// Computes the plural form of a noun.
272///
273/// Returns the irregular plural if one is defined in the noun's `forms` map under
274/// the `"plural"` key. Otherwise, applies English pluralization rules:
275///
276/// - Sibilants (`-s`, `-x`, `-ch`, `-sh`) → append `-es` ("box" → "boxes")
277/// - Consonant + `y` → replace `y` with `-ies` ("city" → "cities")
278/// - Vowel + `y` (`-ay`, `-ey`, `-oy`, `-uy`) → append `-s` ("day" → "days")
279/// - Default → append `-s` ("dog" → "dogs")
280///
281/// # Arguments
282///
283/// * `noun` - The noun entry containing the lemma and optional irregular forms.
284///
285/// # Examples
286///
287/// ```
288/// use logicaffeine_lexicon::runtime::{NounEntry, pluralize};
289/// use std::collections::HashMap;
290///
291/// // Regular noun
292/// let dog = NounEntry {
293///     lemma: "dog".to_string(),
294///     forms: HashMap::new(),
295///     features: vec![],
296///     sort: None,
297/// };
298/// assert_eq!(pluralize(&dog), "dogs");
299///
300/// // Irregular noun
301/// let mouse = NounEntry {
302///     lemma: "mouse".to_string(),
303///     forms: [("plural".to_string(), "mice".to_string())].into(),
304///     features: vec![],
305///     sort: None,
306/// };
307/// assert_eq!(pluralize(&mouse), "mice");
308/// ```
309pub fn pluralize(noun: &NounEntry) -> String {
310    if let Some(plural) = noun.forms.get("plural") {
311        plural.clone()
312    } else {
313        let lemma = noun.lemma.to_lowercase();
314        if lemma.ends_with('s') || lemma.ends_with('x') ||
315           lemma.ends_with("ch") || lemma.ends_with("sh") {
316            format!("{}es", lemma)
317        } else if lemma.ends_with('y') && !lemma.ends_with("ay") &&
318                  !lemma.ends_with("ey") && !lemma.ends_with("oy") && !lemma.ends_with("uy") {
319            format!("{}ies", &lemma[..lemma.len()-1])
320        } else {
321            format!("{}s", lemma)
322        }
323    }
324}
325
326/// Computes the third-person singular present tense form of a verb.
327///
328/// Returns the irregular form if one is defined in the verb's `forms` map under
329/// the `"present3s"` key. Otherwise, applies English conjugation rules:
330///
331/// - Sibilants and `-o` (`-s`, `-x`, `-ch`, `-sh`, `-o`) → append `-es` ("go" → "goes")
332/// - Consonant + `y` → replace `y` with `-ies` ("fly" → "flies")
333/// - Vowel + `y` (`-ay`, `-ey`, `-oy`, `-uy`) → append `-s` ("play" → "plays")
334/// - Default → append `-s` ("run" → "runs")
335///
336/// # Arguments
337///
338/// * `verb` - The verb entry containing the lemma and optional irregular forms.
339///
340/// # Examples
341///
342/// ```
343/// use logicaffeine_lexicon::runtime::{VerbEntry, present_3s};
344/// use std::collections::HashMap;
345///
346/// let run = VerbEntry {
347///     lemma: "run".to_string(),
348///     class: "Activity".to_string(),
349///     forms: HashMap::new(),
350///     features: vec![],
351/// };
352/// assert_eq!(present_3s(&run), "runs");
353///
354/// let go = VerbEntry {
355///     lemma: "go".to_string(),
356///     class: "Activity".to_string(),
357///     forms: [("present3s".to_string(), "goes".to_string())].into(),
358///     features: vec![],
359/// };
360/// assert_eq!(present_3s(&go), "goes");
361/// ```
362pub fn present_3s(verb: &VerbEntry) -> String {
363    if let Some(form) = verb.forms.get("present3s") {
364        form.clone()
365    } else {
366        let lemma = verb.lemma.to_lowercase();
367        if lemma.ends_with('s') || lemma.ends_with('x') ||
368           lemma.ends_with("ch") || lemma.ends_with("sh") || lemma.ends_with('o') {
369            format!("{}es", lemma)
370        } else if lemma.ends_with('y') && !lemma.ends_with("ay") &&
371                  !lemma.ends_with("ey") && !lemma.ends_with("oy") && !lemma.ends_with("uy") {
372            format!("{}ies", &lemma[..lemma.len()-1])
373        } else {
374            format!("{}s", lemma)
375        }
376    }
377}
378
379/// Computes the past tense form of a verb.
380///
381/// Returns the irregular form if one is defined in the verb's `forms` map under
382/// the `"past"` key. Otherwise, applies English past tense rules:
383///
384/// - Ends in `-e` → append `-d` ("love" → "loved")
385/// - Consonant + `y` → replace `y` with `-ied` ("carry" → "carried")
386/// - Vowel + `y` (`-ay`, `-ey`, `-oy`, `-uy`) → append `-ed` ("play" → "played")
387/// - Default → append `-ed` ("walk" → "walked")
388///
389/// # Arguments
390///
391/// * `verb` - The verb entry containing the lemma and optional irregular forms.
392///
393/// # Examples
394///
395/// ```
396/// use logicaffeine_lexicon::runtime::{VerbEntry, past_tense};
397/// use std::collections::HashMap;
398///
399/// let walk = VerbEntry {
400///     lemma: "walk".to_string(),
401///     class: "Activity".to_string(),
402///     forms: HashMap::new(),
403///     features: vec![],
404/// };
405/// assert_eq!(past_tense(&walk), "walked");
406///
407/// let run = VerbEntry {
408///     lemma: "run".to_string(),
409///     class: "Activity".to_string(),
410///     forms: [("past".to_string(), "ran".to_string())].into(),
411///     features: vec![],
412/// };
413/// assert_eq!(past_tense(&run), "ran");
414/// ```
415pub fn past_tense(verb: &VerbEntry) -> String {
416    if let Some(form) = verb.forms.get("past") {
417        form.clone()
418    } else {
419        let lemma = verb.lemma.to_lowercase();
420        if lemma.ends_with('e') {
421            format!("{}d", lemma)
422        } else if lemma.ends_with('y') && !lemma.ends_with("ay") &&
423                  !lemma.ends_with("ey") && !lemma.ends_with("oy") && !lemma.ends_with("uy") {
424            format!("{}ied", &lemma[..lemma.len()-1])
425        } else {
426            format!("{}ed", lemma)
427        }
428    }
429}
430
431/// Computes the gerund (present participle) form of a verb.
432///
433/// Returns the irregular form if one is defined in the verb's `forms` map under
434/// the `"gerund"` key. Otherwise, applies English gerund formation rules:
435///
436/// - Ends in `-e` (but not `-ee`) → drop `e` and append `-ing` ("make" → "making")
437/// - Ends in `-ee` → append `-ing` without dropping ("see" → "seeing")
438/// - Default → append `-ing` ("run" → "running")
439///
440/// Note: This implementation does not handle consonant doubling (e.g., "run" → "running"
441/// should double the 'n', but this produces "runing"). For accurate results with such
442/// verbs, provide an irregular form in the `forms` map.
443///
444/// # Arguments
445///
446/// * `verb` - The verb entry containing the lemma and optional irregular forms.
447///
448/// # Examples
449///
450/// ```
451/// use logicaffeine_lexicon::runtime::{VerbEntry, gerund};
452/// use std::collections::HashMap;
453///
454/// let make = VerbEntry {
455///     lemma: "make".to_string(),
456///     class: "Activity".to_string(),
457///     forms: HashMap::new(),
458///     features: vec![],
459/// };
460/// assert_eq!(gerund(&make), "making");
461///
462/// let see = VerbEntry {
463///     lemma: "see".to_string(),
464///     class: "Activity".to_string(),
465///     forms: HashMap::new(),
466///     features: vec![],
467/// };
468/// assert_eq!(gerund(&see), "seeing");
469/// ```
470pub fn gerund(verb: &VerbEntry) -> String {
471    if let Some(form) = verb.forms.get("gerund") {
472        form.clone()
473    } else {
474        let lemma = verb.lemma.to_lowercase();
475        if lemma.ends_with('e') && !lemma.ends_with("ee") {
476            format!("{}ing", &lemma[..lemma.len()-1])
477        } else {
478            format!("{}ing", lemma)
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn test_lexicon_loads() {
489        let index = LexiconIndex::new();
490        assert!(!index.proper_nouns().is_empty());
491        assert!(!index.common_nouns().is_empty());
492        assert!(!index.intersective_adjectives().is_empty());
493    }
494
495    #[test]
496    fn from_json_matches_embedded_new() {
497        // A caller-supplied document (the wasm app fetches the same bytes at runtime)
498        // must build an index indistinguishable from the embedded-JSON constructor.
499        let embedded = LexiconIndex::new();
500        let parsed = LexiconIndex::from_json(LEXICON_JSON).expect("shipped lexicon.json parses");
501        assert_eq!(embedded.proper_nouns().len(), parsed.proper_nouns().len());
502        assert_eq!(embedded.common_nouns().len(), parsed.common_nouns().len());
503        assert_eq!(embedded.transitive_verbs().len(), parsed.transitive_verbs().len());
504        assert_eq!(embedded.intransitive_verbs().len(), parsed.intransitive_verbs().len());
505        assert_eq!(
506            embedded.intersective_adjectives().len(),
507            parsed.intersective_adjectives().len()
508        );
509        // Malformed input is a proper error, never a panic.
510        assert!(LexiconIndex::from_json("{").is_err());
511    }
512
513    #[test]
514    fn test_proper_nouns() {
515        let index = LexiconIndex::new();
516        let proper = index.proper_nouns();
517        assert!(proper.iter().any(|n| n.lemma == "John"));
518        assert!(proper.iter().any(|n| n.lemma == "Mary"));
519    }
520
521    #[test]
522    fn test_intersective_adjectives() {
523        let index = LexiconIndex::new();
524        let adj = index.intersective_adjectives();
525        assert!(adj.iter().any(|a| a.lemma == "Happy"));
526        assert!(adj.iter().any(|a| a.lemma == "Red"));
527    }
528
529    #[test]
530    fn test_pluralize() {
531        let noun = NounEntry {
532            lemma: "Dog".to_string(),
533            forms: HashMap::new(),
534            features: vec![],
535            sort: None,
536        };
537        assert_eq!(pluralize(&noun), "dogs");
538
539        let noun_irregular = NounEntry {
540            lemma: "Man".to_string(),
541            forms: [("plural".to_string(), "men".to_string())].into(),
542            features: vec![],
543            sort: None,
544        };
545        assert_eq!(pluralize(&noun_irregular), "men");
546    }
547
548    #[test]
549    fn test_present_3s() {
550        let verb = VerbEntry {
551            lemma: "Run".to_string(),
552            class: "Activity".to_string(),
553            forms: HashMap::new(),
554            features: vec![],
555        };
556        assert_eq!(present_3s(&verb), "runs");
557    }
558}