1use 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#[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
76pub 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
89pub 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
101pub struct Generator {
106 lexicon: &'static LexiconIndex,
107}
108
109#[derive(Debug, Clone)]
113pub struct Challenge {
114 pub exercise_id: String,
116 pub prompt: String,
118 pub sentence: String,
120 pub answer: AnswerType,
122 pub hint: Option<String>,
124 pub explanation: Option<String>,
126}
127
128#[derive(Debug, Clone)]
130pub enum AnswerType {
131 FreeForm {
133 golden_logic: String,
135 },
136 MultipleChoice {
138 options: Vec<String>,
140 correct_index: usize,
142 },
143 Ambiguity {
145 readings: Vec<String>,
147 },
148}
149
150impl Generator {
151 const MAX_DRAWS: usize = 32;
156
157 pub fn new() -> Self {
159 Self {
160 lexicon: lexicon().expect("gated: LexiconGate resolves the lexicon before Learn content renders"),
161 }
162 }
163
164 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 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 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 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}