dsap-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dsap/__init__.py +21 -0
- dsap/__main__.py +9 -0
- dsap/cli.py +618 -0
- dsap/config.py +158 -0
- dsap/data/blind75.yaml +405 -0
- dsap/data/grind75.yaml +401 -0
- dsap/data/neetcode150.yaml +796 -0
- dsap/database.py +774 -0
- dsap/models.py +225 -0
- dsap/problem_sets.py +195 -0
- dsap/py.typed +0 -0
- dsap/sm2.py +521 -0
- dsap/ui.py +445 -0
- dsap_cli-0.1.0.dist-info/METADATA +287 -0
- dsap_cli-0.1.0.dist-info/RECORD +18 -0
- dsap_cli-0.1.0.dist-info/WHEEL +4 -0
- dsap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dsap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dsap/sm2.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""SM-2 Spaced Repetition Algorithm - Implementation from Scratch.
|
|
2
|
+
|
|
3
|
+
==================================================================
|
|
4
|
+
|
|
5
|
+
This module implements the SM-2 algorithm created by Piotr Wozniak in 1987
|
|
6
|
+
for the SuperMemo software. It's the foundation of modern spaced repetition
|
|
7
|
+
systems like Anki.
|
|
8
|
+
|
|
9
|
+
THE PROBLEM SM-2 SOLVES
|
|
10
|
+
-----------------------
|
|
11
|
+
When learning, we face two challenges:
|
|
12
|
+
1. We forget things over time (forgetting curve)
|
|
13
|
+
2. Reviewing too often wastes time; too rarely means we forget
|
|
14
|
+
|
|
15
|
+
SM-2 calculates the OPTIMAL time to review each item - right before you'd
|
|
16
|
+
forget it. This maximizes retention while minimizing study time.
|
|
17
|
+
|
|
18
|
+
HOW IT WORKS - THE THREE CORE VALUES
|
|
19
|
+
------------------------------------
|
|
20
|
+
For each item (in our case, each DSA problem), we track:
|
|
21
|
+
|
|
22
|
+
1. EASINESS FACTOR (EF)
|
|
23
|
+
- A number starting at 2.5
|
|
24
|
+
- Represents how "easy" this item is for YOU
|
|
25
|
+
- Higher EF = easier = longer intervals between reviews
|
|
26
|
+
- Lower EF = harder = shorter intervals (more practice needed)
|
|
27
|
+
- Minimum is 1.3 (prevents intervals from becoming too short)
|
|
28
|
+
|
|
29
|
+
2. INTERVAL
|
|
30
|
+
- Days until the next review
|
|
31
|
+
- Grows exponentially for easy items
|
|
32
|
+
- Resets to 1 day when you fail
|
|
33
|
+
|
|
34
|
+
3. REPETITIONS
|
|
35
|
+
- Count of consecutive successful recalls
|
|
36
|
+
- Resets to 0 when you fail
|
|
37
|
+
- Determines which interval formula to use
|
|
38
|
+
|
|
39
|
+
THE QUALITY RATING SYSTEM
|
|
40
|
+
-------------------------
|
|
41
|
+
After each review, you rate your recall quality from 0-5:
|
|
42
|
+
|
|
43
|
+
5 = Perfect - Instant, confident recall. No hesitation.
|
|
44
|
+
4 = Good - Correct answer, but had to think about it.
|
|
45
|
+
3 = Hard - Correct, but with significant difficulty.
|
|
46
|
+
2 = Forgot - Wrong answer, but the solution seemed familiar.
|
|
47
|
+
1 = Blackout - Wrong, but recognized the answer when shown.
|
|
48
|
+
0 = Total - Complete failure. No memory at all.
|
|
49
|
+
|
|
50
|
+
Ratings 3-5 count as SUCCESS -> advance to next interval
|
|
51
|
+
Ratings 0-2 count as FAILURE -> reset and relearn
|
|
52
|
+
|
|
53
|
+
THE MATH EXPLAINED
|
|
54
|
+
------------------
|
|
55
|
+
|
|
56
|
+
### Interval Calculation
|
|
57
|
+
|
|
58
|
+
For successful reviews (quality >= 3):
|
|
59
|
+
|
|
60
|
+
n=1 (first success): interval = 1 day
|
|
61
|
+
n=2 (second success): interval = 6 days
|
|
62
|
+
n>2 (subsequent): interval = previous_interval * EF
|
|
63
|
+
|
|
64
|
+
Example progression with EF=2.5:
|
|
65
|
+
Review 1: 1 day
|
|
66
|
+
Review 2: 6 days
|
|
67
|
+
Review 3: 6 * 2.5 = 15 days
|
|
68
|
+
Review 4: 15 * 2.5 = 38 days
|
|
69
|
+
Review 5: 38 * 2.5 = 95 days (~3 months!)
|
|
70
|
+
|
|
71
|
+
### Easiness Factor Update
|
|
72
|
+
|
|
73
|
+
After each review, EF is adjusted based on quality:
|
|
74
|
+
|
|
75
|
+
EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
|
|
76
|
+
|
|
77
|
+
Let's unpack this formula step by step:
|
|
78
|
+
|
|
79
|
+
1. (5 - q) = "distance from perfect"
|
|
80
|
+
- If q=5: 5-5 = 0 (perfect!)
|
|
81
|
+
- If q=3: 5-3 = 2 (struggled)
|
|
82
|
+
- If q=0: 5-0 = 5 (complete failure)
|
|
83
|
+
|
|
84
|
+
2. (0.08 + (5-q) * 0.02) = "penalty multiplier"
|
|
85
|
+
- Increases as quality decreases
|
|
86
|
+
- q=5: 0.08 + 0*0.02 = 0.08
|
|
87
|
+
- q=3: 0.08 + 2*0.02 = 0.12
|
|
88
|
+
- q=0: 0.08 + 5*0.02 = 0.18
|
|
89
|
+
|
|
90
|
+
3. (5-q) * penalty = "total penalty"
|
|
91
|
+
- q=5: 0 * 0.08 = 0.00
|
|
92
|
+
- q=4: 1 * 0.10 = 0.10
|
|
93
|
+
- q=3: 2 * 0.12 = 0.24
|
|
94
|
+
- q=2: 3 * 0.14 = 0.42
|
|
95
|
+
- q=1: 4 * 0.16 = 0.64
|
|
96
|
+
- q=0: 5 * 0.18 = 0.90
|
|
97
|
+
|
|
98
|
+
4. 0.1 - penalty = "EF adjustment"
|
|
99
|
+
- q=5: 0.1 - 0.00 = +0.10 (EF increases!)
|
|
100
|
+
- q=4: 0.1 - 0.10 = +0.00 (no change)
|
|
101
|
+
- q=3: 0.1 - 0.24 = -0.14 (EF decreases)
|
|
102
|
+
- q=2: 0.1 - 0.42 = -0.32
|
|
103
|
+
- q=1: 0.1 - 0.64 = -0.54
|
|
104
|
+
- q=0: 0.1 - 0.90 = -0.80
|
|
105
|
+
|
|
106
|
+
So a perfect recall (5) makes the item "easier" (longer future intervals),
|
|
107
|
+
while struggling (3) or failing (0-2) makes it "harder" (shorter intervals).
|
|
108
|
+
|
|
109
|
+
WHY THESE SPECIFIC NUMBERS?
|
|
110
|
+
---------------------------
|
|
111
|
+
Wozniak determined these values through extensive experimentation in the
|
|
112
|
+
1980s. The goals:
|
|
113
|
+
- 1 day: Minimum useful gap to confirm initial learning
|
|
114
|
+
- 6 days: Optimal second interval for most material
|
|
115
|
+
- EF of 2.5: Reasonable starting point for unknown difficulty
|
|
116
|
+
- Minimum EF of 1.3: Prevents impractically short intervals
|
|
117
|
+
|
|
118
|
+
References:
|
|
119
|
+
----------
|
|
120
|
+
- Original paper: https://www.supermemo.com/en/blog/application-of-a-computer-to-improve-the-results-obtained-in-working-with-the-supermemo-method
|
|
121
|
+
- Wikipedia: https://en.wikipedia.org/wiki/SuperMemo#Description_of_SM-2_algorithm
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
from dataclasses import dataclass
|
|
125
|
+
from datetime import datetime, timedelta
|
|
126
|
+
from enum import IntEnum
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Quality(IntEnum):
|
|
130
|
+
"""Quality grades for SM-2 algorithm.
|
|
131
|
+
|
|
132
|
+
The quality of recall is rated 0-5. This scale measures how well
|
|
133
|
+
you remembered the solution approach, not just whether you got
|
|
134
|
+
it right or wrong.
|
|
135
|
+
|
|
136
|
+
For DSA problems, think of it this way:
|
|
137
|
+
- 5: "I immediately knew the approach and could code it"
|
|
138
|
+
- 4: "I knew the approach but had to think through details"
|
|
139
|
+
- 3: "I eventually figured it out but struggled"
|
|
140
|
+
- 2: "I couldn't solve it, but the solution made sense"
|
|
141
|
+
- 1: "I couldn't solve it, and barely understood the solution"
|
|
142
|
+
- 0: "I had no idea what to do and the solution confused me"
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
PERFECT = 5 # Instant recall, no hesitation
|
|
146
|
+
GOOD = 4 # Correct but required thought
|
|
147
|
+
HARD = 3 # Correct with serious difficulty
|
|
148
|
+
FORGOT = 2 # Wrong, but answer seemed familiar
|
|
149
|
+
BLACKOUT = 1 # Wrong, recognized answer when shown
|
|
150
|
+
TOTAL_BLACKOUT = 0 # Complete failure, no recognition
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def is_successful(cls, quality: int) -> bool:
|
|
154
|
+
"""Determine if a quality rating counts as successful recall.
|
|
155
|
+
|
|
156
|
+
In SM-2, quality >= 3 is considered successful.
|
|
157
|
+
This advances the repetition count and extends intervals.
|
|
158
|
+
Quality < 3 is a failure, which resets the learning process.
|
|
159
|
+
"""
|
|
160
|
+
return quality >= 3
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def description(cls, quality: int) -> str:
|
|
164
|
+
"""Get a human-readable description for a quality rating."""
|
|
165
|
+
descriptions = {
|
|
166
|
+
5: "Perfect - instant recall",
|
|
167
|
+
4: "Good - correct with thought",
|
|
168
|
+
3: "Hard - struggled but solved",
|
|
169
|
+
2: "Forgot - wrong but familiar",
|
|
170
|
+
1: "Blackout - wrong, recognized after",
|
|
171
|
+
0: "Total blackout - no memory",
|
|
172
|
+
}
|
|
173
|
+
return descriptions.get(quality, "Unknown")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Default values as constants with explanatory names
|
|
177
|
+
DEFAULT_EASINESS_FACTOR = 2.5 # Standard starting EF for new items
|
|
178
|
+
MINIMUM_EASINESS_FACTOR = 1.3 # Floor to prevent EF collapse
|
|
179
|
+
|
|
180
|
+
# Interval constants (in days)
|
|
181
|
+
FIRST_INTERVAL = 1 # See again tomorrow after first success
|
|
182
|
+
SECOND_INTERVAL = 6 # About a week after second success
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class SM2State:
|
|
187
|
+
"""Represents the SM-2 learning state for a single item.
|
|
188
|
+
|
|
189
|
+
This is the core data structure that tracks where you are in the
|
|
190
|
+
learning process for each problem.
|
|
191
|
+
|
|
192
|
+
Attributes:
|
|
193
|
+
easiness_factor: How "easy" this item is for you (default 2.5).
|
|
194
|
+
Higher = easier = longer review intervals.
|
|
195
|
+
Range: 1.3 to ~4.0 typically.
|
|
196
|
+
|
|
197
|
+
interval: Days until the next scheduled review.
|
|
198
|
+
0 means not yet reviewed or just failed.
|
|
199
|
+
|
|
200
|
+
repetitions: Count of consecutive successful recalls.
|
|
201
|
+
Resets to 0 on failure.
|
|
202
|
+
Determines which interval formula to use.
|
|
203
|
+
|
|
204
|
+
next_review: The date when this item should next be reviewed.
|
|
205
|
+
None if never reviewed.
|
|
206
|
+
|
|
207
|
+
last_reviewed: Date of the most recent review.
|
|
208
|
+
Useful for tracking and statistics.
|
|
209
|
+
|
|
210
|
+
Example lifecycle:
|
|
211
|
+
Initial: EF=2.5, interval=0, reps=0
|
|
212
|
+
After q=4: EF=2.5, interval=1, reps=1 (see tomorrow)
|
|
213
|
+
After q=5: EF=2.6, interval=6, reps=2 (see in 6 days)
|
|
214
|
+
After q=4: EF=2.6, interval=16, reps=3 (see in 16 days)
|
|
215
|
+
After q=2: EF=2.28, interval=1, reps=0 (FAILED! reset!)
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
easiness_factor: float = DEFAULT_EASINESS_FACTOR
|
|
219
|
+
interval: int = 0
|
|
220
|
+
repetitions: int = 0
|
|
221
|
+
next_review: datetime | None = None
|
|
222
|
+
last_reviewed: datetime | None = None
|
|
223
|
+
|
|
224
|
+
def is_new(self) -> bool:
|
|
225
|
+
"""Check if this item has never been reviewed."""
|
|
226
|
+
return self.repetitions == 0 and self.last_reviewed is None
|
|
227
|
+
|
|
228
|
+
def is_due(self, now: datetime | None = None) -> bool:
|
|
229
|
+
"""Check if this item is due for review.
|
|
230
|
+
|
|
231
|
+
An item is due if:
|
|
232
|
+
- It has never been reviewed (next_review is None), OR
|
|
233
|
+
- The current time is past the scheduled next_review
|
|
234
|
+
"""
|
|
235
|
+
if now is None:
|
|
236
|
+
now = datetime.now()
|
|
237
|
+
|
|
238
|
+
if self.next_review is None:
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
return now >= self.next_review
|
|
242
|
+
|
|
243
|
+
def days_until_review(self, now: datetime | None = None) -> int:
|
|
244
|
+
"""Calculate days until this item is due.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Positive: days until due
|
|
248
|
+
Zero: due today
|
|
249
|
+
Negative: days overdue
|
|
250
|
+
"""
|
|
251
|
+
if now is None:
|
|
252
|
+
now = datetime.now()
|
|
253
|
+
|
|
254
|
+
if self.next_review is None:
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
delta = self.next_review - now
|
|
258
|
+
return delta.days
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def calculate_easiness_factor(current_ef: float, quality: int) -> float:
|
|
262
|
+
"""Calculate the new Easiness Factor based on quality of recall.
|
|
263
|
+
|
|
264
|
+
This is the heart of SM-2's adaptivity. The EF adjusts based on
|
|
265
|
+
how well you performed, making the algorithm personalized to you.
|
|
266
|
+
|
|
267
|
+
THE FORMULA
|
|
268
|
+
-----------
|
|
269
|
+
EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
|
|
270
|
+
|
|
271
|
+
STEP-BY-STEP BREAKDOWN
|
|
272
|
+
----------------------
|
|
273
|
+
Let's trace through with q=3 (hard but correct):
|
|
274
|
+
|
|
275
|
+
1. distance = 5 - 3 = 2
|
|
276
|
+
"How far from perfect was this recall?"
|
|
277
|
+
|
|
278
|
+
2. penalty_rate = 0.08 + 2 * 0.02 = 0.12
|
|
279
|
+
"Base penalty + extra penalty for difficulty"
|
|
280
|
+
|
|
281
|
+
3. total_penalty = 2 * 0.12 = 0.24
|
|
282
|
+
"Distance times penalty rate"
|
|
283
|
+
|
|
284
|
+
4. adjustment = 0.1 - 0.24 = -0.14
|
|
285
|
+
"Net change to EF (negative = harder)"
|
|
286
|
+
|
|
287
|
+
5. new_ef = 2.5 + (-0.14) = 2.36
|
|
288
|
+
"Item is now considered harder"
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
current_ef: Current easiness factor (usually starts at 2.5)
|
|
292
|
+
quality: Quality of recall (0-5)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
New easiness factor, minimum 1.3
|
|
296
|
+
|
|
297
|
+
Examples:
|
|
298
|
+
>>> calculate_easiness_factor(2.5, 5) # Perfect
|
|
299
|
+
2.6
|
|
300
|
+
>>> calculate_easiness_factor(2.5, 4) # Good
|
|
301
|
+
2.5
|
|
302
|
+
>>> calculate_easiness_factor(2.5, 3) # Hard
|
|
303
|
+
2.36
|
|
304
|
+
>>> calculate_easiness_factor(2.5, 0) # Total failure
|
|
305
|
+
1.7
|
|
306
|
+
"""
|
|
307
|
+
if not 0 <= quality <= 5:
|
|
308
|
+
raise ValueError(f"Quality must be 0-5, got {quality}")
|
|
309
|
+
|
|
310
|
+
# Step 1: How far from perfect?
|
|
311
|
+
distance_from_perfect = 5 - quality
|
|
312
|
+
|
|
313
|
+
# Step 2: Calculate the penalty multiplier
|
|
314
|
+
# This increases as quality decreases, making the penalty harsher
|
|
315
|
+
penalty_multiplier = 0.08 + distance_from_perfect * 0.02
|
|
316
|
+
|
|
317
|
+
# Step 3: Calculate total penalty
|
|
318
|
+
total_penalty = distance_from_perfect * penalty_multiplier
|
|
319
|
+
|
|
320
|
+
# Step 4: Calculate EF adjustment
|
|
321
|
+
# 0.1 is the "reward" for any recall; penalty subtracts from it
|
|
322
|
+
ef_adjustment = 0.1 - total_penalty
|
|
323
|
+
|
|
324
|
+
# Step 5: Apply adjustment
|
|
325
|
+
new_ef = current_ef + ef_adjustment
|
|
326
|
+
|
|
327
|
+
# Enforce minimum EF of 1.3
|
|
328
|
+
# Below this, intervals grow too slowly to be useful
|
|
329
|
+
return max(MINIMUM_EASINESS_FACTOR, round(new_ef, 2))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def calculate_interval(
|
|
333
|
+
repetitions: int, previous_interval: int, easiness_factor: float
|
|
334
|
+
) -> int:
|
|
335
|
+
"""Calculate the next review interval in days.
|
|
336
|
+
|
|
337
|
+
THE INTERVAL PROGRESSION
|
|
338
|
+
------------------------
|
|
339
|
+
SM-2 uses a specific progression for the first two intervals,
|
|
340
|
+
then switches to exponential growth:
|
|
341
|
+
|
|
342
|
+
n=1 (first success): 1 day
|
|
343
|
+
Why? Confirms initial learning. Too long and you'd forget.
|
|
344
|
+
|
|
345
|
+
n=2 (second success): 6 days
|
|
346
|
+
Why? A week-ish gap tests medium-term retention.
|
|
347
|
+
|
|
348
|
+
n>2 (subsequent): previous_interval * EF
|
|
349
|
+
Why? Exponential growth with personalized rate.
|
|
350
|
+
|
|
351
|
+
EXAMPLE PROGRESSIONS
|
|
352
|
+
--------------------
|
|
353
|
+
Easy item (EF=2.8 after consistent 5s):
|
|
354
|
+
1 -> 6 -> 17 -> 48 -> 134 days
|
|
355
|
+
After 5 reviews, you only see it twice a year!
|
|
356
|
+
|
|
357
|
+
Hard item (EF=1.5 after consistent 3s):
|
|
358
|
+
1 -> 6 -> 9 -> 14 -> 21 days
|
|
359
|
+
Still progressing, but much slower.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
repetitions: Number of consecutive successful recalls (1-indexed)
|
|
363
|
+
previous_interval: The previous interval in days
|
|
364
|
+
easiness_factor: Current EF for this item
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Next interval in days (always at least 1)
|
|
368
|
+
|
|
369
|
+
Examples:
|
|
370
|
+
>>> calculate_interval(1, 0, 2.5) # First review
|
|
371
|
+
1
|
|
372
|
+
>>> calculate_interval(2, 1, 2.5) # Second review
|
|
373
|
+
6
|
|
374
|
+
>>> calculate_interval(3, 6, 2.5) # Third review
|
|
375
|
+
15
|
|
376
|
+
>>> calculate_interval(4, 15, 2.5) # Fourth review
|
|
377
|
+
38
|
|
378
|
+
"""
|
|
379
|
+
if repetitions <= 0:
|
|
380
|
+
# Not yet successfully reviewed
|
|
381
|
+
return 0
|
|
382
|
+
elif repetitions == 1:
|
|
383
|
+
# First successful review: see again tomorrow
|
|
384
|
+
return FIRST_INTERVAL
|
|
385
|
+
elif repetitions == 2:
|
|
386
|
+
# Second successful review: see again in ~a week
|
|
387
|
+
return SECOND_INTERVAL
|
|
388
|
+
else:
|
|
389
|
+
# Subsequent reviews: exponential growth
|
|
390
|
+
# Round to nearest day (intervals are always whole days)
|
|
391
|
+
return max(1, round(previous_interval * easiness_factor))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def process_review(state: SM2State, quality: int) -> SM2State:
|
|
395
|
+
"""Process a review and return the updated SM-2 state.
|
|
396
|
+
|
|
397
|
+
This is the main entry point for the SM-2 algorithm. Call this
|
|
398
|
+
after the user reviews a problem and provides their quality rating.
|
|
399
|
+
|
|
400
|
+
THE REVIEW PROCESS
|
|
401
|
+
------------------
|
|
402
|
+
1. Update EF based on quality (always, even on failure)
|
|
403
|
+
2. Check if this was a successful recall (quality >= 3)
|
|
404
|
+
3. If successful:
|
|
405
|
+
- Increment repetition counter
|
|
406
|
+
- Calculate new interval using progression
|
|
407
|
+
4. If failed:
|
|
408
|
+
- Reset repetitions to 0
|
|
409
|
+
- Reset interval to 1 day
|
|
410
|
+
5. Calculate the next review date
|
|
411
|
+
|
|
412
|
+
KEY INSIGHT: FAILURE HANDLING
|
|
413
|
+
-----------------------------
|
|
414
|
+
When you fail (quality < 3), we:
|
|
415
|
+
- Reset repetitions to 0 (start the progression over)
|
|
416
|
+
- Reset interval to 1 (see it again tomorrow)
|
|
417
|
+
- BUT keep the lowered EF (item is now marked as harder)
|
|
418
|
+
|
|
419
|
+
This means even after "relearning," a hard item will have shorter
|
|
420
|
+
intervals than an easy item because its EF is lower.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
state: Current SM2State for this item
|
|
424
|
+
quality: Quality of recall (0-5)
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
New SM2State with updated values (immutable pattern)
|
|
428
|
+
|
|
429
|
+
Examples:
|
|
430
|
+
>>> state = SM2State()
|
|
431
|
+
>>> new_state = process_review(state, 5)
|
|
432
|
+
>>> new_state.repetitions
|
|
433
|
+
1
|
|
434
|
+
>>> new_state.interval
|
|
435
|
+
1
|
|
436
|
+
>>> new_state.easiness_factor
|
|
437
|
+
2.6
|
|
438
|
+
|
|
439
|
+
>>> # After failing
|
|
440
|
+
>>> state = SM2State(repetitions=5, interval=30, easiness_factor=2.8)
|
|
441
|
+
>>> new_state = process_review(state, 2)
|
|
442
|
+
>>> new_state.repetitions
|
|
443
|
+
0
|
|
444
|
+
>>> new_state.interval
|
|
445
|
+
1
|
|
446
|
+
"""
|
|
447
|
+
if not 0 <= quality <= 5:
|
|
448
|
+
raise ValueError(f"Quality must be 0-5, got {quality}")
|
|
449
|
+
|
|
450
|
+
# Step 1: Always update EF based on performance
|
|
451
|
+
# This happens regardless of success/failure
|
|
452
|
+
new_ef = calculate_easiness_factor(state.easiness_factor, quality)
|
|
453
|
+
|
|
454
|
+
# Step 2: Determine if this was a successful recall
|
|
455
|
+
is_successful = Quality.is_successful(quality)
|
|
456
|
+
|
|
457
|
+
# Step 3 & 4: Update repetitions and interval
|
|
458
|
+
if is_successful:
|
|
459
|
+
# Success: progress to next interval
|
|
460
|
+
new_repetitions = state.repetitions + 1
|
|
461
|
+
new_interval = calculate_interval(new_repetitions, state.interval, new_ef)
|
|
462
|
+
else:
|
|
463
|
+
# Failure: reset to beginning
|
|
464
|
+
# Keep the lowered EF - don't reset that
|
|
465
|
+
new_repetitions = 0
|
|
466
|
+
new_interval = FIRST_INTERVAL # See again tomorrow
|
|
467
|
+
|
|
468
|
+
# Step 5: Calculate next review date
|
|
469
|
+
now = datetime.now()
|
|
470
|
+
next_review = now + timedelta(days=new_interval)
|
|
471
|
+
|
|
472
|
+
# Return new state (immutable pattern - return new object)
|
|
473
|
+
return SM2State(
|
|
474
|
+
easiness_factor=new_ef,
|
|
475
|
+
interval=new_interval,
|
|
476
|
+
repetitions=new_repetitions,
|
|
477
|
+
next_review=next_review,
|
|
478
|
+
last_reviewed=now,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def get_initial_state() -> SM2State:
|
|
483
|
+
"""Create a fresh SM-2 state for a new item.
|
|
484
|
+
|
|
485
|
+
This is the starting point for any new problem added to the system.
|
|
486
|
+
The item has never been reviewed and starts with default values.
|
|
487
|
+
"""
|
|
488
|
+
return SM2State()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def simulate_reviews(qualities: list[int]) -> list[tuple[int, SM2State]]:
|
|
492
|
+
"""Simulate a series of reviews for educational/testing purposes.
|
|
493
|
+
|
|
494
|
+
This is useful for understanding how SM-2 behaves over time.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
qualities: List of quality ratings (0-5) for each review
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
List of (review_number, state_after_review) tuples
|
|
501
|
+
|
|
502
|
+
Example:
|
|
503
|
+
>>> results = simulate_reviews([4, 5, 5, 3, 5])
|
|
504
|
+
>>> for i, state in results:
|
|
505
|
+
... print(
|
|
506
|
+
... f"Review {i}: interval={state.interval}, EF={state.easiness_factor}"
|
|
507
|
+
... )
|
|
508
|
+
Review 1: interval=1, EF=2.5
|
|
509
|
+
Review 2: interval=6, EF=2.6
|
|
510
|
+
Review 3: interval=16, EF=2.7
|
|
511
|
+
Review 4: interval=22, EF=2.56
|
|
512
|
+
Review 5: interval=58, EF=2.66
|
|
513
|
+
"""
|
|
514
|
+
state = get_initial_state()
|
|
515
|
+
results = []
|
|
516
|
+
|
|
517
|
+
for i, quality in enumerate(qualities, 1):
|
|
518
|
+
state = process_review(state, quality)
|
|
519
|
+
results.append((i, state))
|
|
520
|
+
|
|
521
|
+
return results
|