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]