use std::collections::HashSet;
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 dissimilarity(a: &str, b: &str) -> usize {
let penalty = if is_numeric(a) != is_numeric(b) {
1000
} else {
0
};
penalty + strsim::levenshtein(a, b)
}
pub fn build(card: &Card, pool: &[Card], seed: u64) -> Option<ChoiceQuestion> {
let correct_text = answer_text(card);
let mut seen = HashSet::new();
let mut candidates: Vec<String> = Vec::new();
for other in pool {
if other.subject == card.subject && other.line == card.line {
continue;
}
let text = answer_text(other);
if text == correct_text || text.trim().is_empty() {
continue;
}
if seen.insert(text.clone()) {
candidates.push(text);
}
}
let needed = NUM_OPTIONS - 1;
if candidates.len() < needed {
return None;
}
candidates.sort_by_key(|text| dissimilarity(&correct_text, text));
candidates.truncate(SAMPLE_POOL);
let mut rng = Rng::new(seed);
shuffle(&mut candidates, &mut rng);
candidates.truncate(needed);
let mut options = candidates;
options.push(correct_text.clone());
shuffle(&mut options, &mut rng);
let correct = options.iter().position(|t| *t == correct_text).unwrap();
Some(ChoiceQuestion { options, correct })
}
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).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).is_none());
}
#[test]
fn duplicate_answers_count_once() {
let cards = pool(&["alpha", "beta", "beta", "alpha", "gamma"]);
assert!(build(&cards[0], &cards, 42).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).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).unwrap();
for option in &q.options {
assert!(is_numeric(option), "word distractor {option:?} for a year");
}
}
}
#[test]
fn same_seed_same_question() {
let cards = pool(&["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]);
let a = build(&cards[0], &cards, 7).unwrap();
let b = build(&cards[0], &cards, 7).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).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).unwrap();
assert_eq!("line a\nline b", q.options[q.correct]);
}
}