Spaces:
Runtime error
Runtime error
[NAH-13] Weak-topic targeting: bias next_card toward low-grade topics
Browse filesnext_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>
- learning_engine.py +53 -4
- 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 |
-
"""
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return None
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 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.")
|