use std::{
collections::HashSet,
hash::{Hash, Hasher},
};
use crate::card::Card;
pub const NUM_OPTIONS: usize = 4;
const SAMPLE_POOL: usize = (NUM_OPTIONS - 1) * 2;
#[derive(Debug)]
pub struct ChoiceQuestion {
pub options: Vec<String>,
pub correct: usize,
}
fn answer_text(card: &Card) -> String {
card.back.join("\n")
}
fn is_numeric(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || ",.%/- ".contains(c))
}
fn numeric_shape(s: &str) -> Option<(usize, String)> {
if !is_numeric(s) {
return None;
}
let digits = s.chars().filter(char::is_ascii_digit).count();
let mut seps: Vec<char> = s.chars().filter(|c| !c.is_ascii_digit()).collect();
seps.sort_unstable();
seps.dedup();
Some((digits, seps.into_iter().collect()))
}
fn dissimilarity(a: &str, b: &str) -> usize {
let penalty = if numeric_shape(a) != numeric_shape(b) {
1000
} else {
0
};
penalty + strsim::levenshtein(a, b)
}
pub fn seed_for(card_id: u64, appearance: u32) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
card_id.hash(&mut hasher);
appearance.hash(&mut hasher);
hasher.finish()
}
pub fn build(
card: &Card,
pool: &[Card],
seed: u64,
ai_distractors: Option<&[String]>,
) -> Option<ChoiceQuestion> {
let correct_text = answer_text(card);
let needed = NUM_OPTIONS - 1;
let mut rng = Rng::new(seed);
let mut seen: HashSet<String> = HashSet::new();
seen.insert(correct_text.clone());
let mut chosen: Vec<String> = Vec::new();
for option in ai_distractors.unwrap_or(&[]) {
if chosen.len() == needed {
break;
}
let trimmed = option.trim();
if !trimmed.is_empty() && seen.insert(trimmed.to_string()) {
chosen.push(trimmed.to_string());
}
}
if chosen.len() < needed {
let mut candidates = offline_candidates(card, pool, &seen);
candidates.sort_by_key(|text| dissimilarity(&correct_text, text));
candidates.truncate(SAMPLE_POOL);
shuffle(&mut candidates, &mut rng);
for candidate in candidates {
if chosen.len() == needed {
break;
}
chosen.push(candidate);
}
}
if chosen.len() < needed {
return None;
}
let mut options = chosen;
options.push(correct_text.clone());
shuffle(&mut options, &mut rng);
let correct = options.iter().position(|t| *t == correct_text)?;
Some(ChoiceQuestion { options, correct })
}
pub fn recognition_question(
card: &Card,
pool: &[Card],
seed: u64,
ai_distractors: Option<&[String]>,
) -> Option<ChoiceQuestion> {
if card.back.len() != 1 {
return None;
}
let ai = ai_distractors.filter(|d| d.len() >= NUM_OPTIONS - 1)?;
build(card, pool, seed, Some(ai))
}
fn offline_candidates(card: &Card, pool: &[Card], exclude: &HashSet<String>) -> Vec<String> {
let mut seen = exclude.clone();
let mut candidates = Vec::new();
for other in pool {
if other.subject == card.subject && other.line == card.line {
continue;
}
let text = answer_text(other);
if text.trim().is_empty() {
continue;
}
if seen.insert(text.clone()) {
candidates.push(text);
}
}
candidates
}
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
fn shuffle<T>(items: &mut [T], rng: &mut Rng) {
for i in (1..items.len()).rev() {
items.swap(i, rng.below(i + 1));
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn card(line: usize, back: &str) -> Card {
Card::plain(
Arc::from("deck.txt"),
format!("front {line}"),
back.split('\n').map(String::from).collect(),
None,
line,
)
}
fn pool(backs: &[&str]) -> Vec<Card> {
backs
.iter()
.enumerate()
.map(|(i, b)| card(i + 1, b))
.collect()
}
#[test]
fn question_has_four_options_with_correct_exactly_once() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let q = build(&cards[0], &cards, 42, None).unwrap();
assert_eq!(NUM_OPTIONS, q.options.len());
assert_eq!(1, q.options.iter().filter(|o| *o == "alpha").count());
assert_eq!("alpha", q.options[q.correct]);
}
#[test]
fn too_small_pool_yields_none() {
let cards = pool(&["alpha", "beta", "gamma"]);
assert!(build(&cards[0], &cards, 42, None).is_none());
}
#[test]
fn duplicate_answers_count_once() {
let cards = pool(&["alpha", "beta", "beta", "alpha", "gamma"]);
assert!(build(&cards[0], &cards, 42, None).is_none());
}
#[test]
fn cloze_siblings_are_never_distractors() {
let mut cards = Vec::new();
for back in ["hole one", "hole two", "hole three"] {
cards.push(card(1, back));
}
for (i, back) in ["w", "x", "y", "z"].iter().enumerate() {
cards.push(card(10 + i, back));
}
for seed in 0..20 {
let q = build(&cards[0], &cards, seed, None).unwrap();
assert!(!q.options.contains(&"hole two".to_string()));
assert!(!q.options.contains(&"hole three".to_string()));
}
}
#[test]
fn numeric_answers_get_numeric_distractors() {
let cards = pool(&[
"1158",
"1240",
"1632",
"1806",
"1918",
"1972",
"2005",
"Heinrich der Löwe",
"Marienplatz",
"Isar",
]);
for seed in 0..20 {
let q = build(&cards[0], &cards, seed, None).unwrap();
for option in &q.options {
assert!(is_numeric(option), "word distractor {option:?} for a year");
}
}
}
#[test]
fn a_decimal_never_competes_with_years() {
let cards = pool(&[
"1589", "1158", "1789", "1638", "1568", "1328", "1807", "1,5",
]);
for seed in 0..30 {
let q = build(&cards[0], &cards, seed, None).unwrap();
assert!(
!q.options.contains(&"1,5".to_string()),
"1,5 sampled as a year distractor (seed {seed})"
);
}
}
#[test]
fn same_seed_same_question() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]);
let a = build(&cards[0], &cards, 7, None).unwrap();
let b = build(&cards[0], &cards, 7, None).unwrap();
assert_eq!(a.options, b.options);
assert_eq!(a.correct, b.correct);
}
#[test]
fn different_seeds_vary_the_options() {
let cards = pool(&[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
]);
let questions: HashSet<Vec<String>> = (0..10)
.map(|seed| build(&cards[0], &cards, seed, None).unwrap().options)
.collect();
assert!(questions.len() > 1, "options never varied across seeds");
}
#[test]
fn multi_line_answers_become_one_option() {
let cards = pool(&["line a\nline b", "x", "y", "z", "w"]);
let q = build(&cards[0], &cards, 1, None).unwrap();
assert_eq!("line a\nline b", q.options[q.correct]);
}
#[test]
fn ai_distractors_are_used_even_when_the_pool_is_too_thin() {
let cards = pool(&["alpha"]);
let ai = [
"wrong one".to_string(),
"wrong two".to_string(),
"wrong three".to_string(),
];
let q = build(&cards[0], &cards, 1, Some(&ai)).unwrap();
assert_eq!(NUM_OPTIONS, q.options.len());
assert_eq!("alpha", q.options[q.correct]);
assert!(q.options.contains(&"wrong one".to_string()));
}
#[test]
fn ai_distractors_are_topped_up_from_the_pool_when_short() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let ai = ["ai wrong".to_string()];
let q = build(&cards[0], &cards, 3, Some(&ai)).unwrap();
assert_eq!(NUM_OPTIONS, q.options.len());
assert!(q.options.contains(&"ai wrong".to_string()));
assert_eq!("alpha", q.options[q.correct]);
}
#[test]
fn an_ai_distractor_equal_to_the_answer_is_dropped() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let ai = ["alpha".to_string(), "ai wrong".to_string()];
for seed in 0..10 {
let q = build(&cards[0], &cards, seed, Some(&ai)).unwrap();
assert_eq!(1, q.options.iter().filter(|o| *o == "alpha").count());
}
}
#[test]
fn recognition_question_needs_atomic_answer_and_full_ai_distractors() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let ai = ["w1".to_string(), "w2".to_string(), "w3".to_string()];
let q = recognition_question(&cards[0], &cards, 1, Some(&ai)).unwrap();
assert_eq!(NUM_OPTIONS, q.options.len());
assert_eq!("alpha", q.options[q.correct]);
}
#[test]
fn recognition_question_rejects_too_few_ai_distractors() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let ai = ["w1".to_string(), "w2".to_string()];
assert!(recognition_question(&cards[0], &cards, 1, Some(&ai)).is_none());
assert!(recognition_question(&cards[0], &cards, 1, None).is_none());
}
#[test]
fn same_appearance_seed_is_stable_but_later_appearances_vary_the_order() {
let cards = pool(&[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
]);
let id = 42;
let first = build(&cards[0], &cards, seed_for(id, 1), None).unwrap();
let first_again = build(&cards[0], &cards, seed_for(id, 1), None).unwrap();
assert_eq!(
first.options, first_again.options,
"the same appearance must not reshuffle mid-poll"
);
let later_orders_differ = (2..12)
.map(|appearance| build(&cards[0], &cards, seed_for(id, appearance), None).unwrap())
.any(|q| q.options != first.options);
assert!(
later_orders_differ,
"no later appearance ever varied the order"
);
}
#[test]
fn recognition_question_rejects_multi_line_answers() {
let cards = pool(&["line a\nline b", "x", "y", "z", "w"]);
let ai = ["w1".to_string(), "w2".to_string(), "w3".to_string()];
assert!(recognition_question(&cards[0], &cards, 1, Some(&ai)).is_none());
}
}