1use std::collections::{BTreeMap, HashSet};
10use std::sync::OnceLock;
11
12use crate::engine::{FormalAiEngine, SymbolicAnswer};
13use crate::seed::parser::{parse_lino, split_pipe_list};
14
15const BASIS_POINTS_DENOMINATOR: usize = 10_000;
16
17const DEFAULT_LANGUAGE: &str = "en";
23
24#[derive(Debug, Clone)]
36struct QuestionLexicon {
37 languages: BTreeMap<String, LanguageLexicon>,
43 tier: TierCurve,
44}
45
46#[derive(Debug, Clone, Default)]
48struct LanguageLexicon {
49 words: Vec<QuestionWord>,
50 openers: HashSet<String>,
51 auxiliaries: HashSet<String>,
52 function_words: HashSet<String>,
53}
54
55impl QuestionLexicon {
56 fn words_for(&self, language: &str) -> &[QuestionWord] {
59 self.languages
60 .get(language)
61 .map_or(&[], |lexicon| lexicon.words.as_slice())
62 }
63
64 fn default_words(&self) -> &[QuestionWord] {
66 self.words_for(DEFAULT_LANGUAGE)
67 }
68
69 fn any_language_has_role(&self, token: &str, role: GrammarRole) -> bool {
73 self.languages.values().any(|lexicon| {
74 let set = match role {
75 GrammarRole::InterrogativeOpener => &lexicon.openers,
76 GrammarRole::AuxiliaryOpener => &lexicon.auxiliaries,
77 GrammarRole::FunctionWord => &lexicon.function_words,
78 };
79 set.contains(token)
80 })
81 }
82}
83
84#[derive(Debug, Clone, Copy)]
85enum GrammarRole {
86 InterrogativeOpener,
87 AuxiliaryOpener,
88 FunctionWord,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96struct TierCurve {
97 base_basis_points: usize,
98 halving_start_word_count: usize,
99 max_halvings: u32,
100 minimum_ranked_words: usize,
101}
102
103impl TierCurve {
104 fn basis_points_for_word_count(self, word_count: usize) -> usize {
108 if word_count <= self.halving_start_word_count {
109 return self.base_basis_points;
110 }
111 let halvings = word_count
112 .saturating_sub(self.halving_start_word_count)
113 .min(self.max_halvings as usize);
114 (self.base_basis_points >> halvings).max(1)
115 }
116}
117
118const QUESTION_LEXICON_LINO: &str = include_str!("../data/seed/question-generation-lexicon.lino");
119
120fn question_lexicon() -> &'static QuestionLexicon {
122 static CELL: OnceLock<QuestionLexicon> = OnceLock::new();
123 CELL.get_or_init(load_question_lexicon)
124}
125
126fn load_question_lexicon() -> QuestionLexicon {
127 let tree = parse_lino(QUESTION_LEXICON_LINO);
128 let mut languages: BTreeMap<String, LanguageLexicon> = BTreeMap::new();
129 let mut tier = TierCurve {
130 base_basis_points: 1_000,
131 halving_start_word_count: 2,
132 max_halvings: 9,
133 minimum_ranked_words: 4,
134 };
135
136 for record in &tree.children {
137 match record.find_child_value("record_type") {
138 "frequency_word" => {
139 let language = record.find_child_value("language");
140 if language.is_empty() {
141 continue;
142 }
143 let surface = record.find_child_value("surface");
144 if surface.is_empty() {
145 continue;
146 }
147 let scores: Vec<f32> = split_pipe_list(record.find_child_value("frequency_scores"))
148 .iter()
149 .filter_map(|token| token.parse::<f32>().ok())
150 .collect();
151 languages
152 .entry(language.to_string())
153 .or_default()
154 .words
155 .push(QuestionWord::from_corpus_scores(surface, &scores));
156 }
157 "grammar_role" => {
158 let language = record.find_child_value("language");
159 if language.is_empty() {
160 continue;
161 }
162 let members: Vec<String> = split_pipe_list(record.find_child_value("member"))
163 .into_iter()
164 .map(|member| member.to_ascii_lowercase())
165 .collect();
166 let lexicon = languages.entry(language.to_string()).or_default();
167 match record.find_child_value("role") {
168 "interrogative_opener" => lexicon.openers.extend(members),
169 "auxiliary_opener" => lexicon.auxiliaries.extend(members),
170 "function_word" => lexicon.function_words.extend(members),
171 _ => {}
172 }
173 }
174 "tier_policy" => {
175 if let Some(value) = parse_usize(record.find_child_value("base_basis_points")) {
176 tier.base_basis_points = value;
177 }
178 if let Some(value) =
179 parse_usize(record.find_child_value("halving_start_word_count"))
180 {
181 tier.halving_start_word_count = value;
182 }
183 if let Some(value) = parse_usize(record.find_child_value("max_halvings"))
184 .and_then(|value| u32::try_from(value).ok())
185 {
186 tier.max_halvings = value;
187 }
188 if let Some(value) = parse_usize(record.find_child_value("minimum_ranked_words")) {
189 tier.minimum_ranked_words = value;
190 }
191 }
192 _ => {}
193 }
194 }
195
196 QuestionLexicon { languages, tier }
197}
198
199fn parse_usize(value: &str) -> Option<usize> {
200 value.trim().parse::<usize>().ok()
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct QuestionLexiconSummary {
208 pub language: String,
210 pub vocabulary: Vec<String>,
212 pub interrogative_openers: Vec<String>,
214 pub auxiliary_openers: Vec<String>,
216 pub function_words: Vec<String>,
218 pub tier_base_basis_points: usize,
220 pub tier_minimum_ranked_words: usize,
222}
223
224#[must_use]
227pub fn question_lexicon_summary() -> QuestionLexiconSummary {
228 question_lexicon_summary_for_language(DEFAULT_LANGUAGE)
229 .expect("the default language must be present in the seed lexicon")
230}
231
232#[must_use]
238pub fn question_lexicon_summary_for_language(language: &str) -> Option<QuestionLexiconSummary> {
239 let lexicon = question_lexicon();
240 let language_lexicon = lexicon.languages.get(language)?;
241 let vocabulary = QuestionGenerationConfig::for_language(language)
242 .words()
243 .iter()
244 .map(|word| word.surface.clone())
245 .collect();
246 Some(QuestionLexiconSummary {
247 language: language.to_string(),
248 vocabulary,
249 interrogative_openers: sorted(&language_lexicon.openers),
250 auxiliary_openers: sorted(&language_lexicon.auxiliaries),
251 function_words: sorted(&language_lexicon.function_words),
252 tier_base_basis_points: lexicon.tier.base_basis_points,
253 tier_minimum_ranked_words: lexicon.tier.minimum_ranked_words,
254 })
255}
256
257fn sorted(set: &HashSet<String>) -> Vec<String> {
258 let mut members: Vec<String> = set.iter().cloned().collect();
259 members.sort();
260 members
261}
262
263#[derive(Debug, Clone, PartialEq)]
264pub struct QuestionWord {
265 pub surface: String,
266 pub average_frequency_score: f32,
267 pub corpus_count: usize,
268}
269
270impl QuestionWord {
271 #[must_use]
272 pub fn from_corpus_scores(surface: impl Into<String>, scores: &[f32]) -> Self {
273 let mut corpus_count = 0usize;
274 let mut score_count = 0.0;
275 let mut score_sum = 0.0;
276 for score in scores.iter().copied().filter(|score| score.is_finite()) {
277 corpus_count += 1;
278 score_count += 1.0;
279 score_sum += score;
280 }
281 let average_frequency_score = if corpus_count == 0 {
282 0.0
283 } else {
284 score_sum / score_count
285 };
286
287 Self {
288 surface: normalize_word_surface(&surface.into()),
289 average_frequency_score,
290 corpus_count,
291 }
292 }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum QuestionAcceptance {
297 AnyQuestionLike,
298 Grammatical,
299 GrammaticalAndMeaningful,
300}
301
302impl QuestionAcceptance {
303 fn accepts(self, question: &GeneratedQuestion) -> bool {
304 match self {
305 Self::AnyQuestionLike => true,
306 Self::Grammatical => question.grammar == QuestionGrammarClass::Grammatical,
307 Self::GrammaticalAndMeaningful => {
308 question.class == GeneratedQuestionClass::GrammaticalAndMeaningful
309 }
310 }
311 }
312
313 const fn requires_grammatical_candidate(self) -> bool {
314 matches!(self, Self::Grammatical | Self::GrammaticalAndMeaningful)
315 }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum FrequencySelectionPolicy {
321 FrequencyTiers,
324 AllRankedWords,
326}
327
328#[derive(Debug, Clone, PartialEq)]
329pub struct QuestionGenerationConfig {
330 words: Vec<QuestionWord>,
331 acceptance: QuestionAcceptance,
332 frequency_policy: FrequencySelectionPolicy,
333 tier: TierCurve,
334 minimum_ranked_words: usize,
335}
336
337impl Default for QuestionGenerationConfig {
338 fn default() -> Self {
339 Self::for_language(DEFAULT_LANGUAGE)
342 }
343}
344
345impl QuestionGenerationConfig {
346 #[must_use]
351 pub fn for_language(language: &str) -> Self {
352 let words = question_lexicon().words_for(language);
353 let words = if words.is_empty() {
354 question_lexicon().default_words()
355 } else {
356 words
357 };
358 Self::from_words(words.iter().cloned())
359 }
360
361 #[must_use]
362 pub fn from_words<I>(words: I) -> Self
363 where
364 I: IntoIterator<Item = QuestionWord>,
365 {
366 let mut ranked: Vec<QuestionWord> = words
367 .into_iter()
368 .filter(|word| !word.surface.trim().is_empty())
369 .collect();
370 ranked.sort_by(|left, right| {
371 right
372 .average_frequency_score
373 .total_cmp(&left.average_frequency_score)
374 .then_with(|| left.surface.cmp(&right.surface))
375 });
376
377 let mut seen = HashSet::new();
378 ranked.retain(|word| seen.insert(word.surface.to_ascii_lowercase()));
379
380 let tier = question_lexicon().tier;
381 Self {
382 words: ranked,
383 acceptance: QuestionAcceptance::GrammaticalAndMeaningful,
384 frequency_policy: FrequencySelectionPolicy::FrequencyTiers,
385 tier,
386 minimum_ranked_words: tier.minimum_ranked_words,
387 }
388 }
389
390 #[must_use]
391 pub const fn with_acceptance(mut self, acceptance: QuestionAcceptance) -> Self {
392 self.acceptance = acceptance;
393 self
394 }
395
396 #[must_use]
397 pub const fn with_all_ranked_words(mut self) -> Self {
398 self.frequency_policy = FrequencySelectionPolicy::AllRankedWords;
399 self
400 }
401
402 #[must_use]
403 pub const fn with_minimum_ranked_words(mut self, minimum_ranked_words: usize) -> Self {
404 self.minimum_ranked_words = minimum_ranked_words;
405 self
406 }
407
408 #[must_use]
409 pub fn words(&self) -> &[QuestionWord] {
410 &self.words
411 }
412
413 fn ranked_word_limit(&self, word_count: usize) -> usize {
414 match self.frequency_policy {
415 FrequencySelectionPolicy::AllRankedWords => self.words.len(),
416 FrequencySelectionPolicy::FrequencyTiers => {
417 let basis_points = self.tier.basis_points_for_word_count(word_count);
418 let selected = self
419 .words
420 .len()
421 .saturating_mul(basis_points)
422 .saturating_add(BASIS_POINTS_DENOMINATOR - 1)
423 / BASIS_POINTS_DENOMINATOR;
424 selected
425 .max(1)
426 .max(self.minimum_ranked_words.min(self.words.len()))
427 .min(self.words.len())
428 }
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum QuestionGrammarClass {
435 Fragment,
436 Grammatical,
437 Ungrammatical,
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum LogicalMeaningClass {
442 Meaningful,
443 OpenSlot,
444 NotMeaningful,
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum GeneratedQuestionClass {
449 GrammaticalAndMeaningful,
450 GrammaticalOpenSlot,
451 Fragment,
452 Ungrammatical,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct GeneratedQuestion {
457 pub text: String,
458 pub words: Vec<String>,
459 pub word_count: usize,
460 pub grammar: QuestionGrammarClass,
461 pub logical_meaning: LogicalMeaningClass,
462 pub class: GeneratedQuestionClass,
463}
464
465#[derive(Debug, Clone)]
466pub struct QuestionGenerator {
467 config: QuestionGenerationConfig,
468 word_count: usize,
469 indices: Vec<usize>,
470 exhausted: bool,
471}
472
473impl QuestionGenerator {
474 #[must_use]
475 pub fn new(config: QuestionGenerationConfig) -> Self {
476 let exhausted = config.words.is_empty()
477 || !config.words.iter().any(|word| {
478 is_question_opener(&word.surface) || is_auxiliary_opener(&word.surface)
479 });
480 Self {
481 config,
482 word_count: 1,
483 indices: vec![0],
484 exhausted,
485 }
486 }
487}
488
489impl Default for QuestionGenerator {
490 fn default() -> Self {
491 Self::new(QuestionGenerationConfig::default())
492 }
493}
494
495impl Iterator for QuestionGenerator {
496 type Item = GeneratedQuestion;
497
498 fn next(&mut self) -> Option<Self::Item> {
499 if self.exhausted {
500 return None;
501 }
502
503 loop {
504 let limit = self.config.ranked_word_limit(self.word_count);
505 if limit == 0 {
506 self.exhausted = true;
507 return None;
508 }
509 if self.config.acceptance.requires_grammatical_candidate() && self.word_count > limit {
510 self.exhausted = true;
511 return None;
512 }
513 if self.indices.iter().any(|index| *index >= limit) {
514 self.indices = vec![0; self.word_count];
515 }
516
517 let question = self.current_question();
518 self.advance(limit);
519
520 if let Some(question) = question {
521 if self.config.acceptance.accepts(&question) {
522 return Some(question);
523 }
524 }
525 }
526 }
527}
528
529impl QuestionGenerator {
530 fn current_question(&self) -> Option<GeneratedQuestion> {
531 let tokens: Vec<String> = self
532 .indices
533 .iter()
534 .filter_map(|index| self.config.words.get(*index))
535 .map(|word| word.surface.clone())
536 .collect();
537
538 if tokens.len() != self.word_count || !is_question_like(&tokens) {
539 return None;
540 }
541
542 Some(classify_question(tokens, &self.indices))
543 }
544
545 fn advance(&mut self, limit: usize) {
546 for position in (0..self.indices.len()).rev() {
547 if self.indices[position] + 1 < limit {
548 self.indices[position] += 1;
549 return;
550 }
551 self.indices[position] = 0;
552 }
553
554 self.word_count += 1;
555 self.indices = vec![0; self.word_count];
556 }
557}
558
559#[derive(Debug, Clone, PartialEq)]
560pub struct GeneratedQuestionAnswer {
561 pub question: GeneratedQuestion,
562 pub answer: SymbolicAnswer,
563}
564
565#[derive(Debug, Clone)]
566pub struct GeneratedQuestionAnswerStream {
567 questions: QuestionGenerator,
568 engine: FormalAiEngine,
569}
570
571impl Iterator for GeneratedQuestionAnswerStream {
572 type Item = GeneratedQuestionAnswer;
573
574 fn next(&mut self) -> Option<Self::Item> {
575 self.questions
576 .next()
577 .map(|question| GeneratedQuestionAnswer {
578 answer: self.engine.answer(&question.text),
579 question,
580 })
581 }
582}
583
584#[must_use]
585pub fn generated_question_answers(
586 config: QuestionGenerationConfig,
587) -> GeneratedQuestionAnswerStream {
588 GeneratedQuestionAnswerStream {
589 questions: QuestionGenerator::new(config),
590 engine: FormalAiEngine,
591 }
592}
593
594fn classify_question(tokens: Vec<String>, indices: &[usize]) -> GeneratedQuestion {
595 let word_count = tokens.len();
596 let grammar = classify_grammar(&tokens, indices);
597 let logical_meaning = classify_logical_meaning(&tokens, indices, grammar);
598 let class = match (grammar, logical_meaning) {
599 (QuestionGrammarClass::Grammatical, LogicalMeaningClass::Meaningful) => {
600 GeneratedQuestionClass::GrammaticalAndMeaningful
601 }
602 (QuestionGrammarClass::Grammatical, LogicalMeaningClass::OpenSlot) => {
603 GeneratedQuestionClass::GrammaticalOpenSlot
604 }
605 (QuestionGrammarClass::Fragment, _) => GeneratedQuestionClass::Fragment,
606 (QuestionGrammarClass::Ungrammatical, _) | (_, LogicalMeaningClass::NotMeaningful) => {
607 GeneratedQuestionClass::Ungrammatical
608 }
609 };
610
611 GeneratedQuestion {
612 text: format!("{}?", tokens.join(" ")),
613 words: tokens,
614 word_count,
615 grammar,
616 logical_meaning,
617 class,
618 }
619}
620
621fn classify_grammar(tokens: &[String], indices: &[usize]) -> QuestionGrammarClass {
622 let Some(first) = tokens.first() else {
623 return QuestionGrammarClass::Ungrammatical;
624 };
625
626 if tokens.len() == 1 {
627 return if is_question_opener(first) || is_auxiliary_opener(first) {
628 QuestionGrammarClass::Fragment
629 } else {
630 QuestionGrammarClass::Ungrammatical
631 };
632 }
633
634 if !is_question_opener(first) && !is_auxiliary_opener(first) {
635 return QuestionGrammarClass::Ungrammatical;
636 }
637 if has_duplicate_token(tokens) || !tail_indices_are_ordered(indices) {
638 return QuestionGrammarClass::Ungrammatical;
639 }
640 if tokens
641 .iter()
642 .skip(1)
643 .any(|token| is_question_pronoun(token))
644 {
645 return QuestionGrammarClass::Ungrammatical;
646 }
647 if tokens.len() == 2 {
648 return QuestionGrammarClass::Fragment;
649 }
650 if is_question_pronoun(first) {
651 if tokens
652 .get(1)
653 .is_some_and(|token| is_auxiliary_opener(token) || is_content_word(token))
654 {
655 return QuestionGrammarClass::Grammatical;
656 }
657 return QuestionGrammarClass::Ungrammatical;
658 }
659 if is_auxiliary_opener(first) && tokens.iter().skip(1).all(|token| is_content_word(token)) {
660 return QuestionGrammarClass::Grammatical;
661 }
662
663 QuestionGrammarClass::Ungrammatical
664}
665
666fn classify_logical_meaning(
667 tokens: &[String],
668 indices: &[usize],
669 grammar: QuestionGrammarClass,
670) -> LogicalMeaningClass {
671 match grammar {
672 QuestionGrammarClass::Ungrammatical => LogicalMeaningClass::NotMeaningful,
673 QuestionGrammarClass::Fragment => LogicalMeaningClass::OpenSlot,
674 QuestionGrammarClass::Grammatical => {
675 let content_count = tokens
676 .iter()
677 .skip(1)
678 .filter(|token| is_content_word(token))
679 .count();
680 if content_count > 0
681 && tokens.last().is_some_and(|token| is_content_word(token))
682 && tail_indices_are_ordered(indices)
683 {
684 LogicalMeaningClass::Meaningful
685 } else {
686 LogicalMeaningClass::OpenSlot
687 }
688 }
689 }
690}
691
692fn normalize_word_surface(surface: &str) -> String {
693 surface
694 .trim()
695 .trim_end_matches('?')
696 .split_whitespace()
697 .collect::<Vec<_>>()
698 .join(" ")
699 .to_ascii_lowercase()
700}
701
702fn is_question_like(tokens: &[String]) -> bool {
703 tokens
704 .first()
705 .is_some_and(|token| is_question_opener(token) || is_auxiliary_opener(token))
706}
707
708fn is_question_opener(token: &str) -> bool {
711 question_lexicon().any_language_has_role(
712 &token.to_ascii_lowercase(),
713 GrammarRole::InterrogativeOpener,
714 )
715}
716
717fn is_question_pronoun(token: &str) -> bool {
718 is_question_opener(token)
719}
720
721fn is_auxiliary_opener(token: &str) -> bool {
724 question_lexicon()
725 .any_language_has_role(&token.to_ascii_lowercase(), GrammarRole::AuxiliaryOpener)
726}
727
728fn is_content_word(token: &str) -> bool {
732 let lower = token.to_ascii_lowercase();
733 !is_question_pronoun(&lower)
734 && !is_auxiliary_opener(&lower)
735 && !question_lexicon().any_language_has_role(&lower, GrammarRole::FunctionWord)
736}
737
738fn has_duplicate_token(tokens: &[String]) -> bool {
739 let mut seen = HashSet::new();
740 tokens
741 .iter()
742 .any(|token| !seen.insert(token.to_ascii_lowercase()))
743}
744
745fn tail_indices_are_ordered(indices: &[usize]) -> bool {
746 indices
747 .iter()
748 .skip(1)
749 .zip(indices.iter().skip(2))
750 .all(|(left, right)| left < right)
751}