From fcd6a53ccac62ad28d26872cb46263617f60159c Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Mon, 13 Jul 2026 10:54:42 +0100 Subject: [PATCH 1/4] test: simulate_workload_varies --- rslib/src/scheduler/fsrs/simulator.rs | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 13da583bd56..12cfeba74cd 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -363,3 +363,46 @@ impl Card { } } } + +#[cfg(test)] +mod test { + use std::collections::HashSet; + + use anki_proto::scheduler::SimulateFsrsReviewRequest; + + use super::*; + + #[test] + fn simulate_workload_varies() { + let mut col = Collection::new(); + + let req = SimulateFsrsReviewRequest { + params: vec![], // leave empty so fsrs fills defaults in tests + desired_retention: 0.85f32, + deck_size: 50u32, + days_to_simulate: 20u32, + new_limit: 5u32, + review_limit: 1000u32, + max_interval: 365u32, + search: "".to_string(), + new_cards_ignore_review_limit: false, + easy_days_percentages: vec![0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32], + review_order: ReviewCardOrder::Random as i32, + suspend_after_lapse_count: Some(3u32), + historical_retention: 0.9f32, + learning_step_count: 3u32, + relearning_step_count: 2u32, + }; + + let resp = col.simulate_workload(req).unwrap(); + + // costs are f32; compare bit representations to allow hashing + let cost_variants: HashSet = resp.cost.values().map(|c| c.to_bits()).collect(); + + assert!( + cost_variants.len() > 1, + "expected varying workload costs, instead got {:?}", + resp.cost + ); + } +} From 80aabc46667ef3911a5caacda577dd734bb2a354 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Tue, 14 Jul 2026 16:02:08 +0100 Subject: [PATCH 2/4] Apply patch --------- Co-authored-by: Fernando Lins --- rslib/src/scheduler/fsrs/simulator.rs | 88 +++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 12cfeba74cd..ba2dad99c2a 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -366,17 +366,12 @@ impl Card { #[cfg(test)] mod test { - use std::collections::HashSet; - use anki_proto::scheduler::SimulateFsrsReviewRequest; use super::*; - #[test] - fn simulate_workload_varies() { - let mut col = Collection::new(); - - let req = SimulateFsrsReviewRequest { + fn base_request() -> SimulateFsrsReviewRequest { + SimulateFsrsReviewRequest { params: vec![], // leave empty so fsrs fills defaults in tests desired_retention: 0.85f32, deck_size: 50u32, @@ -392,17 +387,84 @@ mod test { historical_retention: 0.9f32, learning_step_count: 3u32, relearning_step_count: 2u32, - }; + } + } - let resp = col.simulate_workload(req).unwrap(); + #[test] + fn simulate_workload_varies() { + let mut col = Collection::new(); - // costs are f32; compare bit representations to allow hashing - let cost_variants: HashSet = resp.cost.values().map(|c| c.to_bits()).collect(); + let resp = col.simulate_workload(base_request()).unwrap(); + + // Regression guard for #5101: the workload simulation must apply the per-card + // desired retention, so cost has to grow with the target retention. A plain + // "the values differ" check is not enough here: even with the bug present the + // costs vary due to simulation noise, so we assert the actual trend instead by + // comparing the low- and high-retention buckets. + let cost = |dr: u32| resp.cost[&dr]; + let low_avg = (70..=79).map(cost).sum::() / 10.0; + let high_avg = (90..=99).map(cost).sum::() / 10.0; assert!( - cost_variants.len() > 1, - "expected varying workload costs, instead got {:?}", + high_avg > low_avg * 1.2, + "expected workload cost to grow with desired retention, instead got \ + low_avg={low_avg}, high_avg={high_avg}, cost={:?}", resp.cost ); } + + #[test] + fn request_config_uses_requested_retention() -> Result<()> { + // Regression guard for the other half of #5101 ("for normal simulator too"): + // simulate_request_to_config must apply the *requested* desired retention to + // every card, ignoring each card's individually stored value. This helper feeds + // the normal simulator, the workload simulator and optimal-retention + // computation, so pinning it here covers all three callers without simulation + // noise. + let mut col = Collection::new(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, DeckId(1))?; + let mut card = col + .storage + .all_cards_of_note(note.id)? + .into_iter() + .next() + .unwrap(); + card.ctype = CardType::Review; + card.queue = CardQueue::Review; + card.interval = 100; + card.memory_state = Some(FsrsMemoryState { + stability: 100.0, + difficulty: 5.0, + }); + // Deliberately differs from the requested retention below. + card.desired_retention = Some(0.70); + card.decay = Some(0.2); + col.storage.update_card(&card)?; + + let req = SimulateFsrsReviewRequest { + desired_retention: 0.95f32, + // Isolate the existing review card: no synthetic new cards. + deck_size: 0u32, + new_limit: 0u32, + ..base_request() + }; + + let (_config, cards) = col.simulate_request_to_config(&req)?; + + assert!( + !cards.is_empty(), + "expected the review card to be converted" + ); + assert!( + cards + .iter() + .all(|c| (c.desired_retention - 0.95).abs() < 1e-6), + "converted cards must use the requested retention (0.95), instead got {:?}", + cards.iter().map(|c| c.desired_retention).collect_vec() + ); + + Ok(()) + } } From 09e118bc81405ea2482bf46d8835ec8173b19bc5 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Tue, 14 Jul 2026 16:06:14 +0100 Subject: [PATCH 3/4] added: simulate_uses_dr --- rslib/src/scheduler/fsrs/simulator.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index ba2dad99c2a..6eb0dec246e 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -369,6 +369,7 @@ mod test { use anki_proto::scheduler::SimulateFsrsReviewRequest; use super::*; + use crate::services::NotesService; fn base_request() -> SimulateFsrsReviewRequest { SimulateFsrsReviewRequest { @@ -467,4 +468,22 @@ mod test { Ok(()) } + + #[test] + fn simulate_uses_dr() { + let mut col = Collection::new(); + col.set_config_bool(BoolKey::Fsrs, true, false).unwrap(); + + let note = crate::tests::NoteAdder::basic(&mut col).add(&mut col); + let cids = col.cards_of_note(note.id.into()).unwrap().cids; + let cids = cids.into_iter().map(CardId).collect_vec(); + col.grade_now(&cids, 3).unwrap(); + + let mut req = base_request(); + req.deck_size = 0; // ??????? TODO: FIXME should be 1 + + let (_, cards) = col.simulate_request_to_config(&req).unwrap(); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0].desired_retention, 0.85f32) + } } From 0105a8a6adb243404d51e944af0478e376f0dd1f Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Wed, 15 Jul 2026 11:47:42 +0100 Subject: [PATCH 4/4] Update rslib/src/scheduler/fsrs/simulator.rs --- rslib/src/scheduler/fsrs/simulator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 6eb0dec246e..e83b0938392 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -480,7 +480,7 @@ mod test { col.grade_now(&cids, 3).unwrap(); let mut req = base_request(); - req.deck_size = 0; // ??????? TODO: FIXME should be 1 + req.deck_size = 0; let (_, cards) = col.simulate_request_to_config(&req).unwrap(); assert_eq!(cards.len(), 1);