Download mathly2/learning.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 6.03 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/learning.py
- Command line
-
hf download hf://spaces/wzender/mathly/mathly2/learning.py
-
curl -L -o learning.py https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/learning.py
6.03 kB
| """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 | |