Spaces:
Sleeping
Sleeping
File size: 6,421 Bytes
84b770a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | """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]
|