Skip to content

Commit

Permalink
Merge pull request #3 from open-spaced-repetition/add-fuzz
Browse files Browse the repository at this point in the history
Add interval fuzzing
  • Loading branch information
joshdavham authored Nov 25, 2024
2 parents f5b5c21 + c057bb1 commit da4a6e2
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 10 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "anki-sm-2"
version = "0.1.1"
version = "0.1.2"
description = "Anki SM-2 based spaced repetition scheduler"
readme = "README.md"
authors = [{ name = "Joshua Hamilton", email = "[email protected]" }]
Expand Down
83 changes: 75 additions & 8 deletions src/anki_sm_2/anki_sm_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from datetime import datetime, timezone, timedelta
from copy import deepcopy
from typing import Any
import math
import random

class State(IntEnum):
"""
Expand Down Expand Up @@ -281,8 +283,6 @@ def review_card(self, card: Card, rating: Rating, review_datetime: datetime | No

elif card.state == State.Review:

# TODO: add fuzz

assert type(card.ease) == float # mypy
assert type(card.current_interval) == int # mypy

Expand All @@ -291,13 +291,15 @@ def review_card(self, card: Card, rating: Rating, review_datetime: datetime | No
card.state = State.Relearing
card.step = 0
card.ease = max(1.3, card.ease * 0.80) # reduce ease by 20%
card.current_interval = max( self.minimum_interval, round(card.current_interval * self.new_interval * self.interval_modifier) )
current_interval = max( self.minimum_interval, round(card.current_interval * self.new_interval * self.interval_modifier) )
card.current_interval = self._get_fuzzed_interval(current_interval)
card.due = review_datetime + timedelta(days=card.current_interval)

elif rating == Rating.Hard:

card.ease = max(1.3, card.ease * 0.85) # reduce ease by 15%
card.current_interval = min( self.maximum_interval, round(card.current_interval * self.hard_interval * self.interval_modifier) )
current_interval = min( self.maximum_interval, round(card.current_interval * self.hard_interval * self.interval_modifier) )
card.current_interval = self._get_fuzzed_interval(current_interval)
card.due = review_datetime + timedelta(days=card.current_interval)

elif rating == Rating.Good:
Expand All @@ -307,11 +309,13 @@ def review_card(self, card: Card, rating: Rating, review_datetime: datetime | No
days_overdue = (review_datetime - card.due).days
if days_overdue >= 1:

card.current_interval = min( self.maximum_interval, round(( card.current_interval + (days_overdue / 2.0) ) * card.ease * self.interval_modifier) )
current_interval = min( self.maximum_interval, round(( card.current_interval + (days_overdue / 2.0) ) * card.ease * self.interval_modifier) )

else:

card.current_interval = min( self.maximum_interval, round(card.current_interval * card.ease * self.interval_modifier) )
current_interval = min( self.maximum_interval, round(card.current_interval * card.ease * self.interval_modifier) )

card.current_interval = self._get_fuzzed_interval(current_interval)

card.due = review_datetime + timedelta(days=card.current_interval)

Expand All @@ -320,11 +324,13 @@ def review_card(self, card: Card, rating: Rating, review_datetime: datetime | No
days_overdue = (review_datetime - card.due).days
if days_overdue >= 1:

card.current_interval = min( self.maximum_interval, round(( card.current_interval + days_overdue ) * card.ease * self.easy_bonus * self.interval_modifier) )
current_interval = min( self.maximum_interval, round(( card.current_interval + days_overdue ) * card.ease * self.easy_bonus * self.interval_modifier) )

else:

card.current_interval = min( self.maximum_interval, round(card.current_interval * card.ease * self.easy_bonus * self.interval_modifier) )
current_interval = min( self.maximum_interval, round(card.current_interval * card.ease * self.easy_bonus * self.interval_modifier) )

card.current_interval = self._get_fuzzed_interval(current_interval)

card.ease = card.ease * 1.15 # increase ease by 15%
card.due = review_datetime + timedelta(days=card.current_interval)
Expand Down Expand Up @@ -377,6 +383,67 @@ def review_card(self, card: Card, rating: Rating, review_datetime: datetime | No

return card, review_log

def _get_fuzzed_interval(self, interval: int) -> int:
"""
Takes the current calculated interval and adds a small amount of random fuzz to it.
For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days.
Args:
interval (int): The calculated next interval, before fuzzing.
Returns:
int: The new interval, after fuzzing.
"""

if interval < 2.5: # fuzz is not applied to intervals less than 2.5
return interval

def _get_fuzz_range(interval: int) -> tuple[int, int]:
"""
Helper function that computes the possible upper and lower bounds of the interval after fuzzing.
"""

FUZZ_RANGES = [
{
"start": 2.5,
"end": 7.0,
"factor": 0.15,
},
{
"start": 7.0,
"end": 20.0,
"factor": 0.1,
},
{
"start": 20.0,
"end": math.inf,
"factor": 0.05,
},
]

delta = 1.0
for fuzz_range in FUZZ_RANGES:

delta += fuzz_range["factor"] * max( min(interval, fuzz_range["end"]) - fuzz_range["start"], 0.0 )

min_ivl = int(round(interval - delta))
max_ivl = int(round(interval + delta))

# make sure the min_ivl and max_ivl fall into a valid range
min_ivl = max(2, min_ivl)
max_ivl = min(max_ivl, self.maximum_interval)
min_ivl = min(min_ivl, max_ivl)

return min_ivl, max_ivl

min_ivl, max_ivl = _get_fuzz_range(interval)

fuzzed_interval = ( random.random() * (max_ivl - min_ivl + 1) ) + min_ivl # the next interval is a random value between min_ivl and max_ivl

fuzzed_interval = min( round(fuzzed_interval), self.maximum_interval )

return fuzzed_interval

def to_dict(self) -> dict[str, Any]:

return_dict = {
Expand Down
37 changes: 36 additions & 1 deletion tests/test_anki_sm_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from anki_sm_2 import AnkiSM2Scheduler, Card, Rating, ReviewLog, State
import json
from copy import deepcopy
import random

class TestAnkiSM2:

Expand Down Expand Up @@ -194,4 +195,38 @@ def test_serialize(self):
assert copied_review_log.review_duration == review_duration
# can use the review log to recreate the card that was reviewed
assert old_card.to_dict() == Card.from_dict(review_log.to_dict()['card']).to_dict()
assert card.to_dict() != old_card.to_dict()
assert card.to_dict() != old_card.to_dict()

def test_fuzz(self):
"""
Reviews a new card Good four times in a row with different random seeds.
The size of the interval after the fourth review should be different.
"""

scheduler = AnkiSM2Scheduler()

# seed 1
random.seed(42)

card = Card()
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc))
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
prev_due = card.due
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
interval = card.due - prev_due

assert interval.days == 6

# seed 2
random.seed(12345)

card = Card()
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc))
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
prev_due = card.due
card, _ = scheduler.review_card(card=card, rating=Rating.Good, review_datetime=card.due)
interval = card.due - prev_due

assert interval.days == 5

0 comments on commit da4a6e2

Please sign in to comment.