"""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]