use std::{
collections::HashSet,
hash::{Hash, Hasher},
};
use crate::card::Card;
pub const NUM_OPTIONS: usize = 4;
#[derive(Debug)]
pub struct ChoiceQuestion {
pub options: Vec<String>,
pub correct: usize,
}
fn answer_text(card: &Card) -> String {
card.back.join("\n")
}
pub fn seed_for(card_id: &str, appearance: u32) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
card_id.hash(&mut hasher);
appearance.hash(&mut hasher);
hasher.finish()
}
fn distinct_distractors(card: &Card, ai_distractors: &[String]) -> Vec<String> {
let needed = NUM_OPTIONS - 1;
let mut seen: HashSet<String> = HashSet::new();
seen.insert(answer_text(card));
let mut chosen: Vec<String> = Vec::new();
for option in ai_distractors {
if chosen.len() == needed {
break;
}
let trimmed = option.trim();
if !trimmed.is_empty() && seen.insert(trimmed.to_string()) {
chosen.push(trimmed.to_string());
}
}
chosen
}
pub fn build(card: &Card, seed: u64, ai_distractors: &[String]) -> Option<ChoiceQuestion> {
let mut options = distinct_distractors(card, ai_distractors);
if options.len() < NUM_OPTIONS - 1 {
return None;
}
let correct_text = answer_text(card);
options.push(correct_text.clone());
let mut rng = Rng::new(seed);
shuffle(&mut options, &mut rng);
let correct = options.iter().position(|t| *t == correct_text)?;
Some(ChoiceQuestion { options, correct })
}
pub fn can_build(card: &Card, ai_distractors: &[String]) -> bool {
distinct_distractors(card, ai_distractors).len() == NUM_OPTIONS - 1
}
pub fn recognition_question(
card: &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, seed, ai)
}
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 ai(distractors: &[&str]) -> Vec<String> {
distractors.iter().map(|s| s.to_string()).collect()
}
#[test]
fn question_has_four_options_with_correct_exactly_once() {
let c = card(1, "alpha");
let d = ai(&["beta", "gamma", "delta"]);
let q = build(&c, 42, &d).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 fewer_than_three_distractors_yields_none() {
let c = card(1, "alpha");
assert!(build(&c, 42, &ai(&["beta", "gamma"])).is_none());
}
#[test]
fn duplicate_distractors_count_once() {
let c = card(1, "alpha");
assert!(build(&c, 42, &ai(&["beta", "beta", "alpha"])).is_none());
}
#[test]
fn an_ai_distractor_equal_to_the_answer_is_dropped() {
let c = card(1, "alpha");
let d = ai(&["alpha", "beta", "gamma", "delta"]);
for seed in 0..10 {
let q = build(&c, seed, &d).unwrap();
assert_eq!(1, q.options.iter().filter(|o| *o == "alpha").count());
}
}
#[test]
fn same_seed_same_question() {
let c = card(1, "alpha");
let d = ai(&["beta", "gamma", "delta"]);
let a = build(&c, 7, &d).unwrap();
let b = build(&c, 7, &d).unwrap();
assert_eq!(a.options, b.options);
assert_eq!(a.correct, b.correct);
}
#[test]
fn different_seeds_vary_the_options() {
let c = card(1, "alpha");
let d = ai(&["beta", "gamma", "delta"]);
let orders: HashSet<Vec<String>> = (0..10)
.map(|seed| build(&c, seed, &d).unwrap().options)
.collect();
assert!(orders.len() > 1, "options never varied across seeds");
}
#[test]
fn multi_line_answers_become_one_option() {
let c = card(1, "line a\nline b");
let d = ai(&["x", "y", "z"]);
let q = build(&c, 1, &d).unwrap();
assert_eq!("line a\nline b", q.options[q.correct]);
}
#[test]
fn recognition_question_needs_atomic_answer_and_full_ai_distractors() {
let c = card(1, "alpha");
let d = ai(&["w1", "w2", "w3"]);
let q = recognition_question(&c, 1, Some(&d)).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 c = card(1, "alpha");
assert!(recognition_question(&c, 1, Some(&ai(&["w1", "w2"]))).is_none());
assert!(recognition_question(&c, 1, None).is_none());
}
#[test]
fn recognition_question_rejects_multi_line_answers() {
let c = card(1, "line a\nline b");
let d = ai(&["w1", "w2", "w3"]);
assert!(recognition_question(&c, 1, Some(&d)).is_none());
}
#[test]
fn same_appearance_seed_is_stable_but_later_appearances_vary_the_order() {
let c = card(1, "alpha");
let d = ai(&["beta", "gamma", "delta"]);
let id = "q42";
let first = build(&c, seed_for(id, 1), &d).unwrap();
let first_again = build(&c, seed_for(id, 1), &d).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(&c, seed_for(id, appearance), &d).unwrap())
.any(|q| q.options != first.options);
assert!(
later_orders_differ,
"no later appearance ever varied the order"
);
}
}