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}