├── .github └── workflows │ └── test.yml ├── .idx └── dev.nix ├── LICENSE ├── README.md ├── alea.go ├── fsrs.go ├── fsrs_test.go ├── go.mod ├── models.go ├── parameters.go ├── scheduler.go ├── scheduler_basic.go ├── scheduler_longterm.go └── weights.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [ push, pull_request, workflow_dispatch ] 3 | jobs: 4 | test: 5 | runs-on: ${{ matrix.os }} 6 | strategy: 7 | matrix: 8 | os: [ ubuntu-latest, macos-latest, windows-latest ] 9 | go: [ 1.21.x, 1.22.x ] 10 | env: 11 | OS: ${{ matrix.os }} 12 | GO: ${{ matrix.go }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ${{ matrix.go }} 19 | - name: Run tests 20 | run: go test -race -coverprofile coverage.txt -covermode atomic ./... 21 | -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | # To learn more about how to use Nix to configure your environment 2 | # see: https://developers.google.com/idx/guides/customize-idx-env 3 | { pkgs, ... }: { 4 | # Which nixpkgs channel to use. 5 | channel = "stable-24.11"; # or "unstable" 6 | 7 | # Use https://search.nixos.org/packages to find packages 8 | packages = [ 9 | # pkgs.go 10 | # pkgs.python311 11 | # pkgs.python311Packages.pip 12 | # pkgs.nodejs_20 13 | # pkgs.nodePackages.nodemon 14 | pkgs.go 15 | pkgs.gopls 16 | pkgs.fish 17 | pkgs.htop 18 | pkgs.fastfetch 19 | ]; 20 | 21 | # Sets environment variables in the workspace 22 | env = {}; 23 | idx = { 24 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" 25 | extensions = [ 26 | # "vscodevim.vim" 27 | "golang.go" 28 | ]; 29 | 30 | # Enable previews 31 | previews = { 32 | enable = true; 33 | previews = { 34 | # web = { 35 | # # Example: run "npm run dev" with PORT set to IDX's defined port for previews, 36 | # # and show it in IDX's web preview panel 37 | # command = ["npm" "run" "dev"]; 38 | # manager = "web"; 39 | # env = { 40 | # # Environment variables to set for your server 41 | # PORT = "$PORT"; 42 | # }; 43 | # }; 44 | }; 45 | }; 46 | 47 | # Workspace lifecycle hooks 48 | workspace = { 49 | # Runs when a workspace is first created 50 | onCreate = { 51 | # Example: install JS dependencies from NPM 52 | # npm-install = "npm install"; 53 | "setup" = "go test"; 54 | }; 55 | # Runs when the workspace is (re)started 56 | onStart = { 57 | # Example: start a background task to watch and re-build backend code 58 | # watch-backend = "npm run watch-backend"; 59 | "setup" = "go test"; 60 | }; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 open-spaced-repetition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-fsrs 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/open-spaced-repetition/go-fsrs/v3.svg)](https://pkg.go.dev/github.com/open-spaced-repetition/go-fsrs/v3) [![Go Report Card](https://goreportcard.com/badge/github.com/open-spaced-repetition/go-fsrs/v3)](https://goreportcard.com/report/github.com/open-spaced-repetition/go-fsrs/v3) 4 | ![Go version](https://img.shields.io/github/go-mod/go-version/open-spaced-repetition/go-fsrs) 5 | 6 | Go module implements [Free Spaced Repetition Scheduler algorithm](https://github.com/open-spaced-repetition/free-spaced-repetition-scheduler) 7 | 8 | ## Install 9 | 10 | ```bash 11 | go get -u github.com/open-spaced-repetition/go-fsrs/v3@latest 12 | ``` 13 | 14 | ## Usage 15 | 16 | Please see [GoDoc](https://pkg.go.dev/github.com/open-spaced-repetition/go-fsrs/v3) 17 | and [Wiki](https://github.com/open-spaced-repetition/go-fsrs/wiki) for documents. 18 | 19 | ## Contributing 20 | 21 | Please feel free to submit any [Pull Requests](https://github.com/open-spaced-repetition/go-fsrs/pulls). Just be sure 22 | not to introduce any breaking changes and do not any functionality excess the scope of the algorithm implementation. 23 | 24 | For algorithm problems or discussions, please 25 | go [open-spaced-repetition/free-spaced-repetition-scheduler](https://github.com/open-spaced-repetition/free-spaced-repetition-scheduler). 26 | 27 | ## Online development 28 | 29 | 30 | -------------------------------------------------------------------------------- /alea.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | type state struct { 10 | C float64 11 | S0 float64 12 | S1 float64 13 | S2 float64 14 | } 15 | 16 | type alea struct { 17 | c float64 18 | s0 float64 19 | s1 float64 20 | s2 float64 21 | } 22 | 23 | func NewAlea(seed interface{}) *alea { 24 | mash := Mash() 25 | a := &alea{ 26 | c: 1, 27 | s0: mash(" "), 28 | s1: mash(" "), 29 | s2: mash(" "), 30 | } 31 | 32 | if seed == nil { 33 | seed = time.Now().UnixNano() 34 | } 35 | 36 | seedStr := "" 37 | switch s := seed.(type) { 38 | case int: 39 | seedStr = strconv.Itoa(s) 40 | case string: 41 | seedStr = s 42 | } 43 | 44 | a.s0 -= mash(seedStr) 45 | if a.s0 < 0 { 46 | a.s0 += 1 47 | } 48 | a.s1 -= mash(seedStr) 49 | if a.s1 < 0 { 50 | a.s1 += 1 51 | } 52 | a.s2 -= mash(seedStr) 53 | if a.s2 < 0 { 54 | a.s2 += 1 55 | } 56 | 57 | return a 58 | } 59 | 60 | func (a *alea) Next() float64 { 61 | t := 2091639*a.s0 + a.c*2.3283064365386963e-10 // 2^-32 62 | a.s0 = a.s1 63 | a.s1 = a.s2 64 | a.s2 = t - math.Floor(t) 65 | a.c = math.Floor(t) 66 | return a.s2 67 | } 68 | 69 | func (a *alea) SetState(state state) { 70 | a.c = state.C 71 | a.s0 = state.S0 72 | a.s1 = state.S1 73 | a.s2 = state.S2 74 | } 75 | 76 | func (a *alea) GetState() state { 77 | return state{ 78 | C: a.c, 79 | S0: a.s0, 80 | S1: a.s1, 81 | S2: a.s2, 82 | } 83 | } 84 | 85 | func Mash() func(string) float64 { 86 | n := uint32(0xefc8249d) 87 | return func(data string) float64 { 88 | for i := 0; i < len(data); i++ { 89 | n += uint32(data[i]) 90 | h := 0.02519603282416938 * float64(n) 91 | n = uint32(h) 92 | h -= float64(n) 93 | h *= float64(n) 94 | n = uint32(h) 95 | h -= float64(n) 96 | n += uint32(h * 0x100000000) // 2^32 97 | } 98 | return float64(n) * 2.3283064365386963e-10 // 2^-32 99 | } 100 | } 101 | 102 | type PRNG func() float64 103 | 104 | func Alea(seed interface{}) PRNG { 105 | xg := NewAlea(seed) 106 | prng := func() float64 { 107 | return xg.Next() 108 | } 109 | 110 | return prng 111 | } 112 | 113 | func (prng PRNG) Int32() int32 { 114 | return int32(prng() * 0x100000000) 115 | } 116 | 117 | func (prng PRNG) Double() float64 { 118 | return prng() + float64(uint32(prng()*0x200000))*1.1102230246251565e-16 // 2^-53 119 | } 120 | 121 | func (prng PRNG) State(xg *alea) state { 122 | return xg.GetState() 123 | } 124 | 125 | func (prng PRNG) ImportState(xg *alea, state state) PRNG { 126 | xg.SetState(state) 127 | return prng 128 | } 129 | -------------------------------------------------------------------------------- /fsrs.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import "time" 4 | 5 | type FSRS struct { 6 | Parameters 7 | } 8 | 9 | func NewFSRS(param Parameters) *FSRS { 10 | return &FSRS{ 11 | Parameters: param, 12 | } 13 | } 14 | 15 | func (f *FSRS) Repeat(card Card, now time.Time) RecordLog { 16 | return f.scheduler(card, now).Preview() 17 | } 18 | 19 | func (f *FSRS) Next(card Card, now time.Time, grade Rating) SchedulingInfo { 20 | return f.scheduler(card, now).Review(grade) 21 | } 22 | 23 | func (f *FSRS) GetRetrievability(card Card, now time.Time) float64 { 24 | if card.State == New { 25 | return 0 26 | } else { 27 | elapsedDays := now.Sub(card.LastReview).Hours() / 24 28 | retrievability := f.Parameters.forgettingCurve(elapsedDays, card.Stability) 29 | return retrievability 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fsrs_test.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "math" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func roundFloat(val float64, precision uint) float64 { 11 | ratio := math.Pow(10, float64(precision)) 12 | return math.Round(val*ratio) / ratio 13 | } 14 | 15 | func TestBasicSchedulerExample(t *testing.T) { 16 | p := DefaultParam() 17 | fsrs := NewFSRS(p) 18 | card := NewCard() 19 | now := time.Date(2022, 11, 29, 12, 30, 0, 0, time.UTC) 20 | var ivlList []uint64 21 | var stateList []State 22 | schedulingCards := fsrs.Repeat(card, now) 23 | 24 | var ratings = []Rating{Good, Good, Good, Good, Good, Good, Again, Again, Good, Good, Good, Good, Good} 25 | var rating Rating 26 | var revlog ReviewLog 27 | 28 | for i := 0; i < len(ratings); i++ { 29 | rating = ratings[i] 30 | card = schedulingCards[rating].Card 31 | ivlList = append(ivlList, card.ScheduledDays) 32 | revlog = schedulingCards[rating].ReviewLog 33 | stateList = append(stateList, revlog.State) 34 | now = card.Due 35 | schedulingCards = fsrs.Repeat(card, now) 36 | } 37 | 38 | wantIvlList := []uint64{0, 4, 14, 44, 125, 328, 0, 0, 7, 16, 34, 71, 142} 39 | if !reflect.DeepEqual(ivlList, wantIvlList) { 40 | t.Errorf("excepted:%v, got:%v", wantIvlList, ivlList) 41 | } 42 | wantStateList := []State{New, Learning, Review, Review, Review, Review, Review, Relearning, Relearning, Review, Review, Review, Review} 43 | if !reflect.DeepEqual(stateList, wantStateList) { 44 | t.Errorf("excepted:%v, got:%v", wantStateList, stateList) 45 | } 46 | } 47 | 48 | func TestBasicSchedulerMemoState(t *testing.T) { 49 | p := DefaultParam() 50 | fsrs := NewFSRS(p) 51 | card := NewCard() 52 | now := time.Date(2022, 11, 29, 12, 30, 0, 0, time.UTC) 53 | schedulingCards := fsrs.Repeat(card, now) 54 | var ratings = []Rating{Again, Good, Good, Good, Good, Good} 55 | var ivlList = []uint64{0, 0, 1, 3, 8, 21} 56 | var rating Rating 57 | for i := 0; i < len(ratings); i++ { 58 | rating = ratings[i] 59 | card = schedulingCards[rating].Card 60 | now = now.Add(time.Duration(ivlList[i]) * 24 * time.Hour) 61 | schedulingCards = fsrs.Repeat(card, now) 62 | } 63 | wantStability := 48.4848 64 | cardStability := roundFloat(schedulingCards[Good].Card.Stability, 4) 65 | wantDifficulty := 7.0866 66 | cardDifficulty := roundFloat(schedulingCards[Good].Card.Difficulty, 4) 67 | if !reflect.DeepEqual(wantStability, cardStability) { 68 | t.Errorf("excepted:%v, got:%v", wantStability, cardStability) 69 | } 70 | 71 | if !reflect.DeepEqual(wantDifficulty, cardDifficulty) { 72 | t.Errorf("excepted:%v, got:%v", wantDifficulty, cardDifficulty) 73 | } 74 | } 75 | 76 | func TestNextInterval(t *testing.T) { 77 | p := DefaultParam() 78 | fsrs := NewFSRS(p) 79 | var ivlList []float64 80 | for i := 1; i <= 10; i++ { 81 | fsrs.RequestRetention = float64(i) / 10 82 | ivlList = append(ivlList, fsrs.nextInterval(1, 0)) 83 | } 84 | wantIvlList := []float64{422, 102, 43, 22, 13, 8, 4, 2, 1, 1} 85 | if !reflect.DeepEqual(ivlList, wantIvlList) { 86 | t.Errorf("excepted:%v, got:%v", wantIvlList, ivlList) 87 | } 88 | } 89 | 90 | func TestLongTermScheduler(t *testing.T) { 91 | p := DefaultParam() 92 | p.EnableShortTerm = false 93 | fsrs := NewFSRS(p) 94 | card := NewCard() 95 | now := time.Date(2022, 11, 29, 12, 30, 0, 0, time.UTC) 96 | ratings := []Rating{Good, Good, Good, Good, Good, Good, Again, Again, Good, Good, Good, Good, Good} 97 | ivlHistory := []uint64{} 98 | sHisotry := []float64{} 99 | dHistory := []float64{} 100 | for _, rating := range ratings { 101 | record := fsrs.Repeat(card, now)[rating] 102 | next := fsrs.Next(card, now, rating) 103 | if !reflect.DeepEqual(record.Card, next.Card) { 104 | t.Errorf("excepted:%v, got:%v", record.Card, next.Card) 105 | } 106 | 107 | card = record.Card 108 | ivlHistory = append(ivlHistory, (card.ScheduledDays)) 109 | sHisotry = append(sHisotry, roundFloat(card.Stability, 4)) 110 | dHistory = append(dHistory, roundFloat(card.Difficulty, 4)) 111 | now = card.Due 112 | } 113 | wantIvlHistory := []uint64{3, 11, 35, 101, 269, 669, 12, 2, 5, 12, 26, 55, 112} 114 | if !reflect.DeepEqual(ivlHistory, wantIvlHistory) { 115 | t.Errorf("excepted:%v, got:%v", wantIvlHistory, ivlHistory) 116 | } 117 | wantSHistory := []float64{3.173, 10.7389, 34.5776, 100.7483, 269.2838, 669.3093, 11.8987, 2.236, 5.2001, 11.8993, 26.4917, 55.4949, 111.9726} 118 | if !reflect.DeepEqual(sHisotry, wantSHistory) { 119 | t.Errorf("excepted:%v, got:%v", wantSHistory, sHisotry) 120 | } 121 | wantDHistory := []float64{5.2824, 5.273, 5.2635, 5.2542, 5.2448, 5.2355, 6.7654, 7.794, 7.773, 7.7521, 7.7312, 7.7105, 7.6899} 122 | if !reflect.DeepEqual(dHistory, wantDHistory) { 123 | t.Errorf("excepted:%v, got:%v", wantDHistory, dHistory) 124 | } 125 | } 126 | 127 | func TestGetRetrievability(t *testing.T) { 128 | retrievabilityList := []float64{} 129 | fsrs := NewFSRS(DefaultParam()) 130 | card := NewCard() 131 | now := time.Date(2022, 11, 29, 12, 30, 0, 0, time.UTC) 132 | retrievabilityList = append(retrievabilityList, roundFloat(fsrs.GetRetrievability(card, now), 4)) 133 | for i := 0; i < 3; i++ { 134 | card = fsrs.Next(card, now, Good).Card 135 | now = card.Due 136 | retrievabilityList = append(retrievabilityList, roundFloat(fsrs.GetRetrievability(card, now), 4)) 137 | } 138 | wantRetrievabilityList := []float64{0, 0.9997, 0.9091, 0.9013} 139 | if !reflect.DeepEqual(retrievabilityList, wantRetrievabilityList) { 140 | t.Errorf("excepted:%v, got:%v", wantRetrievabilityList, retrievabilityList) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/open-spaced-repetition/go-fsrs/v3 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Card struct { 8 | Due time.Time `json:"Due"` 9 | Stability float64 `json:"Stability"` 10 | Difficulty float64 `json:"Difficulty"` 11 | ElapsedDays uint64 `json:"ElapsedDays"` 12 | ScheduledDays uint64 `json:"ScheduledDays"` 13 | Reps uint64 `json:"Reps"` 14 | Lapses uint64 `json:"Lapses"` 15 | State State `json:"State"` 16 | LastReview time.Time `json:"LastReview"` 17 | } 18 | 19 | func NewCard() Card { 20 | return Card{ 21 | Due: time.Time{}, 22 | Stability: 0, 23 | Difficulty: 0, 24 | ElapsedDays: 0, 25 | ScheduledDays: 0, 26 | Reps: 0, 27 | Lapses: 0, 28 | State: New, 29 | LastReview: time.Time{}, 30 | } 31 | } 32 | 33 | type ReviewLog struct { 34 | Rating Rating `json:"Rating"` 35 | ScheduledDays uint64 `json:"ScheduledDays"` 36 | ElapsedDays uint64 `json:"ElapsedDays"` 37 | Review time.Time `json:"Review"` 38 | State State `json:"State"` 39 | } 40 | 41 | type schedulingCards struct { 42 | Again Card 43 | Hard Card 44 | Good Card 45 | Easy Card 46 | } 47 | 48 | func (s *schedulingCards) init(card Card) { 49 | s.Again = card 50 | s.Hard = card 51 | s.Good = card 52 | s.Easy = card 53 | } 54 | 55 | type SchedulingInfo struct { 56 | Card Card 57 | ReviewLog ReviewLog 58 | } 59 | 60 | type RecordLog map[Rating]SchedulingInfo 61 | 62 | type Rating int8 63 | 64 | const ( 65 | Again Rating = iota + 1 66 | Hard 67 | Good 68 | Easy 69 | ) 70 | 71 | func (s Rating) String() string { 72 | switch s { 73 | case Again: 74 | return "Again" 75 | case Hard: 76 | return "Hard" 77 | case Good: 78 | return "Good" 79 | case Easy: 80 | return "Easy" 81 | } 82 | return "unknown" 83 | } 84 | 85 | type State int8 86 | 87 | const ( 88 | New State = iota 89 | Learning 90 | Review 91 | Relearning 92 | ) 93 | -------------------------------------------------------------------------------- /parameters.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | type Parameters struct { 8 | RequestRetention float64 `json:"RequestRetention"` 9 | MaximumInterval float64 `json:"MaximumInterval"` 10 | W Weights `json:"Weights"` 11 | Decay float64 `json:"Decay"` 12 | Factor float64 `json:"Factor"` 13 | EnableShortTerm bool `json:"EnableShortTerm"` 14 | EnableFuzz bool `json:"EnableFuzz"` 15 | seed string 16 | } 17 | 18 | func DefaultParam() Parameters { 19 | var Decay = -0.5 20 | var Factor = math.Pow(0.9, 1/Decay) - 1 21 | return Parameters{ 22 | RequestRetention: 0.9, 23 | MaximumInterval: 36500, 24 | W: DefaultWeights(), 25 | Decay: Decay, 26 | Factor: Factor, 27 | EnableShortTerm: true, 28 | EnableFuzz: false, 29 | } 30 | } 31 | 32 | func (p *Parameters) forgettingCurve(elapsedDays float64, stability float64) float64 { 33 | return math.Pow(1+p.Factor*elapsedDays/stability, p.Decay) 34 | } 35 | 36 | func (p *Parameters) initStability(r Rating) float64 { 37 | return math.Max(p.W[r-1], 0.1) 38 | } 39 | func (p *Parameters) initDifficulty(r Rating) float64 { 40 | return constrainDifficulty(p.W[4] - math.Exp(p.W[5]*float64(r-1)) + 1) 41 | } 42 | 43 | func (p *Parameters) ApplyFuzz(ivl float64, elapsedDays float64, enableFuzz bool) float64 { 44 | if !enableFuzz || ivl < 2.5 { 45 | return ivl 46 | } 47 | 48 | generator := Alea(p.seed) 49 | fuzzFactor := generator.Double() 50 | 51 | minIvl, maxIvl := getFuzzRange(ivl, elapsedDays, p.MaximumInterval) 52 | 53 | return math.Floor(fuzzFactor*float64(maxIvl-minIvl+1)) + float64(minIvl) 54 | } 55 | 56 | func constrainDifficulty(d float64) float64 { 57 | return math.Min(math.Max(d, 1), 10) 58 | } 59 | 60 | func linearDamping(deltaD float64, oldD float64) float64 { 61 | return (10.0 - oldD) * deltaD / 9.0 62 | } 63 | 64 | func (p *Parameters) nextInterval(s, elapsedDays float64) float64 { 65 | newInterval := s / p.Factor * (math.Pow(p.RequestRetention, 1/p.Decay) - 1) 66 | return p.ApplyFuzz(math.Max(math.Min(math.Round(newInterval), p.MaximumInterval), 1), elapsedDays, p.EnableFuzz) 67 | } 68 | 69 | func (p *Parameters) nextDifficulty(d float64, r Rating) float64 { 70 | deltaD := -p.W[6] * float64(r-3) 71 | nextD := d + linearDamping(deltaD, d) 72 | return constrainDifficulty(p.meanReversion(p.initDifficulty(Easy), nextD)) 73 | } 74 | 75 | func (p *Parameters) shortTermStability(s float64, r Rating) float64 { 76 | return s * math.Exp(p.W[17]*(float64(r-3)+p.W[18])) 77 | } 78 | 79 | func (p *Parameters) meanReversion(init float64, current float64) float64 { 80 | return p.W[7]*init + (1-p.W[7])*current 81 | } 82 | 83 | func (p *Parameters) nextRecallStability(d float64, s float64, r float64, rating Rating) float64 { 84 | var hardPenalty, easyBonus float64 85 | if rating == Hard { 86 | hardPenalty = p.W[15] 87 | } else { 88 | hardPenalty = 1 89 | } 90 | if rating == Easy { 91 | easyBonus = p.W[16] 92 | } else { 93 | easyBonus = 1 94 | } 95 | return s * (1 + math.Exp(p.W[8])* 96 | (11-d)* 97 | math.Pow(s, -p.W[9])* 98 | (math.Exp((1-r)*p.W[10])-1)* 99 | hardPenalty* 100 | easyBonus) 101 | } 102 | 103 | func (p *Parameters) nextForgetStability(d float64, s float64, r float64) float64 { 104 | return p.W[11] * 105 | math.Pow(d, -p.W[12]) * 106 | (math.Pow(s+1, p.W[13]) - 1) * 107 | math.Exp((1-r)*p.W[14]) 108 | } 109 | 110 | type FuzzRange struct { 111 | Start float64 112 | End float64 113 | Factor float64 114 | } 115 | 116 | var FUZZ_RANGES = []FuzzRange{ 117 | {Start: 2.5, End: 7.0, Factor: 0.15}, 118 | {Start: 7.0, End: 20.0, Factor: 0.1}, 119 | {Start: 20.0, End: math.Inf(1), Factor: 0.05}, 120 | } 121 | 122 | func getFuzzRange(interval, elapsedDays, maximumInterval float64) (minIvl, maxIvl int) { 123 | delta := 1.0 124 | for _, r := range FUZZ_RANGES { 125 | delta += r.Factor * math.Max(math.Min(interval, r.End)-r.Start, 0.0) 126 | } 127 | 128 | interval = math.Min(interval, maximumInterval) 129 | minIvlFloat := math.Max(2, math.Round(interval-delta)) 130 | maxIvlFloat := math.Min(math.Round(interval+delta), maximumInterval) 131 | 132 | if interval > elapsedDays { 133 | minIvlFloat = math.Max(minIvlFloat, elapsedDays+1) 134 | } 135 | minIvlFloat = math.Min(minIvlFloat, maxIvlFloat) 136 | 137 | minIvl = int(minIvlFloat) 138 | maxIvl = int(maxIvlFloat) 139 | 140 | return minIvl, maxIvl 141 | } 142 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "time" 7 | ) 8 | 9 | type Scheduler struct { 10 | parameters *Parameters 11 | 12 | last Card 13 | current Card 14 | now time.Time 15 | next RecordLog 16 | 17 | impl implScheduler 18 | } 19 | 20 | type implScheduler interface { 21 | newState(Rating) SchedulingInfo 22 | learningState(Rating) SchedulingInfo 23 | reviewState(Rating) SchedulingInfo 24 | } 25 | 26 | func (s *Scheduler) Preview() RecordLog { 27 | return RecordLog{ 28 | Again: s.Review(Again), 29 | Hard: s.Review(Hard), 30 | Good: s.Review(Good), 31 | Easy: s.Review(Easy), 32 | } 33 | } 34 | 35 | func (s *Scheduler) Review(grade Rating) SchedulingInfo { 36 | state := s.last.State 37 | var item SchedulingInfo 38 | switch state { 39 | case New: 40 | item = s.impl.newState(grade) 41 | case Learning, Relearning: 42 | item = s.impl.learningState(grade) 43 | case Review: 44 | item = s.impl.reviewState(grade) 45 | } 46 | return item 47 | } 48 | 49 | func (s *Scheduler) initSeed() { 50 | time := s.now 51 | reps := s.current.Reps 52 | mul := s.current.Difficulty * s.current.Stability 53 | s.parameters.seed = fmt.Sprintf("%d_%d_%f", time.Unix(), reps, mul) 54 | } 55 | 56 | func (s *Scheduler) buildLog(rating Rating) ReviewLog { 57 | return ReviewLog{ 58 | Rating: rating, 59 | State: s.current.State, 60 | ElapsedDays: s.current.ElapsedDays, 61 | ScheduledDays: s.current.ScheduledDays, 62 | Review: s.now, 63 | } 64 | } 65 | 66 | func (p *Parameters) newScheduler(card Card, now time.Time, newImpl func(s *Scheduler) implScheduler) *Scheduler { 67 | s := &Scheduler{ 68 | parameters: p, 69 | next: make(RecordLog), 70 | last: card, 71 | current: card, 72 | now: now, 73 | } 74 | 75 | var interval float64 = 0 // card.state === State.New => 0 76 | if s.current.State != New && !s.current.LastReview.IsZero() { 77 | interval = math.Floor(s.now.Sub(s.current.LastReview).Hours() / 24) 78 | } 79 | s.current.LastReview = s.now 80 | s.current.ElapsedDays = uint64(interval) 81 | s.current.Reps++ 82 | s.initSeed() 83 | 84 | s.impl = newImpl(s) 85 | 86 | return s 87 | } 88 | 89 | func (p *Parameters) scheduler(card Card, now time.Time) *Scheduler { 90 | if p.EnableShortTerm { 91 | return p.NewBasicScheduler(card, now) 92 | } else { 93 | return p.NewLongTermScheduler(card, now) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /scheduler_basic.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "math" 5 | "time" 6 | ) 7 | 8 | type basicScheduler struct { 9 | *Scheduler 10 | } 11 | 12 | var _ implScheduler = basicScheduler{} 13 | 14 | func (p *Parameters) NewBasicScheduler(card Card, now time.Time) *Scheduler { 15 | return p.newScheduler(card, now, func(s *Scheduler) implScheduler { 16 | return basicScheduler{s} 17 | }) 18 | } 19 | 20 | func (bs basicScheduler) newState(grade Rating) SchedulingInfo { 21 | exist, ok := bs.next[grade] 22 | if ok { 23 | return exist 24 | } 25 | 26 | next := bs.current 27 | next.Difficulty = bs.parameters.initDifficulty(grade) 28 | next.Stability = bs.parameters.initStability(grade) 29 | 30 | switch grade { 31 | case Again: 32 | next.ScheduledDays = 0 33 | next.Due = bs.now.Add(1 * time.Minute) 34 | next.State = Learning 35 | case Hard: 36 | next.ScheduledDays = 0 37 | next.Due = bs.now.Add(5 * time.Minute) 38 | next.State = Learning 39 | case Good: 40 | next.ScheduledDays = 0 41 | next.Due = bs.now.Add(10 * time.Minute) 42 | next.State = Learning 43 | case Easy: 44 | easyInterval := bs.parameters.nextInterval( 45 | next.Stability, 46 | float64(next.ElapsedDays), 47 | ) 48 | next.ScheduledDays = uint64(easyInterval) 49 | next.Due = bs.now.Add(time.Duration(easyInterval) * 24 * time.Hour) 50 | next.State = Review 51 | } 52 | 53 | item := SchedulingInfo{ 54 | Card: next, 55 | ReviewLog: bs.buildLog(grade), 56 | } 57 | bs.next[grade] = item 58 | return item 59 | } 60 | 61 | func (bs basicScheduler) learningState(grade Rating) SchedulingInfo { 62 | exist, ok := bs.next[grade] 63 | if ok { 64 | return exist 65 | } 66 | 67 | next := bs.current 68 | interval := float64(bs.current.ElapsedDays) 69 | next.Difficulty = bs.parameters.nextDifficulty(bs.last.Difficulty, grade) 70 | next.Stability = bs.parameters.shortTermStability(bs.last.Stability, grade) 71 | 72 | switch grade { 73 | case Again: 74 | next.ScheduledDays = 0 75 | next.Due = bs.now.Add(5 * time.Minute) 76 | next.State = bs.last.State 77 | case Hard: 78 | next.ScheduledDays = 0 79 | next.Due = bs.now.Add(10 * time.Minute) 80 | next.State = bs.last.State 81 | case Good: 82 | goodInterval := bs.parameters.nextInterval(next.Stability, interval) 83 | next.ScheduledDays = uint64(goodInterval) 84 | next.Due = bs.now.Add(time.Duration(goodInterval) * 24 * time.Hour) 85 | next.State = Review 86 | case Easy: 87 | goodStability := bs.parameters.shortTermStability(bs.last.Stability, Good) 88 | goodInterval := bs.parameters.nextInterval(goodStability, interval) 89 | easyInterval := math.Max( 90 | bs.parameters.nextInterval(next.Stability, interval), 91 | float64(goodInterval)+1, 92 | ) 93 | next.ScheduledDays = uint64(easyInterval) 94 | next.Due = bs.now.Add(time.Duration(easyInterval) * 24 * time.Hour) 95 | next.State = Review 96 | } 97 | 98 | item := SchedulingInfo{ 99 | Card: next, 100 | ReviewLog: bs.buildLog(grade), 101 | } 102 | bs.next[grade] = item 103 | return item 104 | } 105 | 106 | func (bs basicScheduler) reviewState(grade Rating) SchedulingInfo { 107 | exist, ok := bs.next[grade] 108 | if ok { 109 | return exist 110 | } 111 | 112 | interval := float64(bs.current.ElapsedDays) 113 | difficulty := bs.last.Difficulty 114 | stability := bs.last.Stability 115 | retrievability := bs.parameters.forgettingCurve(interval, stability) 116 | 117 | nextAgain := bs.current 118 | nextHard := bs.current 119 | nextGood := bs.current 120 | nextEasy := bs.current 121 | 122 | bs.nextDs(&nextAgain, &nextHard, &nextGood, &nextEasy, difficulty, stability, retrievability) 123 | bs.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval) 124 | bs.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 125 | nextAgain.Lapses++ 126 | 127 | itemAgain := SchedulingInfo{Card: nextAgain, ReviewLog: bs.buildLog(Again)} 128 | itemHard := SchedulingInfo{Card: nextHard, ReviewLog: bs.buildLog(Hard)} 129 | itemGood := SchedulingInfo{Card: nextGood, ReviewLog: bs.buildLog(Good)} 130 | itemEasy := SchedulingInfo{Card: nextEasy, ReviewLog: bs.buildLog(Easy)} 131 | 132 | bs.next[Again] = itemAgain 133 | bs.next[Hard] = itemHard 134 | bs.next[Good] = itemGood 135 | bs.next[Easy] = itemEasy 136 | 137 | return bs.next[grade] 138 | } 139 | 140 | func (bs basicScheduler) nextDs(nextAgain, nextHard, nextGood, nextEasy *Card, difficulty, stability, retrievability float64) { 141 | nextAgain.Difficulty = bs.parameters.nextDifficulty(difficulty, Again) 142 | nextSMin := stability / math.Exp(bs.parameters.W[17]*bs.parameters.W[18]) 143 | nextAgain.Stability = math.Min(nextSMin, bs.parameters.nextForgetStability(difficulty, stability, retrievability)) 144 | 145 | nextHard.Difficulty = bs.parameters.nextDifficulty(difficulty, Hard) 146 | nextHard.Stability = bs.parameters.nextRecallStability(difficulty, stability, retrievability, Hard) 147 | 148 | nextGood.Difficulty = bs.parameters.nextDifficulty(difficulty, Good) 149 | nextGood.Stability = bs.parameters.nextRecallStability(difficulty, stability, retrievability, Good) 150 | 151 | nextEasy.Difficulty = bs.parameters.nextDifficulty(difficulty, Easy) 152 | nextEasy.Stability = bs.parameters.nextRecallStability(difficulty, stability, retrievability, Easy) 153 | } 154 | 155 | func (bs basicScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card, elapsedDays float64) { 156 | hardInterval := bs.parameters.nextInterval(nextHard.Stability, elapsedDays) 157 | goodInterval := bs.parameters.nextInterval(nextGood.Stability, elapsedDays) 158 | hardInterval = math.Min(hardInterval, goodInterval) 159 | goodInterval = math.Max(goodInterval, hardInterval+1) 160 | easyInterval := math.Max( 161 | bs.parameters.nextInterval(nextEasy.Stability, elapsedDays), 162 | goodInterval+1, 163 | ) 164 | 165 | nextAgain.ScheduledDays = 0 166 | nextAgain.Due = bs.now.Add(5 * time.Minute) 167 | 168 | nextHard.ScheduledDays = uint64(hardInterval) 169 | nextHard.Due = bs.now.Add(time.Duration(hardInterval) * 24 * time.Hour) 170 | 171 | nextGood.ScheduledDays = uint64(goodInterval) 172 | nextGood.Due = bs.now.Add(time.Duration(goodInterval) * 24 * time.Hour) 173 | 174 | nextEasy.ScheduledDays = uint64(easyInterval) 175 | nextEasy.Due = bs.now.Add(time.Duration(easyInterval) * 24 * time.Hour) 176 | } 177 | 178 | func (bs basicScheduler) nextState(nextAgain, nextHard, nextGood, nextEasy *Card) { 179 | nextAgain.State = Relearning 180 | nextHard.State = Review 181 | nextGood.State = Review 182 | nextEasy.State = Review 183 | } 184 | -------------------------------------------------------------------------------- /scheduler_longterm.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | import ( 4 | "math" 5 | "time" 6 | ) 7 | 8 | type longTermScheduler struct { 9 | *Scheduler 10 | } 11 | 12 | var _ implScheduler = longTermScheduler{} 13 | 14 | func (p *Parameters) NewLongTermScheduler(card Card, now time.Time) *Scheduler { 15 | return p.newScheduler(card, now, func(s *Scheduler) implScheduler { 16 | return longTermScheduler{s} 17 | }) 18 | } 19 | 20 | func (lts longTermScheduler) newState(grade Rating) SchedulingInfo { 21 | exist, ok := lts.next[grade] 22 | if ok { 23 | return exist 24 | } 25 | 26 | lts.current.ScheduledDays = 0 27 | lts.current.ElapsedDays = 0 28 | 29 | nextAgain := lts.current 30 | nextHard := lts.current 31 | nextGood := lts.current 32 | nextEasy := lts.current 33 | 34 | lts.initDs(&nextAgain, &nextHard, &nextGood, &nextEasy) 35 | 36 | lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, 0) 37 | lts.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 38 | lts.updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) 39 | 40 | return lts.next[grade] 41 | } 42 | 43 | func (lts longTermScheduler) initDs(nextAgain, nextHard, nextGood, nextEasy *Card) { 44 | nextAgain.Difficulty = lts.parameters.initDifficulty(Again) 45 | nextAgain.Stability = lts.parameters.initStability(Again) 46 | 47 | nextHard.Difficulty = lts.parameters.initDifficulty(Hard) 48 | nextHard.Stability = lts.parameters.initStability(Hard) 49 | 50 | nextGood.Difficulty = lts.parameters.initDifficulty(Good) 51 | nextGood.Stability = lts.parameters.initStability(Good) 52 | 53 | nextEasy.Difficulty = lts.parameters.initDifficulty(Easy) 54 | nextEasy.Stability = lts.parameters.initStability(Easy) 55 | } 56 | 57 | func (lts longTermScheduler) learningState(grade Rating) SchedulingInfo { 58 | return lts.reviewState(grade) 59 | } 60 | 61 | func (lts longTermScheduler) reviewState(grade Rating) SchedulingInfo { 62 | exist, ok := lts.next[grade] 63 | if ok { 64 | return exist 65 | } 66 | 67 | interval := float64(lts.current.ElapsedDays) 68 | difficulty := lts.last.Difficulty 69 | stability := lts.last.Stability 70 | retrievability := lts.parameters.forgettingCurve(interval, stability) 71 | 72 | nextAgain := lts.current 73 | nextHard := lts.current 74 | nextGood := lts.current 75 | nextEasy := lts.current 76 | 77 | lts.nextDs(&nextAgain, &nextHard, &nextGood, &nextEasy, difficulty, stability, retrievability) 78 | lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval) 79 | lts.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 80 | nextAgain.Lapses++ 81 | 82 | lts.updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) 83 | return lts.next[grade] 84 | } 85 | 86 | func (lts longTermScheduler) nextDs(nextAgain, nextHard, nextGood, nextEasy *Card, difficulty, stability, retrievability float64) { 87 | nextAgain.Difficulty = lts.parameters.nextDifficulty(difficulty, Again) 88 | nextAgain.Stability = math.Min(stability, lts.parameters.nextForgetStability(difficulty, stability, retrievability)) 89 | 90 | nextHard.Difficulty = lts.parameters.nextDifficulty(difficulty, Hard) 91 | nextHard.Stability = lts.parameters.nextRecallStability(difficulty, stability, retrievability, Hard) 92 | 93 | nextGood.Difficulty = lts.parameters.nextDifficulty(difficulty, Good) 94 | nextGood.Stability = lts.parameters.nextRecallStability(difficulty, stability, retrievability, Good) 95 | 96 | nextEasy.Difficulty = lts.parameters.nextDifficulty(difficulty, Easy) 97 | nextEasy.Stability = lts.parameters.nextRecallStability(difficulty, stability, retrievability, Easy) 98 | } 99 | 100 | func (lts longTermScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card, elapsedDays float64) { 101 | againInterval := lts.parameters.nextInterval(nextAgain.Stability, elapsedDays) 102 | hardInterval := lts.parameters.nextInterval(nextHard.Stability, elapsedDays) 103 | goodInterval := lts.parameters.nextInterval(nextGood.Stability, elapsedDays) 104 | easyInterval := lts.parameters.nextInterval(nextEasy.Stability, elapsedDays) 105 | 106 | againInterval = math.Min(againInterval, hardInterval) 107 | hardInterval = math.Max(hardInterval, againInterval+1) 108 | goodInterval = math.Max(goodInterval, hardInterval+1) 109 | easyInterval = math.Max(easyInterval, goodInterval+1) 110 | 111 | nextAgain.ScheduledDays = uint64(againInterval) 112 | nextAgain.Due = lts.now.Add(time.Duration(againInterval) * 24 * time.Hour) 113 | 114 | nextHard.ScheduledDays = uint64(hardInterval) 115 | nextHard.Due = lts.now.Add(time.Duration(hardInterval) * 24 * time.Hour) 116 | 117 | nextGood.ScheduledDays = uint64(goodInterval) 118 | nextGood.Due = lts.now.Add(time.Duration(goodInterval) * 24 * time.Hour) 119 | 120 | nextEasy.ScheduledDays = uint64(easyInterval) 121 | nextEasy.Due = lts.now.Add(time.Duration(easyInterval) * 24 * time.Hour) 122 | } 123 | 124 | func (lts longTermScheduler) nextState(nextAgain, nextHard, nextGood, nextEasy *Card) { 125 | nextAgain.State = Review 126 | nextHard.State = Review 127 | nextGood.State = Review 128 | nextEasy.State = Review 129 | } 130 | 131 | func (lts longTermScheduler) updateNext(nextAgain, nextHard, nextGood, nextEasy *Card) { 132 | itemAgain := SchedulingInfo{ 133 | Card: *nextAgain, 134 | ReviewLog: lts.buildLog(Again), 135 | } 136 | itemHard := SchedulingInfo{ 137 | Card: *nextHard, 138 | ReviewLog: lts.buildLog(Hard), 139 | } 140 | itemGood := SchedulingInfo{ 141 | Card: *nextGood, 142 | ReviewLog: lts.buildLog(Good), 143 | } 144 | itemEasy := SchedulingInfo{ 145 | Card: *nextEasy, 146 | ReviewLog: lts.buildLog(Easy), 147 | } 148 | 149 | lts.next[Again] = itemAgain 150 | lts.next[Hard] = itemHard 151 | lts.next[Good] = itemGood 152 | lts.next[Easy] = itemEasy 153 | } 154 | -------------------------------------------------------------------------------- /weights.go: -------------------------------------------------------------------------------- 1 | package fsrs 2 | 3 | type Weights [19]float64 4 | 5 | func DefaultWeights() Weights { 6 | return Weights{0.40255, 7 | 1.18385, 8 | 3.173, 9 | 15.69105, 10 | 7.1949, 11 | 0.5345, 12 | 1.4604, 13 | 0.0046, 14 | 1.54575, 15 | 0.1192, 16 | 1.01925, 17 | 1.9395, 18 | 0.11, 19 | 0.29605, 20 | 2.2698, 21 | 0.2315, 22 | 2.9898, 23 | 0.51655, 24 | 0.6621} 25 | } 26 | --------------------------------------------------------------------------------