Skip to main content

logicaffeine_web/
generator.rs

1//! Exercise generation from templates.
2//!
3//! Transforms exercise configurations from the curriculum into concrete
4//! challenges by filling template slots with words from the lexicon.
5//!
6//! # Template Syntax
7//!
8//! Templates use curly braces for slots: `{SlotType}` or `{SlotType:Modifier}`.
9//!
10//! ## Slot Types
11//!
12//! | Slot | Description | Example Output |
13//! |------|-------------|----------------|
14//! | `{ProperName}` | Random proper noun | "Alice", "Bob" |
15//! | `{Noun}` | Random common noun (singular) | "cat", "dog" |
16//! | `{Noun:Plural}` | Pluralized common noun | "cats", "dogs" |
17//! | `{Verb}` | Base form verb | "run", "eat" |
18//! | `{Verb:Past}` | Past tense | "ran", "ate" |
19//! | `{Verb:Present3s}` | Third person singular | "runs", "eats" |
20//! | `{Verb:Gerund}` | Present participle | "running", "eating" |
21//! | `{Adjective}` | Intersective adjective | "tall", "red" |
22//!
23//! ## Constraints
24//!
25//! Constraints filter word selection by semantic features. They're specified
26//! in the exercise config's `constraints` map:
27//!
28//! ```json
29//! {
30//!   "Verb": ["Intransitive"],
31//!   "Noun": ["Animate"]
32//! }
33//! ```
34//!
35//! ## Example
36//!
37//! Template: `"{ProperName} {Verb:Present3s}."`
38//! With constraint `Verb: ["Intransitive"]`
39//! Output: `"Alice runs."`
40//!
41//! # Usage
42//!
43//! ```no_run
44//! use logicaffeine_web::generator::Generator;
45//! use logicaffeine_web::content::ExerciseConfig;
46//! use rand::SeedableRng;
47//!
48//! let generator = Generator::new();
49//! let mut rng = rand::rngs::StdRng::seed_from_u64(42);
50//! # let exercise_config: ExerciseConfig = serde_json::from_str(r#"{"id":"test","type":"Translation","difficulty":1,"prompt":"test"}"#).unwrap();
51//!
52//! if let Some(challenge) = generator.generate(&exercise_config, &mut rng) {
53//!     println!("Sentence: {}", challenge.sentence);
54//! }
55//! ```
56
57use crate::content::{ExerciseConfig, ExerciseType};
58use logicaffeine_language::runtime_lexicon::{LexiconIndex, pluralize, present_3s, past_tense, gerund};
59use logicaffeine_language::{compile, compile_all_scopes};
60use rand::Rng;
61use rand::seq::SliceRandom;
62use std::collections::HashMap;
63#[cfg(not(target_arch = "wasm32"))]
64use std::sync::LazyLock;
65#[cfg(target_arch = "wasm32")]
66use std::sync::OnceLock;
67
68// The process-wide lexicon, parsed exactly once. Native builds parse the embedded
69// JSON on first use; wasm pins the copy fetched from /data/lexicon.json (staged by
70// scripts/stage-web-data.sh) so the 279 KB document never rides in the binary.
71#[cfg(not(target_arch = "wasm32"))]
72static LEXICON: LazyLock<LexiconIndex> = LazyLock::new(LexiconIndex::new);
73#[cfg(target_arch = "wasm32")]
74static LEXICON: OnceLock<LexiconIndex> = OnceLock::new();
75
76/// The pinned lexicon. `None` only on wasm before [`ensure_lexicon`] resolves —
77/// the `LexiconGate` component holds Learn content back until it has.
78pub fn lexicon() -> Option<&'static LexiconIndex> {
79    #[cfg(not(target_arch = "wasm32"))]
80    {
81        Some(&LEXICON)
82    }
83    #[cfg(target_arch = "wasm32")]
84    {
85        LEXICON.get()
86    }
87}
88
89/// Resolves once the lexicon is pinned — instant on native and on repeat calls.
90pub async fn ensure_lexicon() -> Result<(), String> {
91    #[cfg(target_arch = "wasm32")]
92    if LEXICON.get().is_none() {
93        let text = crate::ui::data_fetch::fetch_static_text("/data/lexicon.json").await?;
94        let index = LexiconIndex::from_json(&text)
95            .map_err(|e| format!("parsing /data/lexicon.json: {e}"))?;
96        let _ = LEXICON.set(index);
97    }
98    Ok(())
99}
100
101/// Exercise generator that fills templates with lexicon words.
102///
103/// Holds a reference to the lexicon index for word selection.
104/// Stateless; create one instance and reuse for all exercise generation.
105pub struct Generator {
106    lexicon: &'static LexiconIndex,
107}
108
109/// A generated exercise instance ready for display and grading.
110///
111/// Contains the filled template, expected answer(s), and optional hints.
112#[derive(Debug, Clone)]
113pub struct Challenge {
114    /// Unique identifier linking back to the exercise config.
115    pub exercise_id: String,
116    /// Instructions shown to the user (e.g., "Translate this sentence").
117    pub prompt: String,
118    /// The filled template sentence for the user to translate.
119    pub sentence: String,
120    /// The expected answer format and content.
121    pub answer: AnswerType,
122    /// Optional hint revealed on request.
123    pub hint: Option<String>,
124    /// Detailed explanation shown after answering.
125    pub explanation: Option<String>,
126}
127
128/// Expected answer format for a challenge.
129#[derive(Debug, Clone)]
130pub enum AnswerType {
131    /// User enters free-form FOL expression, compared against golden answer.
132    FreeForm {
133        /// The correct FOL translation, pre-compiled from the sentence.
134        golden_logic: String,
135    },
136    /// User selects from multiple predefined options.
137    MultipleChoice {
138        /// List of answer choices to display.
139        options: Vec<String>,
140        /// Zero-based index of the correct option.
141        correct_index: usize,
142    },
143    /// Sentence has multiple valid readings; user must provide all.
144    Ambiguity {
145        /// All valid FOL translations for the ambiguous sentence.
146        readings: Vec<String>,
147    },
148}
149
150impl Generator {
151    /// How many sentences a generator may draw before giving up on an
152    /// exercise. Lexicon pools contain words the compiler rejects in some
153    /// frames, so generation rejection-samples; the bound keeps a
154    /// misconfigured template from looping forever.
155    const MAX_DRAWS: usize = 32;
156
157    /// Creates a new generator over the pinned lexicon index.
158    pub fn new() -> Self {
159        Self {
160            lexicon: lexicon().expect("gated: LexiconGate resolves the lexicon before Learn content renders"),
161        }
162    }
163
164    /// Generates a challenge from an exercise configuration.
165    ///
166    /// Returns `None` if:
167    /// - The template cannot be filled (missing word categories)
168    /// - The generated sentence fails to compile (invalid grammar)
169    /// - Required fields are missing from the exercise config
170    pub fn generate(&self, exercise: &ExerciseConfig, rng: &mut impl Rng) -> Option<Challenge> {
171        match exercise.exercise_type {
172            ExerciseType::Translation => self.generate_translation(exercise, rng),
173            ExerciseType::MultipleChoice => self.generate_multiple_choice(exercise, rng),
174            ExerciseType::Ambiguity => self.generate_ambiguity(exercise, rng),
175        }
176    }
177
178    fn generate_translation(&self, exercise: &ExerciseConfig, rng: &mut impl Rng) -> Option<Challenge> {
179        let template = exercise.template.as_ref()?;
180
181        // Rejection sampling: a draw whose sentence the compiler rejects is
182        // discarded and redrawn, so one unparseable word in a pool can never
183        // sink the exercise.
184        for _ in 0..Self::MAX_DRAWS {
185            let Some(sentence) = self.fill_template(template, &exercise.constraints, rng) else {
186                return None;
187            };
188            let Ok(golden_logic) = compile(&sentence) else {
189                continue;
190            };
191
192            return Some(Challenge {
193                exercise_id: exercise.id.clone(),
194                prompt: exercise.prompt.clone(),
195                sentence,
196                answer: AnswerType::FreeForm { golden_logic },
197                hint: exercise.hint.clone(),
198                explanation: exercise.explanation.clone(),
199            });
200        }
201        None
202    }
203
204    fn generate_multiple_choice(&self, exercise: &ExerciseConfig, rng: &mut impl Rng) -> Option<Challenge> {
205        let options = exercise.options.clone()?;
206        let correct_index = exercise.correct?;
207
208        let sentence = if let Some(template) = &exercise.template {
209            self.fill_template(template, &exercise.constraints, rng)?
210        } else {
211            exercise.prompt.clone()
212        };
213
214        Some(Challenge {
215            exercise_id: exercise.id.clone(),
216            prompt: exercise.prompt.clone(),
217            sentence,
218            answer: AnswerType::MultipleChoice { options, correct_index },
219            hint: exercise.hint.clone(),
220            explanation: exercise.explanation.clone(),
221        })
222    }
223
224    fn generate_ambiguity(&self, exercise: &ExerciseConfig, rng: &mut impl Rng) -> Option<Challenge> {
225        let template = exercise.template.as_ref()?;
226
227        for _ in 0..Self::MAX_DRAWS {
228            let Some(sentence) = self.fill_template(template, &exercise.constraints, rng) else {
229                return None;
230            };
231            let Ok(readings) = compile_all_scopes(&sentence) else {
232                continue;
233            };
234
235            return Some(Challenge {
236                exercise_id: exercise.id.clone(),
237                prompt: exercise.prompt.clone(),
238                sentence,
239                answer: AnswerType::Ambiguity { readings },
240                hint: exercise.hint.clone(),
241                explanation: exercise.explanation.clone(),
242            });
243        }
244        None
245    }
246
247    fn fill_template(&self, template: &str, constraints: &HashMap<String, Vec<String>>, rng: &mut impl Rng) -> Option<String> {
248        let mut result = template.to_string();
249        let mut used_names: HashMap<String, String> = HashMap::new();
250
251        while let Some(start) = result.find('{') {
252            let end = result[start..].find('}')? + start;
253            let slot = &result[start + 1..end];
254
255            let (slot_type, modifier) = if let Some(colon_pos) = slot.find(':') {
256                (&slot[..colon_pos], Some(&slot[colon_pos + 1..]))
257            } else {
258                (slot, None)
259            };
260
261            let slot_constraints = constraints.get(slot_type).map(|v| v.as_slice()).unwrap_or(&[]);
262            let word = self.fill_slot(slot_type, slot_constraints, modifier, &mut used_names, rng)?;
263
264            result = format!("{}{}{}", &result[..start], word, &result[end + 1..]);
265        }
266
267        Some(result)
268    }
269
270    fn fill_slot(
271        &self,
272        slot_type: &str,
273        constraints: &[String],
274        modifier: Option<&str>,
275        used_names: &mut HashMap<String, String>,
276        rng: &mut impl Rng,
277    ) -> Option<String> {
278        match slot_type {
279            "ProperName" => {
280                let key = format!("ProperName_{}", used_names.len());
281                if let Some(existing) = used_names.get(&key) {
282                    return Some(existing.clone());
283                }
284
285                let proper_nouns = self.lexicon.proper_nouns();
286                let available: Vec<_> = proper_nouns
287                    .iter()
288                    .filter(|n| !used_names.values().any(|v| v == &n.lemma))
289                    .copied()
290                    .collect();
291
292                let entry = if !available.is_empty() {
293                    available.choose(rng)?
294                } else {
295                    proper_nouns.choose(rng)?
296                };
297                let name = entry.lemma.clone();
298                used_names.insert(key, name.clone());
299                Some(name)
300            }
301            "Noun" => {
302                let nouns = if constraints.is_empty() {
303                    self.lexicon.common_nouns()
304                } else {
305                    let mut filtered = Vec::new();
306                    for constraint in constraints {
307                        filtered.extend(self.lexicon.nouns_with_feature(constraint));
308                    }
309                    filtered
310                };
311
312                let entry = nouns.choose(rng)?;
313                let word = entry.lemma.to_lowercase();
314
315                match modifier {
316                    Some("Plural") => Some(pluralize(entry)),
317                    _ => Some(word),
318                }
319            }
320            "Verb" => {
321                let verbs = if constraints.contains(&"Intransitive".to_string()) {
322                    self.lexicon.intransitive_verbs()
323                } else if constraints.contains(&"Transitive".to_string()) {
324                    self.lexicon.transitive_verbs()
325                } else {
326                    let mut result = Vec::new();
327                    for constraint in constraints {
328                        result.extend(self.lexicon.verbs_with_feature(constraint));
329                    }
330                    if result.is_empty() {
331                        self.lexicon.intransitive_verbs()
332                    } else {
333                        result
334                    }
335                };
336
337                let entry = verbs.choose(rng)?;
338
339                match modifier {
340                    Some("Past") => Some(past_tense(entry)),
341                    Some("Gerund") => Some(gerund(entry)),
342                    Some("Present3s") => Some(present_3s(entry)),
343                    _ => Some(entry.lemma.to_lowercase()),
344                }
345            }
346            "Adjective" => {
347                let adjectives = if constraints.contains(&"Intersective".to_string()) {
348                    self.lexicon.intersective_adjectives()
349                } else if constraints.is_empty() {
350                    self.lexicon.intersective_adjectives()
351                } else {
352                    let mut result = Vec::new();
353                    for constraint in constraints {
354                        result.extend(self.lexicon.adjectives_with_feature(constraint));
355                    }
356                    result
357                };
358
359                let entry = adjectives.choose(rng)?;
360                Some(entry.lemma.to_lowercase())
361            }
362            _ => Some("thing".to_string()),
363        }
364    }
365}
366
367impl Default for Generator {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::content::ContentEngine;
377    use rand::SeedableRng;
378    use rand::rngs::StdRng;
379
380    #[test]
381    fn test_generate_translation_challenge() {
382        let engine = ContentEngine::new();
383        let generator = Generator::new();
384        let mut rng = StdRng::seed_from_u64(42);
385
386        // Use introduction module which has Translation exercises
387        let exercise = engine.get_exercise("first-steps", "introduction", "I_1.1");
388        assert!(exercise.is_some(), "Exercise first-steps/introduction/I_1.1 should exist");
389        let exercise = exercise.unwrap();
390        let challenge = generator.generate(exercise, &mut rng);
391
392        assert!(challenge.is_some(), "Should generate a challenge");
393        let challenge = challenge.unwrap();
394        assert!(!challenge.sentence.is_empty(), "Sentence should not be empty");
395
396        if let AnswerType::FreeForm { golden_logic } = &challenge.answer {
397            assert!(!golden_logic.is_empty(), "Golden logic should not be empty");
398        } else {
399            panic!("Expected FreeForm answer type");
400        }
401    }
402
403    #[test]
404    fn test_generate_multiple_choice() {
405        let engine = ContentEngine::new();
406        let generator = Generator::new();
407        let mut rng = StdRng::seed_from_u64(42);
408
409        // Use syllogistic module which has MultipleChoice exercises
410        let exercise = engine.get_exercise("first-steps", "syllogistic", "A_1.1");
411        assert!(exercise.is_some(), "Exercise first-steps/syllogistic/A_1.1 should exist");
412        let exercise = exercise.unwrap();
413        let challenge = generator.generate(exercise, &mut rng);
414
415        assert!(challenge.is_some(), "Should generate a challenge");
416        let challenge = challenge.unwrap();
417
418        if let AnswerType::MultipleChoice { options, correct_index } = &challenge.answer {
419            assert_eq!(options.len(), 4, "Should have 4 options");
420            assert!(*correct_index < options.len(), "Correct index should be within options range");
421        } else {
422            panic!("Expected MultipleChoice answer type");
423        }
424    }
425
426    #[test]
427    fn test_fill_template_proper_names() {
428        let generator = Generator::new();
429        let mut rng = StdRng::seed_from_u64(42);
430
431        let constraints = HashMap::new();
432        let result = generator.fill_template("{ProperName} runs.", &constraints, &mut rng);
433
434        assert!(result.is_some());
435        let sentence = result.unwrap();
436        assert!(sentence.ends_with(" runs."), "Template should be filled: {}", sentence);
437        assert!(!sentence.starts_with("{"), "Slot should be replaced");
438    }
439
440    #[test]
441    fn test_fill_template_with_modifier() {
442        let generator = Generator::new();
443        let mut rng = StdRng::seed_from_u64(42);
444
445        let constraints = HashMap::new();
446        let result = generator.fill_template("All {Noun:Plural} run.", &constraints, &mut rng);
447
448        assert!(result.is_some());
449        let sentence = result.unwrap();
450        assert!(!sentence.contains("{"), "All slots should be filled: {}", sentence);
451    }
452
453    #[test]
454    fn test_deterministic_with_seed() {
455        let generator = Generator::new();
456        let mut rng1 = StdRng::seed_from_u64(12345);
457        let mut rng2 = StdRng::seed_from_u64(12345);
458
459        let constraints = HashMap::new();
460        let result1 = generator.fill_template("{ProperName} is {Adjective}.", &constraints, &mut rng1);
461        let result2 = generator.fill_template("{ProperName} is {Adjective}.", &constraints, &mut rng2);
462
463        assert_eq!(result1, result2, "Same seed should produce same output");
464    }
465
466    #[test]
467    fn test_all_introduction_exercises() {
468        let engine = ContentEngine::new();
469        let generator = Generator::new();
470
471        let module = engine.get_module("first-steps", "introduction");
472        assert!(module.is_some(), "Introduction module should exist");
473        let module = module.unwrap();
474
475        println!("Introduction module has {} exercises", module.exercises.len());
476
477        for (i, exercise) in module.exercises.iter().enumerate() {
478            let mut rng = StdRng::seed_from_u64(42 + i as u64);
479            println!("Exercise {}: id={}, type={:?}", i, exercise.id, exercise.exercise_type);
480
481            let challenge = generator.generate(exercise, &mut rng);
482            if challenge.is_none() {
483                println!("  FAILED to generate challenge!");
484                if let Some(template) = &exercise.template {
485                    println!("  Template: {}", template);
486                    let filled = generator.fill_template(template, &exercise.constraints, &mut StdRng::seed_from_u64(42));
487                    println!("  Filled template: {:?}", filled);
488                    if let Some(sentence) = filled {
489                        let compiled = compile(&sentence);
490                        println!("  Compile result: {:?}", compiled);
491                    }
492                }
493            } else {
494                println!("  OK: {:?}", challenge.as_ref().map(|c| &c.sentence));
495            }
496            assert!(challenge.is_some(), "Exercise {} ({}) should generate a challenge", i, exercise.id);
497        }
498    }
499
500    #[test]
501    fn test_all_exercises_across_all_modules() {
502        let engine = ContentEngine::new();
503        let generator = Generator::new();
504
505        let mut total_exercises = 0;
506        let mut successful = 0;
507        let mut failed_exercises = Vec::new();
508
509        for era in engine.eras() {
510            for module in &era.modules {
511                if let Some(m) = engine.get_module(&era.meta.id, &module.meta.id) {
512                    for (i, exercise) in m.exercises.iter().enumerate() {
513                        total_exercises += 1;
514                        let mut rng = StdRng::seed_from_u64(42 + i as u64);
515
516                        let challenge = generator.generate(exercise, &mut rng);
517                        if challenge.is_some() {
518                            successful += 1;
519                        } else {
520                            failed_exercises.push(format!("{}/{}/{}", era.meta.id, module.meta.id, exercise.id));
521                        }
522                    }
523                }
524            }
525        }
526
527        println!("Total exercises: {}", total_exercises);
528        println!("Successful: {}", successful);
529        println!("Failed: {}", failed_exercises.len());
530
531        if !failed_exercises.is_empty() {
532            println!("\nFailed exercises:");
533            for ex in &failed_exercises {
534                println!("  - {}", ex);
535            }
536        }
537
538        assert!(
539            failed_exercises.is_empty(),
540            "every exercise must generate a challenge; failed: {:?}",
541            failed_exercises
542        );
543    }
544}