nz-nz Claude Opus 4.8 commited on
Commit
ab8e02f
·
1 Parent(s): 22364b0

[NAH-13] Weak-topic targeting: bias next_card toward low-grade topics

Browse files

next_card now looks at the next few due cards and surfaces the one whose topic
has the lowest average grade so far (when that topic is actually weak, avg < 3).
The chosen card is rotated to the front so apply_result's "pop the front"
contract still holds. No-op until there's answer history, so early cards keep
normal order.

Tests (test_weak_topic.py, no model): no-history order, weak topic surfaced,
lookahead window respected, apply_result contract preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. learning_engine.py +53 -4
  2. test_weak_topic.py +73 -0
learning_engine.py CHANGED
@@ -30,12 +30,28 @@ def init_session(deck: list[Card]) -> Session:
30
  )
31
 
32
 
 
 
 
 
33
  def next_card(session: Session) -> Card | None:
34
- """Pop the next due card id from the queue, biased toward weak topics."""
35
- if not session["queue"]:
 
 
 
 
 
 
 
 
 
36
  return None
37
- card_id = session["queue"][0]
38
- return _find(session, card_id)
 
 
 
39
 
40
 
41
  # ---- Grading ---------------------------------------------------------------
@@ -191,6 +207,39 @@ def _find(session: Session, card_id: str) -> Card | None:
191
  return next((c for c in session["deck"] if c["id"] == card_id), None)
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def _insert_at(session: Session, card_id: str, pos: int) -> None:
195
  pos = max(0, min(pos, len(session["queue"])))
196
  session["queue"].insert(pos, card_id)
 
30
  )
31
 
32
 
33
+ WEAK_TOPIC_THRESHOLD = 3.0 # avg grade below this = a topic the user is weak on
34
+ WEAK_LOOKAHEAD = 4 # how far down the queue we'll reach to surface a weak card
35
+
36
+
37
  def next_card(session: Session) -> Card | None:
38
+ """
39
+ Return the next card to study. Among the next few due cards we bias toward
40
+ the user's weakest topic (lowest average grade so far) — so once the model
41
+ sees you're shaky on a topic, that topic comes back sooner. With no history
42
+ yet this is a no-op and we serve the queue in order.
43
+
44
+ The chosen card is rotated to the front of the queue so `apply_result`'s
45
+ "pop the front" contract still holds.
46
+ """
47
+ queue = session["queue"]
48
+ if not queue:
49
  return None
50
+
51
+ idx = _weak_biased_index(session)
52
+ if idx > 0:
53
+ queue.insert(0, queue.pop(idx)) # bring the weak-topic card to the front
54
+ return _find(session, queue[0])
55
 
56
 
57
  # ---- Grading ---------------------------------------------------------------
 
207
  return next((c for c in session["deck"] if c["id"] == card_id), None)
208
 
209
 
210
+ def _topic_averages(session: Session) -> dict[str, float]:
211
+ """Average grade per topic across answered history (empty until first answer)."""
212
+ grades: dict[str, list[int]] = {}
213
+ for h in session["history"]:
214
+ grades.setdefault(h["topic"], []).append(h["grade"])
215
+ return {t: _avg(g) for t, g in grades.items()}
216
+
217
+
218
+ def _weak_biased_index(session: Session) -> int:
219
+ """
220
+ Index into the queue of the card to serve next. Looks at the next
221
+ WEAK_LOOKAHEAD cards and picks the one whose topic has the lowest average
222
+ grade, as long as that topic is actually weak (avg < threshold). Returns 0
223
+ (keep normal order) when nothing in reach is weak or there's no history yet.
224
+ """
225
+ queue = session["queue"]
226
+ averages = _topic_averages(session)
227
+ if not averages:
228
+ return 0
229
+
230
+ best_idx, best_avg = 0, None
231
+ for i, card_id in enumerate(queue[:WEAK_LOOKAHEAD]):
232
+ card = _find(session, card_id)
233
+ if card is None:
234
+ continue
235
+ avg = averages.get(card["topic"])
236
+ if avg is None or avg >= WEAK_TOPIC_THRESHOLD:
237
+ continue
238
+ if best_avg is None or avg < best_avg:
239
+ best_idx, best_avg = i, avg
240
+ return best_idx
241
+
242
+
243
  def _insert_at(session: Session, card_id: str, pos: int) -> None:
244
  pos = max(0, min(pos, len(session["queue"])))
245
  session["queue"].insert(pos, card_id)
test_weak_topic.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAH-13 — weak-topic targeting: next_card biases toward low-average-grade topics.
3
+
4
+ No model needed (pure scheduling logic). Run: python3 test_weak_topic.py
5
+ """
6
+ import learning_engine as le
7
+ from schema import new_card
8
+
9
+
10
+ def _deck():
11
+ return [
12
+ new_card("strong q1", "a", topic="Algebra", card_id="alg1"),
13
+ new_card("strong q2", "a", topic="Algebra", card_id="alg2"),
14
+ new_card("weak q1", "a", topic="Geometry", card_id="geo1"),
15
+ new_card("weak q2", "a", topic="Geometry", card_id="geo2"),
16
+ ]
17
+
18
+
19
+ def test_no_history_keeps_order():
20
+ s = le.init_session(_deck())
21
+ # No answers yet -> serve the queue head untouched.
22
+ assert le.next_card(s)["id"] == "alg1"
23
+ assert s["queue"][0] == "alg1"
24
+ print("ok no history -> normal queue order")
25
+
26
+
27
+ def test_weak_topic_surfaces_first():
28
+ s = le.init_session(_deck())
29
+ # Simulate: aced Algebra, bombed Geometry.
30
+ s["history"] = [
31
+ {"card_id": "alg1", "topic": "Algebra", "grade": 5, "user_answer": ""},
32
+ {"card_id": "geo1", "topic": "Geometry", "grade": 1, "user_answer": ""},
33
+ ]
34
+ # Queue still leads with an Algebra card, but a weak Geometry card is in reach.
35
+ s["queue"] = ["alg2", "geo2", "alg1"]
36
+ card = le.next_card(s)
37
+ assert card["topic"] == "Geometry", f"expected weak topic first, got {card}"
38
+ assert s["queue"][0] == card["id"], "chosen card must be rotated to the front"
39
+ print("ok weak topic (Geometry) surfaced ahead of strong topic")
40
+
41
+
42
+ def test_lookahead_window_respected():
43
+ s = le.init_session(_deck())
44
+ s["history"] = [{"card_id": "geo1", "topic": "Geometry", "grade": 0, "user_answer": ""}]
45
+ # Weak Geometry card sits beyond the lookahead window -> not pulled forward.
46
+ s["queue"] = ["alg1", "alg2", "alg2", "alg1", "geo2"]
47
+ le.WEAK_LOOKAHEAD # window is 4; geo2 is at index 4
48
+ card = le.next_card(s)
49
+ assert card["topic"] == "Algebra", "weak card outside window should not jump"
50
+ print("ok weak card beyond lookahead window left in place")
51
+
52
+
53
+ def test_apply_result_contract_holds():
54
+ # next_card rotates the served card to front; apply_result pops the front.
55
+ s = le.init_session(_deck())
56
+ s["history"] = [{"card_id": "geo1", "topic": "Geometry", "grade": 1, "user_answer": ""}]
57
+ s["queue"] = ["alg1", "geo2"]
58
+ card = le.next_card(s)
59
+ assert card["id"] == "geo2"
60
+ before = len(s["queue"])
61
+ le.apply_result(s, card, {"score": 4, "correct": True,
62
+ "explanation": "", "missed_concept": ""})
63
+ assert "geo2" not in s["queue"][:1], "served card should be popped from front"
64
+ assert len(s["queue"]) == before, "popped one, re-inserted one"
65
+ print("ok apply_result still pops the served card from the front")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ test_no_history_keeps_order()
70
+ test_weak_topic_surfaces_first()
71
+ test_lookahead_window_respected()
72
+ test_apply_result_contract_holds()
73
+ print("\nAll NAH-13 weak-topic tests passed.")