implement fuzzing

Notes:

- The fuzz seed is now derived from the card id and # of reps, so
if a card is undone and done again, the same fuzz will be used.
- The intervals shown on the answer buttons now include the fuzz, instead
of hiding it from the user. This will prevent questions about due dates
being different to what was shown on the buttons, but will create
questions about due dates being different for cards with the same
interval, and some people may find it distracting for learning cards.
The new approach is easier to reason about, but time will tell
whether it's a net gain or not.
- The env var we were using to shift the clock away from rollover for
unit tests has been repurposed to also disable fuzzing, which simplifies
the tests.
- Cards in filtered decks without scheduling now have the preview delay
fuzzed.
- Sub-day learning cards are mostly fuzzed like before, but will apply
the up-to-5-minutes of fuzz regardless of the time of day.
- The answer buttons now round minute values, as the fuzz on short
intervals is distracting.
This commit is contained in:
Damien Elmes 2021-02-22 19:46:57 +10:00
parent e1e552ff93
commit 97300a16bf
10 changed files with 136 additions and 61 deletions

View file

@ -3,7 +3,7 @@ import sys
import pytest import pytest
os.environ["SHIFT_CLOCK_HACK"] = "1" os.environ["ANKI_TEST_MODE"] = "1"
if __name__ == "__main__": if __name__ == "__main__":
folder = os.path.join(os.path.dirname(__file__), "..", "tests") folder = os.path.join(os.path.dirname(__file__), "..", "tests")

View file

@ -67,6 +67,11 @@ impl AnswerContext {
lapse_multiplier: self.config.inner.lapse_multiplier, lapse_multiplier: self.config.inner.lapse_multiplier,
minimum_lapse_interval: self.config.inner.minimum_lapse_interval, minimum_lapse_interval: self.config.inner.minimum_lapse_interval,
in_filtered_deck: self.deck.is_filtered(), in_filtered_deck: self.deck.is_filtered(),
preview_step: if let DeckKind::Filtered(deck) = &self.deck.kind {
deck.preview_delay
} else {
0
},
} }
} }
@ -540,7 +545,7 @@ impl Collection {
config: self.deck_config_for_card(card)?, config: self.deck_config_for_card(card)?,
timing, timing,
now: TimestampSecs::now(), now: TimestampSecs::now(),
fuzz_seed: None, fuzz_seed: get_fuzz_seed(card),
}) })
} }
@ -552,3 +557,13 @@ impl Collection {
Ok(current.next_states(&state_ctx)) Ok(current.next_states(&state_ctx))
} }
} }
/// Return a consistent seed for a given card at a given number of reps.
/// If in test environment, disable fuzzing.
fn get_fuzz_seed(card: &Card) -> Option<u64> {
if *crate::timestamp::TESTING {
None
} else {
Some((card.id.0 as u64).wrapping_add(card.reps as u64))
}
}

View file

@ -28,7 +28,7 @@ impl FilteredState {
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates {
match self { match self {
FilteredState::Preview(state) => state.next_states(), FilteredState::Preview(state) => state.next_states(ctx),
FilteredState::Rescheduling(state) => state.next_states(ctx), FilteredState::Rescheduling(state) => state.next_states(ctx),
} }
} }

View file

@ -33,20 +33,20 @@ impl LearnState {
fn answer_again(self, ctx: &StateContext) -> LearnState { fn answer_again(self, ctx: &StateContext) -> LearnState {
LearnState { LearnState {
remaining_steps: ctx.steps.remaining_for_failed(), remaining_steps: ctx.steps.remaining_for_failed(),
scheduled_secs: ctx.steps.again_delay_secs_learn(), scheduled_secs: ctx.with_learning_fuzz(ctx.steps.again_delay_secs_learn()),
} }
} }
fn answer_hard(self, ctx: &StateContext) -> CardState { fn answer_hard(self, ctx: &StateContext) -> CardState {
if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) { if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) {
LearnState { LearnState {
scheduled_secs: hard_delay, scheduled_secs: ctx.with_learning_fuzz(hard_delay),
..self ..self
} }
.into() .into()
} else { } else {
ReviewState { ReviewState {
scheduled_days: ctx.graduating_interval_good, scheduled_days: ctx.fuzzed_graduating_interval_good(),
..Default::default() ..Default::default()
} }
.into() .into()
@ -57,12 +57,12 @@ impl LearnState {
if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) { if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) {
LearnState { LearnState {
remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps), remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),
scheduled_secs: good_delay, scheduled_secs: ctx.with_learning_fuzz(good_delay),
} }
.into() .into()
} else { } else {
ReviewState { ReviewState {
scheduled_days: ctx.graduating_interval_good, scheduled_days: ctx.fuzzed_graduating_interval_good(),
..Default::default() ..Default::default()
} }
.into() .into()
@ -71,7 +71,7 @@ impl LearnState {
fn answer_easy(self, ctx: &StateContext) -> ReviewState { fn answer_easy(self, ctx: &StateContext) -> ReviewState {
ReviewState { ReviewState {
scheduled_days: ctx.graduating_interval_easy, scheduled_days: ctx.fuzzed_graduating_interval_easy(),
..Default::default() ..Default::default()
} }
} }

View file

@ -12,6 +12,9 @@ pub(crate) mod rescheduling_filter;
pub(crate) mod review; pub(crate) mod review;
pub(crate) mod steps; pub(crate) mod steps;
use rand::prelude::*;
use rand::rngs::StdRng;
pub use { pub use {
filtered::FilteredState, learning::LearnState, new::NewState, normal::NormalState, filtered::FilteredState, learning::LearnState, new::NewState, normal::NormalState,
preview_filter::PreviewState, relearning::RelearnState, preview_filter::PreviewState, relearning::RelearnState,
@ -75,6 +78,64 @@ pub(crate) struct StateContext<'a> {
// filtered // filtered
pub in_filtered_deck: bool, pub in_filtered_deck: bool,
pub preview_step: u32,
}
impl<'a> StateContext<'a> {
pub(crate) fn with_review_fuzz(&self, interval: f32) -> u32 {
// fixme: floor() is to match python
let interval = interval.floor();
if let Some(seed) = self.fuzz_seed {
let mut rng = StdRng::seed_from_u64(seed);
let (lower, upper) = if interval < 2.0 {
(1.0, 1.0)
} else if interval < 3.0 {
(2.0, 3.0)
} else if interval < 7.0 {
fuzz_range(interval, 0.25, 0.0)
} else if interval < 30.0 {
fuzz_range(interval, 0.15, 2.0)
} else {
fuzz_range(interval, 0.05, 4.0)
};
if lower >= upper {
lower
} else {
rng.gen_range(lower, upper)
}
} else {
interval
}
.round() as u32
}
/// Add up to 25% increase to seconds, but no more than 5 minutes.
pub(crate) fn with_learning_fuzz(&self, secs: u32) -> u32 {
if let Some(seed) = self.fuzz_seed {
let mut rng = StdRng::seed_from_u64(seed);
let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32;
if secs >= upper_exclusive {
secs
} else {
rng.gen_range(secs, upper_exclusive)
}
} else {
secs
}
}
pub(crate) fn fuzzed_graduating_interval_good(&self) -> u32 {
self.with_review_fuzz(self.graduating_interval_good as f32)
}
pub(crate) fn fuzzed_graduating_interval_easy(&self) -> u32 {
self.with_review_fuzz(self.graduating_interval_easy as f32)
}
}
fn fuzz_range(interval: f32, factor: f32, minimum: f32) -> (f32, f32) {
let delta = (interval * factor).max(minimum).max(1.0);
(interval - delta, interval + delta + 1.0)
} }
#[derive(Debug)] #[derive(Debug)]

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{IntervalKind, NextCardStates, NormalState}; use super::{IntervalKind, NextCardStates, NormalState, StateContext};
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PreviewState { pub struct PreviewState {
@ -14,10 +14,14 @@ impl PreviewState {
IntervalKind::InSecs(self.scheduled_secs) IntervalKind::InSecs(self.scheduled_secs)
} }
pub(crate) fn next_states(self) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates {
NextCardStates { NextCardStates {
current: self.into(), current: self.into(),
again: self.into(), again: PreviewState {
scheduled_secs: ctx.with_learning_fuzz(ctx.preview_step * 60),
..self
}
.into(),
hard: self.original_state.into(), hard: self.original_state.into(),
good: self.original_state.into(), good: self.original_state.into(),
easy: self.original_state.into(), easy: self.original_state.into(),

View file

@ -33,11 +33,11 @@ impl RelearnState {
} }
fn answer_again(self, ctx: &StateContext) -> CardState { fn answer_again(self, ctx: &StateContext) -> CardState {
if let Some(learn_interval) = ctx.relearn_steps.again_delay_secs_relearn() { if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() {
RelearnState { RelearnState {
learning: LearnState { learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(), remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: learn_interval, scheduled_secs: ctx.with_learning_fuzz(again_delay),
}, },
review: ReviewState { review: ReviewState {
scheduled_days: self.review.failing_review_interval(ctx), scheduled_days: self.review.failing_review_interval(ctx),
@ -52,13 +52,13 @@ impl RelearnState {
} }
fn answer_hard(self, ctx: &StateContext) -> CardState { fn answer_hard(self, ctx: &StateContext) -> CardState {
if let Some(learn_interval) = ctx if let Some(hard_delay) = ctx
.relearn_steps .relearn_steps
.hard_delay_secs(self.learning.remaining_steps) .hard_delay_secs(self.learning.remaining_steps)
{ {
RelearnState { RelearnState {
learning: LearnState { learning: LearnState {
scheduled_secs: learn_interval, scheduled_secs: ctx.with_learning_fuzz(hard_delay),
..self.learning ..self.learning
}, },
review: ReviewState { review: ReviewState {
@ -73,13 +73,13 @@ impl RelearnState {
} }
fn answer_good(self, ctx: &StateContext) -> CardState { fn answer_good(self, ctx: &StateContext) -> CardState {
if let Some(learn_interval) = ctx if let Some(good_delay) = ctx
.relearn_steps .relearn_steps
.good_delay_secs(self.learning.remaining_steps) .good_delay_secs(self.learning.remaining_steps)
{ {
RelearnState { RelearnState {
learning: LearnState { learning: LearnState {
scheduled_secs: learn_interval, scheduled_secs: ctx.with_learning_fuzz(good_delay),
remaining_steps: ctx remaining_steps: ctx
.relearn_steps .relearn_steps
.remaining_for_good(self.learning.remaining_steps), .remaining_for_good(self.learning.remaining_steps),

View file

@ -1,9 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use rand::prelude::*;
use rand::rngs::StdRng;
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
use super::{ use super::{
@ -81,11 +78,11 @@ impl ReviewState {
lapses: self.lapses + 1, lapses: self.lapses + 1,
}; };
if let Some(learn_interval) = ctx.relearn_steps.again_delay_secs_relearn() { if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() {
RelearnState { RelearnState {
learning: LearnState { learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(), remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: learn_interval, scheduled_secs: ctx.with_learning_fuzz(again_delay),
}, },
review: again_review, review: again_review,
} }
@ -143,16 +140,18 @@ impl ReviewState {
// fixme: floor() is to match python // fixme: floor() is to match python
let hard_interval = let hard_interval =
constrain_passing_interval(ctx, current_interval * hard_factor, hard_minimum); constrain_passing_interval(ctx, current_interval * hard_factor, hard_minimum, true);
let good_interval = constrain_passing_interval( let good_interval = constrain_passing_interval(
ctx, ctx,
(current_interval + (days_late / 2.0).floor()) * self.ease_factor, (current_interval + (days_late / 2.0).floor()) * self.ease_factor,
hard_interval + 1, hard_interval + 1,
true,
); );
let easy_interval = constrain_passing_interval( let easy_interval = constrain_passing_interval(
ctx, ctx,
(current_interval + days_late) * self.ease_factor * ctx.easy_multiplier, (current_interval + days_late) * self.ease_factor * ctx.easy_multiplier,
good_interval + 1, good_interval + 1,
true,
); );
(hard_interval, good_interval, easy_interval) (hard_interval, good_interval, easy_interval)
@ -169,11 +168,16 @@ impl ReviewState {
let hard_interval = { let hard_interval = {
let factor = ctx.hard_multiplier; let factor = ctx.hard_multiplier;
let half_usual = factor / 2.0; let half_usual = factor / 2.0;
constrain_passing_interval(ctx, (elapsed * factor).max(scheduled * half_usual), 0) constrain_passing_interval(
ctx,
(elapsed * factor).max(scheduled * half_usual),
0,
false,
)
}; };
let good_interval = let good_interval =
constrain_passing_interval(ctx, (elapsed * self.ease_factor).max(scheduled), 0); constrain_passing_interval(ctx, (elapsed * self.ease_factor).max(scheduled), 0, false);
let easy_interval = { let easy_interval = {
// currently flooring() f64s to match python output // currently flooring() f64s to match python output
@ -184,6 +188,7 @@ impl ReviewState {
((elapsed as f64 * self.ease_factor as f64).max(scheduled as f64) * reduced_bonus) ((elapsed as f64 * self.ease_factor as f64).max(scheduled as f64) * reduced_bonus)
.floor() as f32, .floor() as f32,
0, 0,
false,
) )
}; };
@ -191,44 +196,21 @@ impl ReviewState {
} }
} }
fn fuzz_range(interval: f32, factor: f32, minimum: f32) -> (f32, f32) {
let delta = (interval * factor).max(minimum).max(1.0);
(interval - delta, interval + delta)
}
/// Transform the provided hard/good/easy interval. /// Transform the provided hard/good/easy interval.
/// - Apply configured interval multiplier. /// - Apply configured interval multiplier.
/// - Apply fuzz. /// - Apply fuzz.
/// - Ensure it is at least `minimum`, and at least 1. /// - Ensure it is at least `minimum`, and at least 1.
/// - Ensure it is at or below the configured maximum interval. /// - Ensure it is at or below the configured maximum interval.
fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32) -> u32 { fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, fuzz: bool) -> u32 {
// fixme: floor is to match python // fixme: floor is to match python
let interval = interval.floor(); let interval = interval.floor() * ctx.interval_multiplier;
with_review_fuzz(ctx.fuzz_seed, interval * ctx.interval_multiplier) let interval = if fuzz {
ctx.with_review_fuzz(interval)
} else {
interval.floor() as u32
};
interval
.max(minimum) .max(minimum)
.min(ctx.maximum_review_interval) .min(ctx.maximum_review_interval)
.max(1) .max(1)
} }
fn with_review_fuzz(seed: Option<u64>, interval: f32) -> u32 {
// fixme: floor() is to match python
let interval = interval.floor();
if let Some(seed) = seed {
let mut rng = StdRng::seed_from_u64(seed);
let (lower, upper) = if interval < 2.0 {
(1.0, 1.0)
} else if interval < 3.0 {
(2.0, 3.0)
} else if interval < 7.0 {
fuzz_range(interval, 0.25, 0.0)
} else if interval < 30.0 {
fuzz_range(interval, 0.15, 2.0)
} else {
fuzz_range(interval, 0.05, 4.0)
};
rng.gen_range(lower, upper + 1.0)
} else {
interval
}
.round() as u32
}

View file

@ -6,7 +6,7 @@ use crate::i18n::{tr_args, I18n, TR};
/// Short string like '4d' to place above answer buttons. /// Short string like '4d' to place above answer buttons.
pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String { pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String {
let span = Timespan::from_secs(seconds).natural_span(); let span = Timespan::from_secs(seconds).natural_span();
let args = tr_args!["amount" => span.as_rounded_unit()]; let args = tr_args!["amount" => span.as_rounded_unit_for_answer_buttons()];
let key = match span.unit() { let key = match span.unit() {
TimespanUnit::Seconds => TR::SchedulingAnswerButtonTimeSeconds, TimespanUnit::Seconds => TR::SchedulingAnswerButtonTimeSeconds,
TimespanUnit::Minutes => TR::SchedulingAnswerButtonTimeMinutes, TimespanUnit::Minutes => TR::SchedulingAnswerButtonTimeMinutes,
@ -114,13 +114,26 @@ impl Timespan {
/// truncates to one decimal place. /// truncates to one decimal place.
pub fn as_rounded_unit(self) -> f32 { pub fn as_rounded_unit(self) -> f32 {
match self.unit { match self.unit {
// seconds/days as integer // seconds/minutes/days as integer
TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(), TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(),
// other values shown to 1 decimal place // other values shown to 1 decimal place
_ => (self.as_unit() * 10.0).round() / 10.0, _ => (self.as_unit() * 10.0).round() / 10.0,
} }
} }
/// Round seconds, minutes and days to integers, otherwise
/// truncates to one decimal place.
pub fn as_rounded_unit_for_answer_buttons(self) -> f32 {
match self.unit {
// seconds/minutes/days as integer
TimespanUnit::Seconds | TimespanUnit::Minutes | TimespanUnit::Days => {
self.as_unit().round()
}
// other values shown to 1 decimal place
_ => (self.as_unit() * 10.0).round() / 10.0,
}
}
pub fn unit(self) -> TimespanUnit { pub fn unit(self) -> TimespanUnit {
self.unit self.unit
} }
@ -161,7 +174,7 @@ mod test {
let log = log::terminal(); let log = log::terminal();
let i18n = I18n::new(&["zz"], "", log); let i18n = I18n::new(&["zz"], "", log);
assert_eq!(answer_button_time(30.0, &i18n), "30s"); assert_eq!(answer_button_time(30.0, &i18n), "30s");
assert_eq!(answer_button_time(70.0, &i18n), "1.2m"); assert_eq!(answer_button_time(70.0, &i18n), "1m");
assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.1mo"); assert_eq!(answer_button_time(1.1 * MONTH, &i18n), "1.1mo");
} }

View file

@ -52,7 +52,7 @@ impl TimestampMillis {
} }
lazy_static! { lazy_static! {
static ref TESTING: bool = env::var("SHIFT_CLOCK_HACK").is_ok(); pub(crate) static ref TESTING: bool = env::var("ANKI_TEST_MODE").is_ok();
} }
fn elapsed() -> time::Duration { fn elapsed() -> time::Duration {