Spaces:
Sleeping
Sleeping
Download engine.py from SoulFireMage/math-sight-reading: direct link, hf CLI and curl.
- Browser
- Download file 6.42 kB
-
https://huggingface.co/spaces/SoulFireMage/math-sight-reading/resolve/main/engine.py
- Command line
-
hf download hf://spaces/SoulFireMage/math-sight-reading/engine.py
-
curl -L -o engine.py https://huggingface.co/spaces/SoulFireMage/math-sight-reading/resolve/main/engine.py
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] | |