Skip to main content

formal_ai/
question_generation.rs

1//! Lazy question generation and answering primitives for issue #527.
2//!
3//! The generator intentionally produces a stream, not a collection: the set of
4//! token sequences is unbounded as word count grows. Each candidate is classified
5//! before it leaves the iterator so callers can choose whether they want raw
6//! question-like fragments, grammatical questions, or only logically meaningful
7//! questions.
8
9use 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
17/// The language whose vocabulary and grammar roles the default generator reads.
18///
19/// Every record in the lexicon carries a `language` tag, so a second language is
20/// added purely in data; the enumeration, tiering, and answering logic never
21/// branch on the language.
22const DEFAULT_LANGUAGE: &str = "en";
23
24/// The frequency-ranked vocabulary and grammar role sets that drive generation,
25/// lifted out of Rust literals into `data/seed/question-generation-lexicon.lino`.
26///
27/// Issue #527 requires the generator to draw from a frequency-tiered vocabulary
28/// and to classify candidates grammatically and logically. Historically both the
29/// words and the grammar lexicons (interrogative openers, auxiliary openers,
30/// function words) lived as inline `matches!`/literal lists here, which branched
31/// the *general* generation logic on specific English words. This struct holds
32/// them as reviewable link data instead; the Rust code keeps only the structural
33/// glue (lazy enumeration, tier curve, grammar/logic gates) and reads every word
34/// and role from here. Mirrors the cue-lexicon migration (issue #559).
35#[derive(Debug, Clone)]
36struct QuestionLexicon {
37    /// Grammar roles and frequency vocabulary keyed by language tag (`en`, `ru`,
38    /// …). The generation and classification logic never branches on a specific
39    /// word *or* language: it reads whichever language the caller selects, and the
40    /// classifier recognizes an opener/auxiliary/function word in any seeded
41    /// language (surfaces are script-distinct, so languages never collide).
42    languages: BTreeMap<String, LanguageLexicon>,
43    tier: TierCurve,
44}
45
46/// The per-language vocabulary and grammar role sets lifted from the seed data.
47#[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    /// The frequency vocabulary for `language`, or an empty slice when the
57    /// language is not seeded.
58    fn words_for(&self, language: &str) -> &[QuestionWord] {
59        self.languages
60            .get(language)
61            .map_or(&[], |lexicon| lexicon.words.as_slice())
62    }
63
64    /// The frequency vocabulary for the default language (`en`).
65    fn default_words(&self) -> &[QuestionWord] {
66        self.words_for(DEFAULT_LANGUAGE)
67    }
68
69    /// Whether `token` fills `role` in *any* seeded language. The classifier is
70    /// language-agnostic: a Russian opener is recognized exactly like an English
71    /// one, because seeded surfaces never overlap across scripts.
72    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/// The frequency-tier selection curve: how large a slice of the ranked vocabulary
92/// a candidate of a given word count may draw from. Stored in the lexicon's
93/// `tier_policy` record so the "top 10%, then halve" rule is reviewable data, not a
94/// magic number in code.
95#[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    /// The basis-point fraction of the ranked vocabulary a `word_count`-word
105    /// candidate may draw from: the base tier up to [`Self::halving_start_word_count`]
106    /// words, then halved for each additional word (capped at [`Self::max_halvings`]).
107    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
120/// The question-generation lexicon, parsed once from the embedded link data.
121fn 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/// A read-only view of the seed lexicon the generator reads, so a grounding test
204/// can pin the data to the behavior it drives (R13) without the generator having
205/// to expose its internal tables.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct QuestionLexiconSummary {
208    /// The language tag this summary describes (`en`, `ru`, `hi`, `zh`).
209    pub language: String,
210    /// Ranked vocabulary surfaces, most frequent first (ties broken alphabetically).
211    pub vocabulary: Vec<String>,
212    /// `interrogative_opener` role members, sorted.
213    pub interrogative_openers: Vec<String>,
214    /// `auxiliary_opener` role members, sorted.
215    pub auxiliary_openers: Vec<String>,
216    /// `function_word` role members, sorted.
217    pub function_words: Vec<String>,
218    /// Base frequency tier in basis points (1000 bp = the top 10%).
219    pub tier_base_basis_points: usize,
220    /// Minimum number of ranked words a tier may shrink to.
221    pub tier_minimum_ranked_words: usize,
222}
223
224/// Summarize the seed lexicon the default generator reads (the `en` language).
225/// Exposed for the issue-#527 grounding test.
226#[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/// Summarize the seed lexicon for a specific `language`.
233///
234/// Returns `None` when that language is not seeded. Every supported language is
235/// grounded to its behavior by the issue-#527 tests, so a one-language regression
236/// cannot land silently.
237#[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/// How the generator restricts the ranked vocabulary as candidates grow.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum FrequencySelectionPolicy {
321    /// The default: draw from a frequency tier that shrinks with word count
322    /// (top [`TierCurve::base_basis_points`], halving per extra word).
323    FrequencyTiers,
324    /// Draw from the whole ranked vocabulary regardless of word count.
325    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        // The default vocabulary is the frequency-ranked word list from the seed
340        // lexicon — no words are hardcoded here.
341        Self::for_language(DEFAULT_LANGUAGE)
342    }
343}
344
345impl QuestionGenerationConfig {
346    /// Build a config from a seeded `language`'s frequency vocabulary. The
347    /// enumeration, tiering, and classification logic is language-agnostic, so
348    /// selecting a language changes only which words feed the stream. Falls back
349    /// to the default language when `language` is not seeded.
350    #[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
708/// Whether `token` is an interrogative opener (a wh-word). Reads the
709/// `interrogative_opener` role from the seed lexicon; never a hardcoded list.
710fn 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
721/// Whether `token` is an auxiliary/modal opener. Reads the `auxiliary_opener`
722/// role from the seed lexicon; never a hardcoded list.
723fn is_auxiliary_opener(token: &str) -> bool {
724    question_lexicon()
725        .any_language_has_role(&token.to_ascii_lowercase(), GrammarRole::AuxiliaryOpener)
726}
727
728/// Whether `token` carries standalone content — anything that is neither an
729/// interrogative opener, an auxiliary opener, nor a closed-class `function_word`
730/// (all three role sets come from the seed lexicon).
731fn 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}