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/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