From a8e6a99465536328967ddc1f3903a385c378a755 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Tue, 10 Sep 2024 20:56:40 +0800 Subject: [PATCH] Feat/fuzz --- area.go | 128 ++++++++++++++++++++++++++++++++++++++++++ fsrs_test.go | 2 +- parameters.go | 53 ++++++++++++++++- scheduler.go | 9 +++ scheduler_basic.go | 18 +++--- scheduler_longterm.go | 14 ++--- 6 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 area.go diff --git a/area.go b/area.go new file mode 100644 index 0000000..1cefbf8 --- /dev/null +++ b/area.go @@ -0,0 +1,128 @@ +package fsrs + +import ( + "math" + "strconv" + "time" +) + +type state struct { + C float64 + S0 float64 + S1 float64 + S2 float64 +} + +type alea struct { + c float64 + s0 float64 + s1 float64 + s2 float64 +} + +func NewAlea(seed interface{}) *alea { + mash := Mash() + a := &alea{ + c: 1, + s0: mash(" "), + s1: mash(" "), + s2: mash(" "), + } + + if seed == nil { + seed = time.Now().UnixNano() + } + + seedStr := "" + switch s := seed.(type) { + case int: + seedStr = strconv.Itoa(s) + case string: + seedStr = s + } + + a.s0 -= mash(seedStr) + if a.s0 < 0 { + a.s0 += 1 + } + a.s1 -= mash(seedStr) + if a.s1 < 0 { + a.s1 += 1 + } + a.s2 -= mash(seedStr) + if a.s2 < 0 { + a.s2 += 1 + } + + return a +} + +func (a *alea) Next() float64 { + t := 2091639*a.s0 + a.c*2.3283064365386963e-10 // 2^-32 + a.s0 = a.s1 + a.s1 = a.s2 + a.s2 = t - math.Floor(t) + a.c = math.Floor(t) + return a.s2 +} + +func (a *alea) SetState(state state) { + a.c = state.C + a.s0 = state.S0 + a.s1 = state.S1 + a.s2 = state.S2 +} + +func (a *alea) GetState() state { + return state{ + C: a.c, + S0: a.s0, + S1: a.s1, + S2: a.s2, + } +} + +func Mash() func(string) float64 { + n := uint32(0xefc8249d) + return func(data string) float64 { + for i := 0; i < len(data); i++ { + n += uint32(data[i]) + h := 0.02519603282416938 * float64(n) + n = uint32(h) + h -= float64(n) + h *= float64(n) + n = uint32(h) + h -= float64(n) + n += uint32(h * 0x100000000) // 2^32 + } + return float64(n) * 2.3283064365386963e-10 // 2^-32 + } +} + +type PRNG func() float64 + +func Alea(seed interface{}) PRNG { + xg := NewAlea(seed) + prng := func() float64 { + return xg.Next() + } + + return prng +} + +func (prng PRNG) Int32() int32 { + return int32(prng() * 0x100000000) +} + +func (prng PRNG) Double() float64 { + return prng() + float64(uint32(prng()*0x200000))*1.1102230246251565e-16 // 2^-53 +} + +func (prng PRNG) State(xg *alea) state { + return xg.GetState() +} + +func (prng PRNG) ImportState(xg *alea, state state) PRNG { + xg.SetState(state) + return prng +} diff --git a/fsrs_test.go b/fsrs_test.go index 8796eea..f4459f9 100644 --- a/fsrs_test.go +++ b/fsrs_test.go @@ -103,7 +103,7 @@ func TestNextInterval(t *testing.T) { var ivlList []float64 for i := 1; i <= 10; i++ { fsrs.RequestRetention = float64(i) / 10 - ivlList = append(ivlList, fsrs.nextInterval(1)) + ivlList = append(ivlList, fsrs.nextInterval(1, 0)) } wantIvlList := []float64{422, 102, 43, 22, 13, 8, 4, 2, 1, 1} if !reflect.DeepEqual(ivlList, wantIvlList) { diff --git a/parameters.go b/parameters.go index 50eee76..9350712 100644 --- a/parameters.go +++ b/parameters.go @@ -11,6 +11,8 @@ type Parameters struct { Decay float64 `json:"Decay"` Factor float64 `json:"Factor"` EnableShortTerm bool `json:"EnableShortTerm"` + EnableFuzz bool `json:"EnableFuzz"` + seed string } func DefaultParam() Parameters { @@ -23,6 +25,7 @@ func DefaultParam() Parameters { Decay: Decay, Factor: Factor, EnableShortTerm: true, + EnableFuzz: false, } } @@ -37,13 +40,26 @@ func (p *Parameters) initDifficulty(r Rating) float64 { return constrainDifficulty(p.W[4] - math.Exp(p.W[5]*float64(r-1)) + 1) } +func (p *Parameters) ApplyFuzz(ivl float64, elapsedDays float64, enableFuzz bool) float64 { + if !enableFuzz || ivl < 2.5 { + return ivl + } + + generator := Alea(p.seed) + fuzzFactor := generator.Double() + + minIvl, maxIvl := getFuzzRange(ivl, elapsedDays, p.MaximumInterval) + + return math.Floor(fuzzFactor*float64(maxIvl-minIvl+1)) + float64(minIvl) +} + func constrainDifficulty(d float64) float64 { return math.Min(math.Max(d, 1), 10) } -func (p *Parameters) nextInterval(s float64) float64 { +func (p *Parameters) nextInterval(s, elapsedDays float64) float64 { newInterval := s / p.Factor * (math.Pow(p.RequestRetention, 1/p.Decay) - 1) - return math.Max(math.Min(math.Round(newInterval), p.MaximumInterval), 1) + return p.ApplyFuzz(math.Max(math.Min(math.Round(newInterval), p.MaximumInterval), 1), elapsedDays, p.EnableFuzz) } func (p *Parameters) nextDifficulty(d float64, r Rating) float64 { @@ -85,3 +101,36 @@ func (p *Parameters) nextForgetStability(d float64, s float64, r float64) float6 (math.Pow(s+1, p.W[13]) - 1) * math.Exp((1-r)*p.W[14]) } + +type FuzzRange struct { + Start float64 + End float64 + Factor float64 +} + +var FUZZ_RANGES = []FuzzRange{ + {Start: 2.5, End: 7.0, Factor: 0.15}, + {Start: 7.0, End: 20.0, Factor: 0.1}, + {Start: 20.0, End: math.Inf(1), Factor: 0.05}, +} + +func getFuzzRange(interval, elapsedDays, maximumInterval float64) (minIvl, maxIvl int) { + delta := 1.0 + for _, r := range FUZZ_RANGES { + delta += r.Factor * math.Max(math.Min(interval, r.End)-r.Start, 0.0) + } + + interval = math.Min(interval, maximumInterval) + minIvlFloat := math.Max(2, math.Round(interval-delta)) + maxIvlFloat := math.Min(math.Round(interval+delta), maximumInterval) + + if interval > elapsedDays { + minIvlFloat = math.Max(minIvlFloat, elapsedDays+1) + } + minIvlFloat = math.Min(minIvlFloat, maxIvlFloat) + + minIvl = int(minIvlFloat) + maxIvl = int(maxIvlFloat) + + return minIvl, maxIvl +} diff --git a/scheduler.go b/scheduler.go index 1bda088..233f37e 100644 --- a/scheduler.go +++ b/scheduler.go @@ -1,6 +1,7 @@ package fsrs import ( + "fmt" "math" "time" ) @@ -45,6 +46,13 @@ func (s *Scheduler) Review(grade Rating) SchedulingInfo { return item } +func (s *Scheduler) initSeed() { + time := s.now + reps := s.current.Reps + mul := s.current.Difficulty * s.current.Stability + s.parameters.seed = fmt.Sprintf("%d_%d_%f", time.Unix(), reps, mul) +} + func (s *Scheduler) buildLog(rating Rating) ReviewLog { return ReviewLog{ Rating: rating, @@ -71,6 +79,7 @@ func (p *Parameters) newScheduler(card Card, now time.Time, newImpl func(s *Sche s.current.LastReview = s.now s.current.ElapsedDays = uint64(interval) s.current.Reps++ + s.initSeed() s.impl = newImpl(s) diff --git a/scheduler_basic.go b/scheduler_basic.go index faa6c46..6617321 100644 --- a/scheduler_basic.go +++ b/scheduler_basic.go @@ -43,6 +43,7 @@ func (bs basicScheduler) newState(grade Rating) SchedulingInfo { case Easy: easyInterval := bs.parameters.nextInterval( next.Stability, + float64(next.ElapsedDays), ) next.ScheduledDays = uint64(easyInterval) next.Due = bs.now.Add(time.Duration(easyInterval) * 24 * time.Hour) @@ -64,6 +65,7 @@ func (bs basicScheduler) learningState(grade Rating) SchedulingInfo { } next := bs.current + interval := float64(bs.current.ElapsedDays) next.Difficulty = bs.parameters.nextDifficulty(bs.last.Difficulty, grade) next.Stability = bs.parameters.shortTermStability(bs.last.Stability, grade) @@ -77,15 +79,15 @@ func (bs basicScheduler) learningState(grade Rating) SchedulingInfo { next.Due = bs.now.Add(10 * time.Minute) next.State = bs.last.State case Good: - goodInterval := bs.parameters.nextInterval(next.Stability) + goodInterval := bs.parameters.nextInterval(next.Stability, interval) next.ScheduledDays = uint64(goodInterval) next.Due = bs.now.Add(time.Duration(goodInterval) * 24 * time.Hour) next.State = Review case Easy: goodStability := bs.parameters.shortTermStability(bs.last.Stability, Good) - goodInterval := bs.parameters.nextInterval(goodStability) + goodInterval := bs.parameters.nextInterval(goodStability, interval) easyInterval := math.Max( - bs.parameters.nextInterval(next.Stability), + bs.parameters.nextInterval(next.Stability, interval), float64(goodInterval)+1, ) next.ScheduledDays = uint64(easyInterval) @@ -118,7 +120,7 @@ func (bs basicScheduler) reviewState(grade Rating) SchedulingInfo { nextEasy := bs.current bs.nextDs(&nextAgain, &nextHard, &nextGood, &nextEasy, difficulty, stability, retrievability) - bs.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy) + bs.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval) bs.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) nextAgain.Lapses++ @@ -149,13 +151,13 @@ func (bs basicScheduler) nextDs(nextAgain, nextHard, nextGood, nextEasy *Card, d nextEasy.Stability = bs.parameters.nextRecallStability(difficulty, stability, retrievability, Easy) } -func (bs basicScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card) { - hardInterval := bs.parameters.nextInterval(nextHard.Stability) - goodInterval := bs.parameters.nextInterval(nextGood.Stability) +func (bs basicScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card, elapsedDays float64) { + hardInterval := bs.parameters.nextInterval(nextHard.Stability, elapsedDays) + goodInterval := bs.parameters.nextInterval(nextGood.Stability, elapsedDays) hardInterval = math.Min(hardInterval, goodInterval) goodInterval = math.Max(goodInterval, hardInterval+1) easyInterval := math.Max( - bs.parameters.nextInterval(nextEasy.Stability), + bs.parameters.nextInterval(nextEasy.Stability, elapsedDays), goodInterval+1, ) diff --git a/scheduler_longterm.go b/scheduler_longterm.go index 0795306..a68f2ba 100644 --- a/scheduler_longterm.go +++ b/scheduler_longterm.go @@ -33,7 +33,7 @@ func (lts longTermScheduler) newState(grade Rating) SchedulingInfo { lts.initDs(&nextAgain, &nextHard, &nextGood, &nextEasy) - lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy) + lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, 0) lts.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) lts.updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) @@ -75,7 +75,7 @@ func (lts longTermScheduler) reviewState(grade Rating) SchedulingInfo { nextEasy := lts.current lts.nextDs(&nextAgain, &nextHard, &nextGood, &nextEasy, difficulty, stability, retrievability) - lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy) + lts.nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval) lts.nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) nextAgain.Lapses++ @@ -97,11 +97,11 @@ func (lts longTermScheduler) nextDs(nextAgain, nextHard, nextGood, nextEasy *Car nextEasy.Stability = lts.parameters.nextRecallStability(difficulty, stability, retrievability, Easy) } -func (lts longTermScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card) { - againInterval := lts.parameters.nextInterval(nextAgain.Stability) - hardInterval := lts.parameters.nextInterval(nextHard.Stability) - goodInterval := lts.parameters.nextInterval(nextGood.Stability) - easyInterval := lts.parameters.nextInterval(nextEasy.Stability) +func (lts longTermScheduler) nextInterval(nextAgain, nextHard, nextGood, nextEasy *Card, elapsedDays float64) { + againInterval := lts.parameters.nextInterval(nextAgain.Stability, elapsedDays) + hardInterval := lts.parameters.nextInterval(nextHard.Stability, elapsedDays) + goodInterval := lts.parameters.nextInterval(nextGood.Stability, elapsedDays) + easyInterval := lts.parameters.nextInterval(nextEasy.Stability, elapsedDays) againInterval = math.Min(againInterval, hardInterval) hardInterval = math.Max(hardInterval, againInterval+1)