math-sight-reading / engine.py
SoulFireMage's picture
Scoring, tiers, spaced repetition
84b770a verified
Raw History Blame Contribute Delete
6.42 kB
"""Pure logic for MSRF: scoring, tier adaptation, spaced repetition, stats.
No I/O and no Gradio here, so it can be unit-tested directly. Progress is a
plain JSON-serialisable dict that the app keeps in the browser.
"""
import random
CONCEPT_LEVELS = {"Superficial": 0, "Competent": 1, "Exemplary": 2}
TRANSFER_LEVELS = {"C": 0, "B": 1, "A": 2}
HINT_PENALTY = 0.1 # composite points lost per hint level used
PROMOTE_AT = 0.75 # rolling mean composite needed to move up a tier
DEMOTE_BELOW = 0.40 # rolling mean composite that moves you down
WINDOW = 4 # attempts considered at the current tier
MIN_ATTEMPTS = 3 # attempts needed at a tier before it can change
BOX_INTERVALS = [2, 4, 8, 16, 32] # drills until a card is due again, by Leitner box
GOOD, POOR = 0.70, 0.50 # composite thresholds for box up / box reset
MAX_HISTORY = 5000
def new_progress():
return {"v": 1, "tier": 1, "history": [], "cards": {}}
def normalize(progress):
"""Accept whatever came out of browser storage and return a valid dict."""
if not isinstance(progress, dict):
return new_progress()
out = new_progress()
tier = progress.get("tier")
if isinstance(tier, int) and 1 <= tier <= 4:
out["tier"] = tier
hist = progress.get("history")
if isinstance(hist, list):
for h in hist[-MAX_HISTORY:]:
if (
isinstance(h, dict)
and isinstance(h.get("id"), str)
and isinstance(h.get("tier"), int)
and isinstance(h.get("precision"), (int, float))
and h.get("concept") in CONCEPT_LEVELS
and h.get("transfer") in TRANSFER_LEVELS
):
out["history"].append(
{
"ts": h.get("ts", 0) if isinstance(h.get("ts", 0), (int, float)) else 0,
"id": h["id"],
"tier": h["tier"],
"precision": max(0, min(10, int(h["precision"]))),
"concept": h["concept"],
"transfer": h["transfer"],
"hints": max(0, min(2, int(h.get("hints", 0) or 0))),
"composite": float(h.get("composite", 0.0) or 0.0),
}
)
cards = progress.get("cards")
if isinstance(cards, dict):
for cid, c in cards.items():
if isinstance(c, dict) and isinstance(c.get("box"), int) and isinstance(c.get("due"), int):
out["cards"][cid] = {"box": max(0, min(4, c["box"])), "due": c["due"]}
return out
def composite(precision, concept, transfer, hints=0):
"""0..1 summary of one attempt. Concept and transfer are rescaled to 0..1."""
base = (precision / 10 + CONCEPT_LEVELS[concept] / 2 + TRANSFER_LEVELS[transfer] / 2) / 3
return round(max(0.0, base - HINT_PENALTY * hints), 4)
def record_attempt(progress, formula, scores, hints, now=0):
"""Return a new progress dict with this attempt recorded and tier adapted."""
p = normalize(progress)
comp = composite(scores["precision"], scores["concept"], scores["transfer"], hints)
p["history"].append(
{
"ts": now,
"id": formula["id"],
"tier": formula["tier"],
"precision": scores["precision"],
"concept": scores["concept"],
"transfer": scores["transfer"],
"hints": hints,
"composite": comp,
}
)
p["history"] = p["history"][-MAX_HISTORY:]
card = p["cards"].get(formula["id"], {"box": 0, "due": 0})
if comp >= GOOD:
card["box"] = min(4, card["box"] + 1)
elif comp < POOR:
card["box"] = 0
card["due"] = len(p["history"]) + BOX_INTERVALS[card["box"]]
p["cards"][formula["id"]] = card
p["tier"] = adapt_tier(p)
return p
def adapt_tier(p):
tier = p["tier"]
recent = [h["composite"] for h in p["history"] if h["tier"] == tier][-WINDOW:]
if len(recent) >= MIN_ATTEMPTS:
mean = sum(recent) / len(recent)
if mean >= PROMOTE_AT and tier < 4:
return tier + 1
if mean < DEMOTE_BELOW and tier > 1:
return tier - 1
return tier
def due_cards(progress, bank):
"""Ids whose review is due, most overdue first."""
p = normalize(progress)
counter = len(p["history"])
known = {f["id"] for f in bank}
due = [(c["due"] - counter, cid) for cid, c in p["cards"].items() if cid in known and c["due"] <= counter]
return [cid for _, cid in sorted(due)]
def choose_next(progress, bank, rng=None, exclude=None):
"""Pick the next formula: due reviews first (60%), then unseen at your tier,
then the weakest card at or below your tier."""
rng = rng or random
p = normalize(progress)
pool = [f for f in bank if f["id"] != exclude] or list(bank)
by_id = {f["id"]: f for f in pool}
seen = set(p["cards"])
due = [cid for cid in due_cards(p, bank) if cid in by_id]
if due and rng.random() < 0.6:
return by_id[due[0]]
unseen_here = [f for f in pool if f["tier"] == p["tier"] and f["id"] not in seen]
if unseen_here:
return rng.choice(unseen_here)
unseen_below = [f for f in pool if f["tier"] < p["tier"] and f["id"] not in seen]
if unseen_below:
return rng.choice(unseen_below)
eligible = [f for f in pool if f["tier"] <= p["tier"]] or pool
eligible.sort(key=lambda f: (p["cards"].get(f["id"], {"box": 0})["box"], rng.random()))
return eligible[0]
def stats_by_tag(progress, bank):
"""Rows of (tag, attempts, mean precision, mean concept 0-2, mean transfer 0-2),
weakest first, so the learner sees which notation families to practise."""
p = normalize(progress)
tags = {f["id"]: f.get("tags", []) for f in bank}
acc = {}
for h in p["history"]:
for t in tags.get(h["id"], []):
a = acc.setdefault(t, [0, 0.0, 0.0, 0.0, 0.0])
a[0] += 1
a[1] += h["precision"]
a[2] += CONCEPT_LEVELS[h["concept"]]
a[3] += TRANSFER_LEVELS[h["transfer"]]
a[4] += h["composite"]
rows = []
for t, (n, pr, co, tr, cm) in acc.items():
rows.append((cm / n, [t, n, round(pr / n, 1), round(co / n, 2), round(tr / n, 2)]))
rows.sort(key=lambda r: r[0])
return [r for _, r in rows]