use std::fmt::Write as _;
use std::sync::OnceLock;
use crate::question_generation::{
generated_question_answers, GeneratedQuestionClass, LogicalMeaningClass, QuestionAcceptance,
QuestionGenerationConfig, QuestionGenerator, QuestionGrammarClass,
};
pub const QUESTION_CATALOG_PATH: &str = "question-catalog.lino";
const CATALOG_CANDIDATE_COUNT: usize = 12;
const CATALOG_ANSWER_COUNT: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogCandidate {
pub text: String,
pub word_count: usize,
pub grammar: &'static str,
pub logical_meaning: &'static str,
pub class: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogAnswer {
pub question: String,
pub intent: String,
pub confidence: f32,
pub answer: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuestionCatalog {
pub vocabulary_size: usize,
pub candidates: Vec<CatalogCandidate>,
pub answered: Vec<CatalogAnswer>,
}
impl QuestionCatalog {
#[must_use]
pub fn answer_for(&self, question: &str) -> Option<&CatalogAnswer> {
let needle = normalise_question(question);
self.answered
.iter()
.find(|answered| normalise_question(&answered.question) == needle)
}
}
fn cached_catalog() -> &'static QuestionCatalog {
static CATALOG: OnceLock<QuestionCatalog> = OnceLock::new();
CATALOG.get_or_init(build_catalog)
}
fn build_catalog() -> QuestionCatalog {
let base_config = QuestionGenerationConfig::default().with_all_ranked_words();
let vocabulary_size = base_config.words().len();
let candidate_config = base_config
.clone()
.with_acceptance(QuestionAcceptance::AnyQuestionLike);
let candidates = QuestionGenerator::new(candidate_config)
.take(CATALOG_CANDIDATE_COUNT)
.map(|question| CatalogCandidate {
text: question.text.clone(),
word_count: question.word_count,
grammar: grammar_slug(question.grammar),
logical_meaning: logical_meaning_slug(question.logical_meaning),
class: class_slug(question.class),
})
.collect();
let answered = generated_question_answers(base_config)
.take(CATALOG_ANSWER_COUNT)
.map(|pair| CatalogAnswer {
question: pair.question.text.clone(),
intent: pair.answer.intent.clone(),
confidence: round_confidence(pair.answer.confidence),
answer: collapse_whitespace(&pair.answer.answer),
})
.collect();
QuestionCatalog {
vocabulary_size,
candidates,
answered,
}
}
pub const QUESTION_CATALOG_TASK: &str =
"Generate all possible questions from smallest to largest, drawing words from the \
frequency-tiered vocabulary, classify each candidate as grammatical or not and, \
among the grammatical ones, logically meaningful or not, then answer the meaningful \
ones and record the whole question catalog in Links Notation.";
const QUESTION_CATALOG_KEYWORDS: [&str; 5] = [
"question catalog",
"all possible questions",
"generate every possible question",
"generate all questions",
"enumerate questions",
];
#[must_use]
pub fn is_question_catalog_task(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
QUESTION_CATALOG_KEYWORDS
.iter()
.any(|keyword| lower.contains(keyword))
|| ((lower.contains("generate") || lower.contains("enumerate"))
&& lower.contains("question")
&& lower.contains("answer"))
}
#[must_use]
pub fn render_document() -> String {
let catalog = cached_catalog();
let mut out = String::from("question_catalog\n");
out.push_str(" record_type \"question_catalog\"\n");
out.push_str(" intent \"generate_all_possible_questions\"\n");
let _ = writeln!(out, " vocabulary_size \"{}\"", catalog.vocabulary_size);
let _ = writeln!(out, " candidate_count \"{}\"", catalog.candidates.len());
let _ = writeln!(out, " answered_count \"{}\"", catalog.answered.len());
for candidate in &catalog.candidates {
out.push_str(" candidate\n");
field(&mut out, "text", &candidate.text);
let _ = writeln!(out, " word_count \"{}\"", candidate.word_count);
field(&mut out, "grammar", candidate.grammar);
field(&mut out, "logical_meaning", candidate.logical_meaning);
field(&mut out, "class", candidate.class);
}
for answered in &catalog.answered {
out.push_str(" answered\n");
field(&mut out, "question", &answered.question);
field(&mut out, "intent", &answered.intent);
let _ = writeln!(out, " confidence \"{:.3}\"", answered.confidence);
field(&mut out, "answer", &answered.answer);
}
format!("{}\n", out.trim_end())
}
#[must_use]
pub fn catalog() -> QuestionCatalog {
cached_catalog().clone()
}
#[must_use]
pub fn final_answer(document: &str) -> String {
let catalog = cached_catalog();
format!(
"Generated the question catalog from a frequency-tiered vocabulary of {vocabulary} words: \
classified {candidates} candidates smallest-first into the four-way distinction \
(grammatical-and-meaningful, grammatical open-slot, fragment, ungrammatical) and answered \
the first {answers} grammatical-and-meaningful questions with the deterministic engine. \
The answered questions are a reviewable recall table — a repeated question is answered from \
the catalog instead of re-derived — and nothing changes solver behaviour without the \
human-gated learning ledger. Neural inference is not used; every candidate, class, and \
answer is a deterministic function of the lexicon.\n\n\
Generated document ({QUESTION_CATALOG_PATH}):\n\n{document}",
vocabulary = catalog.vocabulary_size,
candidates = catalog.candidates.len(),
answers = catalog.answered.len(),
document = document.trim_end(),
)
}
fn field(out: &mut String, name: &str, value: &str) {
let _ = writeln!(out, " {name} \"{}\"", escape_value(value));
}
fn escape_value(value: &str) -> String {
collapse_whitespace(value).replace('"', "'")
}
fn collapse_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn round_confidence(confidence: f32) -> f32 {
(confidence * 1000.0).round() / 1000.0
}
fn normalise_question(question: &str) -> String {
collapse_whitespace(question).to_ascii_lowercase()
}
const fn grammar_slug(grammar: QuestionGrammarClass) -> &'static str {
match grammar {
QuestionGrammarClass::Grammatical => "grammatical",
QuestionGrammarClass::Fragment => "fragment",
QuestionGrammarClass::Ungrammatical => "ungrammatical",
}
}
const fn logical_meaning_slug(meaning: LogicalMeaningClass) -> &'static str {
match meaning {
LogicalMeaningClass::Meaningful => "meaningful",
LogicalMeaningClass::OpenSlot => "open_slot",
LogicalMeaningClass::NotMeaningful => "not_meaningful",
}
}
const fn class_slug(class: GeneratedQuestionClass) -> &'static str {
match class {
GeneratedQuestionClass::GrammaticalAndMeaningful => "grammatical_and_meaningful",
GeneratedQuestionClass::GrammaticalOpenSlot => "grammatical_open_slot",
GeneratedQuestionClass::Fragment => "fragment",
GeneratedQuestionClass::Ungrammatical => "ungrammatical",
}
}