File size: 6,032 Bytes
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The rules of skill learning. Pure functions plus `record_practice_answer`; they work on any store.
Every day is decided by the server (`clock.school_day`), never by the device."""
from __future__ import annotations

import random
from datetime import date, timedelta

MASTERY_RUN = 3        # correct answers in a row...
MASTERY_DAYS = 2       # ...spread over at least this many different calendar days
FADE_DAYS = 14         # a mastered skill not answered for this many days shows a faded star (a visual cue only)
DAILY_GOAL = 5         # correct answers a day that count toward the streak

NOT_STARTED, LEARNING, MASTERED = "not_started", "learning", "mastered"


def is_mastered(last_counted: list[dict]) -> bool:
    """`last_counted`: the student's counted practice answers on a skill, oldest first.
    Mastered when the last 3 are all correct and fall on 2 or more different days.
    Answers given with help are not in the list: they neither extend nor break the run."""
    run = last_counted[-MASTERY_RUN:]
    return (len(run) == MASTERY_RUN and all(a["correct"] for a in run)
            and len({a["day"] for a in run}) >= MASTERY_DAYS)


def skill_state(progress: dict | None) -> str:
    if progress and progress.get("mastered_at"):
        return MASTERED                      # never decays: nothing later can clear mastered_at
    if progress and progress.get("started_at"):
        return LEARNING
    return NOT_STARTED


def mark_started(store, user_id: str, skill_id: str, now: str) -> dict:
    """A first answer moves a skill out of 'not started'."""
    progress = store.get_progress(user_id, skill_id)
    if progress and progress.get("started_at"):
        return progress
    return store.save_progress(user_id, skill_id, started_at=now)


def record_practice_answer(store, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
                           mistake_id: str | None, day: date, now: str) -> dict:
    """Store one practice answer, then set `mastered_at` the first time the mastery rule is met.
    Returns the progress row."""
    mark_started(store, user_id, skill_id, now)
    store.add_answer(user_id, skill_id, question_key, correct, counted, mistake_id, day)
    progress = store.get_progress(user_id, skill_id)
    if not progress.get("mastered_at") and is_mastered(store.last_counted_answers(user_id, skill_id, MASTERY_RUN)):
        progress = store.save_progress(user_id, skill_id, mastered_at=now)
    return progress


def answer_counts(rows: list[dict]) -> dict[str, int]:
    """How often each question was answered, from the student's answers on a skill."""
    counts: dict[str, int] = {}
    for r in rows:
        counts[r["question_key"]] = counts.get(r["question_key"], 0) + 1
    return counts


def solved_keys(rows: list[dict]) -> set[str]:
    """The questions answered correctly at least once (with or without help)."""
    return {r["question_key"] for r in rows if r["correct"]}


def last_counted(rows: list[dict]) -> list[dict]:
    """The last MASTERY_RUN answers that were not given with help, oldest first: what the mastery rule looks at."""
    return [r for r in rows if r["counted"]][-MASTERY_RUN:]


def current_round(keys: list[str], rows: list[dict], round_start: int | None) -> dict:
    """The round in progress on a skill: its practice questions (`keys`), each answered once. `rows` are all the student's answers on the
    skill, oldest first, and the round is the answers from `round_start` on (null: the student practised before rounds existed, so all
    their answers are past rounds and a new one begins). Returns the answers of the round in order, the questions not answered yet and
    whether the round is done."""
    wanted, seen, taken = set(keys), set(), []
    for row in (rows[round_start:] if round_start is not None else []):
        if row["question_key"] in wanted and row["question_key"] not in seen:
            seen.add(row["question_key"])
            taken.append(row)
    left = [k for k in keys if k not in seen]
    return {"answers": taken, "left": left, "done": bool(keys) and not left}


def next_in_round(left: list[str], just_answered: str | None = None, rng: random.Random | None = None) -> str | None:
    """The next question of the round: one not answered yet, at random; none when the round is done."""
    candidates = [k for k in left if k != just_answered]
    return (rng or random).choice(candidates) if candidates else None


def next_skill(order: list[str], states: dict[str, str], recent: list[str]) -> str | None:
    """The suggested next skill: the unmastered skill the student answered most recently (`recent`, newest first), else the
    first unmastered skill in `order` (the bank's order, among the skills the student can see). None when everything is mastered.
    Only a suggestion: nothing is locked, hidden or reordered by it."""
    visible = set(order)
    for skill_id in recent:
        if skill_id in visible and states.get(skill_id) != MASTERED:
            return skill_id
    return next((skill_id for skill_id in order if states.get(skill_id) != MASTERED), None)


def is_faded(state: str, last_answered: date | None, today: date) -> bool:
    """A faded star: mastered, and not answered for FADE_DAYS days. It never changes the state itself."""
    return state == MASTERED and last_answered is not None and (today - last_answered).days >= FADE_DAYS


def today_progress(daily: dict[date, int], today: date) -> dict:
    done = daily.get(today, 0)
    return {"done": done, "goal": DAILY_GOAL, "reached": done >= DAILY_GOAL}


def streak(daily: dict[date, int], today: date) -> int:
    """Consecutive days with at least the daily goal. Today counts once reached; until then the streak is kept
    (it is still alive) and counted up to yesterday."""
    day = today if daily.get(today, 0) >= DAILY_GOAL else today - timedelta(days=1)
    days = 0
    while daily.get(day, 0) >= DAILY_GOAL:
        days += 1
        day -= timedelta(days=1)
    return days