skillplay 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of skillplay might be problematic. Click here for more details.
- skillplay/__init__.py +3 -0
- skillplay/__main__.py +24 -0
- skillplay/core/__init__.py +39 -0
- skillplay/core/achievements.py +73 -0
- skillplay/core/adaptive.py +291 -0
- skillplay/core/capstone.py +140 -0
- skillplay/core/cli.py +823 -0
- skillplay/core/config.py +61 -0
- skillplay/core/demo.py +138 -0
- skillplay/core/engine.py +620 -0
- skillplay/core/exam.py +122 -0
- skillplay/core/generate.py +642 -0
- skillplay/core/goals.py +138 -0
- skillplay/core/hlr.py +73 -0
- skillplay/core/i18n.py +112 -0
- skillplay/core/leaderboard_server.py +288 -0
- skillplay/core/loader.py +214 -0
- skillplay/core/mentor.py +214 -0
- skillplay/core/progress.py +184 -0
- skillplay/core/registry.py +314 -0
- skillplay/core/registry_index.json +8 -0
- skillplay/core/schema.py +177 -0
- skillplay/core/share.py +61 -0
- skillplay/core/skillgraph.py +161 -0
- skillplay/core/sound.py +34 -0
- skillplay/core/stats.py +88 -0
- skillplay/core/streak.py +33 -0
- skillplay/core/sync.py +182 -0
- skillplay/core/telemetry.py +66 -0
- skillplay/core/validators.py +568 -0
- skillplay/packs/algorithms/challenges/01-fib.yaml +36 -0
- skillplay/packs/algorithms/challenges/02-factorial.yaml +34 -0
- skillplay/packs/algorithms/challenges/03-prime.yaml +38 -0
- skillplay/packs/algorithms/challenges/04-max.yaml +31 -0
- skillplay/packs/algorithms/pack.yaml +10 -0
- skillplay/packs/css-basics/challenges/01-p.yaml +16 -0
- skillplay/packs/css-basics/challenges/02-id.yaml +16 -0
- skillplay/packs/css-basics/challenges/03-class.yaml +16 -0
- skillplay/packs/css-basics/challenges/04-desc.yaml +17 -0
- skillplay/packs/css-basics/challenges/05-color.yaml +20 -0
- skillplay/packs/css-basics/challenges/06-em.yaml +20 -0
- skillplay/packs/css-basics/pack.yaml +10 -0
- skillplay/packs/data-structures/challenges/01-first.yaml +41 -0
- skillplay/packs/data-structures/challenges/02-last.yaml +41 -0
- skillplay/packs/data-structures/challenges/03-reverse.yaml +41 -0
- skillplay/packs/data-structures/challenges/04-count.yaml +41 -0
- skillplay/packs/data-structures/pack.yaml +14 -0
- skillplay/packs/fix-bug/challenges/01-sum.yaml +31 -0
- skillplay/packs/fix-bug/challenges/02-greet.yaml +31 -0
- skillplay/packs/fix-bug/challenges/03-max.yaml +31 -0
- skillplay/packs/fix-bug/challenges/04-even.yaml +31 -0
- skillplay/packs/fix-bug/pack.yaml +12 -0
- skillplay/packs/fix-bug-js/challenges/01-double.yaml +33 -0
- skillplay/packs/fix-bug-js/challenges/02-sum.yaml +36 -0
- skillplay/packs/fix-bug-js/challenges/03-palindrome.yaml +34 -0
- skillplay/packs/fix-bug-js/pack.yaml +12 -0
- skillplay/packs/freeform-intro/challenges/01-greet.yaml +32 -0
- skillplay/packs/freeform-intro/challenges/02-sum_even.yaml +34 -0
- skillplay/packs/freeform-intro/challenges/03-palindrome-js.yaml +33 -0
- skillplay/packs/freeform-intro/pack.yaml +12 -0
- skillplay/packs/git-basics/challenges/01-init.yaml +19 -0
- skillplay/packs/git-basics/challenges/02-status.yaml +20 -0
- skillplay/packs/git-basics/challenges/03-add.yaml +20 -0
- skillplay/packs/git-basics/challenges/04-commit.yaml +21 -0
- skillplay/packs/git-basics/challenges/05-branch.yaml +20 -0
- skillplay/packs/git-basics/challenges/06-mc-stage.yaml +28 -0
- skillplay/packs/git-basics/challenges/07-mc-clone.yaml +28 -0
- skillplay/packs/git-basics/challenges/08-mc-merge-rebase.yaml +28 -0
- skillplay/packs/git-basics/challenges/09-mc-undo.yaml +29 -0
- skillplay/packs/git-basics/challenges/10-mc-remote.yaml +28 -0
- skillplay/packs/git-basics/pack.yaml +10 -0
- skillplay/packs/http-rest/challenges/01-get.yaml +20 -0
- skillplay/packs/http-rest/challenges/02-post.yaml +20 -0
- skillplay/packs/http-rest/challenges/03-put.yaml +20 -0
- skillplay/packs/http-rest/challenges/04-404.yaml +20 -0
- skillplay/packs/http-rest/challenges/05-ctype.yaml +16 -0
- skillplay/packs/http-rest/challenges/06-auth.yaml +21 -0
- skillplay/packs/http-rest/pack.yaml +10 -0
- skillplay/packs/mini-cli/challenges/01-greet.yaml +33 -0
- skillplay/packs/mini-cli/challenges/02-add.yaml +33 -0
- skillplay/packs/mini-cli/challenges/03-main.yaml +32 -0
- skillplay/packs/mini-cli/pack.yaml +17 -0
- skillplay/packs/regex-101/challenges/01-literal.yaml +20 -0
- skillplay/packs/regex-101/challenges/02-digit.yaml +20 -0
- skillplay/packs/regex-101/challenges/03-nondigit.yaml +20 -0
- skillplay/packs/regex-101/challenges/04-start.yaml +20 -0
- skillplay/packs/regex-101/challenges/05-end.yaml +20 -0
- skillplay/packs/regex-101/challenges/06-plus.yaml +20 -0
- skillplay/packs/regex-101/challenges/07-star.yaml +20 -0
- skillplay/packs/regex-101/challenges/08-optional.yaml +20 -0
- skillplay/packs/regex-101/challenges/09-class.yaml +20 -0
- skillplay/packs/regex-101/challenges/10-negclass.yaml +20 -0
- skillplay/packs/regex-101/challenges/11-boundary.yaml +20 -0
- skillplay/packs/regex-101/challenges/12-alt.yaml +20 -0
- skillplay/packs/regex-101/challenges/13-range.yaml +20 -0
- skillplay/packs/regex-101/challenges/14-group.yaml +20 -0
- skillplay/packs/regex-101/challenges/15-anchored.yaml +20 -0
- skillplay/packs/regex-101/pack.yaml +10 -0
- skillplay/packs/schemas/challenge.schema.json +80 -0
- skillplay/packs/schemas/pack.schema.json +31 -0
- skillplay/packs/shell-basics/challenges/01-ls.yaml +16 -0
- skillplay/packs/shell-basics/challenges/02-pwd.yaml +16 -0
- skillplay/packs/shell-basics/challenges/03-cd.yaml +16 -0
- skillplay/packs/shell-basics/challenges/04-rm.yaml +16 -0
- skillplay/packs/shell-basics/challenges/05-grep.yaml +17 -0
- skillplay/packs/shell-basics/challenges/06-find.yaml +17 -0
- skillplay/packs/shell-basics/pack.yaml +10 -0
- skillplay/packs/sql-basics/challenges/01-select.yaml +24 -0
- skillplay/packs/sql-basics/challenges/02-where.yaml +24 -0
- skillplay/packs/sql-basics/challenges/03-order.yaml +24 -0
- skillplay/packs/sql-basics/challenges/04-limit.yaml +24 -0
- skillplay/packs/sql-basics/challenges/05-count.yaml +24 -0
- skillplay/packs/sql-basics/challenges/06-distinct.yaml +24 -0
- skillplay/packs/sql-basics/challenges/07-and.yaml +24 -0
- skillplay/packs/sql-basics/challenges/08-or.yaml +24 -0
- skillplay/packs/sql-basics/challenges/09-in.yaml +24 -0
- skillplay/packs/sql-basics/challenges/10-between.yaml +24 -0
- skillplay/packs/sql-basics/challenges/11-like.yaml +24 -0
- skillplay/packs/sql-basics/challenges/12-join.yaml +27 -0
- skillplay/packs/sql-basics/challenges/13-group.yaml +27 -0
- skillplay/packs/sql-basics/challenges/14-having.yaml +26 -0
- skillplay/packs/sql-basics/challenges/15-subquery.yaml +24 -0
- skillplay/packs/sql-basics/pack.yaml +10 -0
- skillplay/tui/__init__.py +3 -0
- skillplay/tui/app.py +1108 -0
- skillplay-1.1.1.dist-info/METADATA +13 -0
- skillplay-1.1.1.dist-info/RECORD +129 -0
- skillplay-1.1.1.dist-info/WHEEL +4 -0
- skillplay-1.1.1.dist-info/entry_points.txt +2 -0
skillplay/__init__.py
ADDED
skillplay/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from .core import cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
# Bare `skillplay` (no args) launches the TUI; subcommands run the CLI.
|
|
8
|
+
if len(sys.argv) > 1:
|
|
9
|
+
# `skillplay exam --skill X` is a TUI subcommand — handled here so it can
|
|
10
|
+
# launch the app directly into a mastery exam.
|
|
11
|
+
if sys.argv[1] == "exam":
|
|
12
|
+
raise SystemExit(cli.run_exam(sys.argv[2:]))
|
|
13
|
+
raise SystemExit(cli.run(sys.argv[1:]))
|
|
14
|
+
from .tui.app import SkillPlayApp
|
|
15
|
+
|
|
16
|
+
app = SkillPlayApp()
|
|
17
|
+
try:
|
|
18
|
+
app.run()
|
|
19
|
+
except KeyboardInterrupt:
|
|
20
|
+
sys.exit(0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Core data layer: loader, validators, progress, streak, engine."""
|
|
2
|
+
|
|
3
|
+
from . import (
|
|
4
|
+
achievements,
|
|
5
|
+
cli,
|
|
6
|
+
config,
|
|
7
|
+
engine,
|
|
8
|
+
goals,
|
|
9
|
+
i18n,
|
|
10
|
+
leaderboard_server,
|
|
11
|
+
loader,
|
|
12
|
+
progress,
|
|
13
|
+
registry,
|
|
14
|
+
schema,
|
|
15
|
+
share,
|
|
16
|
+
sound,
|
|
17
|
+
streak,
|
|
18
|
+
telemetry,
|
|
19
|
+
validators,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"achievements",
|
|
24
|
+
"cli",
|
|
25
|
+
"config",
|
|
26
|
+
"engine",
|
|
27
|
+
"goals",
|
|
28
|
+
"i18n",
|
|
29
|
+
"leaderboard_server",
|
|
30
|
+
"loader",
|
|
31
|
+
"progress",
|
|
32
|
+
"registry",
|
|
33
|
+
"schema",
|
|
34
|
+
"share",
|
|
35
|
+
"sound",
|
|
36
|
+
"streak",
|
|
37
|
+
"telemetry",
|
|
38
|
+
"validators",
|
|
39
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Achievement/badge definitions and awarding (P8).
|
|
2
|
+
|
|
3
|
+
Achievements are pure functions over the progress document. `award` is called
|
|
4
|
+
from `engine.finalize` and appends any newly-earned ids to `progress["achievements"]`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _total_completed(progress: dict) -> int:
|
|
13
|
+
return sum(len(s.get("completed_ids", [])) for s in progress.get("skills", {}).values())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _checkers() -> list[tuple[str, str, str, Callable[[dict], bool]]]:
|
|
17
|
+
def first_blood(p: dict) -> bool:
|
|
18
|
+
return _total_completed(p) >= 1
|
|
19
|
+
|
|
20
|
+
def streak_7(p: dict) -> bool:
|
|
21
|
+
return p.get("streak", {}).get("longest", 0) >= 7
|
|
22
|
+
|
|
23
|
+
def century(p: dict) -> bool:
|
|
24
|
+
return p.get("total_xp", 0) >= 100
|
|
25
|
+
|
|
26
|
+
def pack_complete(p: dict) -> bool:
|
|
27
|
+
from . import loader
|
|
28
|
+
|
|
29
|
+
for pack in loader.load_all_packs():
|
|
30
|
+
ids = {c.id for c in pack.challenges}
|
|
31
|
+
done = set(p.get("skills", {}).get(pack.skill, {}).get("completed_ids", []))
|
|
32
|
+
if ids and ids <= done:
|
|
33
|
+
return True
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def daily_5(p: dict) -> bool:
|
|
37
|
+
return len(p.get("daily", {}).get("dates", [])) >= 5
|
|
38
|
+
|
|
39
|
+
def combo_master(p: dict) -> bool:
|
|
40
|
+
# Reached via any session; we approximate using daily/weekly accuracy.
|
|
41
|
+
return p.get("total_xp", 0) >= 500
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
("first_blood", "First Blood", "Complete your first challenge.", first_blood),
|
|
45
|
+
("streak_7", "Week Strong", "Keep a 7-day streak alive.", streak_7),
|
|
46
|
+
("century", "Century", "Earn 100 total XP.", century),
|
|
47
|
+
("pack_complete", "Pack Rat", "Finish every challenge in a pack.", pack_complete),
|
|
48
|
+
("daily_5", "Daily Devotion", "Complete 5 daily challenges.", daily_5),
|
|
49
|
+
("combo_master", "Combo Master", "Reach 500 total XP.", combo_master),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
ACHIEVEMENTS: list[dict] = [{"id": i, "name": n, "description": d} for (i, n, d, _) in _checkers()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def award(progress: dict) -> list[str]:
|
|
57
|
+
"""Append any newly-earned achievement ids; return the newly awarded list."""
|
|
58
|
+
earned = set(progress.get("achievements", []))
|
|
59
|
+
newly: list[str] = []
|
|
60
|
+
for aid, _name, _desc, check in _checkers():
|
|
61
|
+
if aid not in earned and check(progress):
|
|
62
|
+
earned.add(aid)
|
|
63
|
+
newly.append(aid)
|
|
64
|
+
if newly:
|
|
65
|
+
progress["achievements"] = sorted(earned)
|
|
66
|
+
return newly
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def by_id(aid: str) -> dict | None:
|
|
70
|
+
for a in ACHIEVEMENTS:
|
|
71
|
+
if a["id"] == aid:
|
|
72
|
+
return a
|
|
73
|
+
return None
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Section E — Adaptive engine v2: a tiny, offline-first local model.
|
|
2
|
+
|
|
3
|
+
Trained purely on the player's own per-attempt history (``progress["attempts"]``),
|
|
4
|
+
it predicts the probability a challenge will be solved correctly, then selects the
|
|
5
|
+
*next-best* challenge: one the player is likely to get wrong but where practice
|
|
6
|
+
still pays off (high expected learning gain). No data ever leaves the machine —
|
|
7
|
+
the fitted weights live in ``progress["model"]`` and training happens locally on
|
|
8
|
+
``finalize``.
|
|
9
|
+
|
|
10
|
+
The model is a small logistic regression fit with batch gradient descent in pure
|
|
11
|
+
Python (no numpy dependency). It is intentionally simple and bounded so it runs
|
|
12
|
+
instantly and stays explainable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import random
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from . import engine, skillgraph
|
|
22
|
+
from . import progress as progress_mod
|
|
23
|
+
from .loader import Challenge, Pack
|
|
24
|
+
|
|
25
|
+
# Fixed feature order — must stay in sync with ``engine._attempt_features`` keys.
|
|
26
|
+
_FEATURE_KEYS = ("difficulty", "box", "mistakes", "weak_share", "rolling", "seen", "latency")
|
|
27
|
+
|
|
28
|
+
MIN_TRAIN_ATTEMPTS = 20
|
|
29
|
+
MAX_TRAIN_ATTEMPTS = 5000
|
|
30
|
+
MODEL_VERSION = 1
|
|
31
|
+
# Only retrain once at least this many *new* attempts have accumulated, so
|
|
32
|
+
# ``finalize`` stays cheap (G5: avoid re-fitting on every session save).
|
|
33
|
+
RETRAIN_MIN_NEW = 10
|
|
34
|
+
# How strongly the heuristic "drill the weakest mistake type" nudge is folded
|
|
35
|
+
# into the model's learning-gain score (G4: keep retention behavior when the
|
|
36
|
+
# model is active instead of silently dropping it).
|
|
37
|
+
RETENTION_BONUS_WEIGHT = 0.03
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sigmoid(z: float) -> float:
|
|
41
|
+
if z >= 0:
|
|
42
|
+
ez = math.exp(-z)
|
|
43
|
+
return 1.0 / (1.0 + ez)
|
|
44
|
+
ez = math.exp(z)
|
|
45
|
+
return ez / (1.0 + ez)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _eval_auc(attempts: list[dict[str, Any]], model: AdaptiveModel) -> float | None:
|
|
49
|
+
"""Cheap offline effectiveness check: rank-based AUC of P(correct) vs the
|
|
50
|
+
actual outcome (Mann-Whitney U, O(n log n)). 0.5 = no signal, 1.0 = perfect.
|
|
51
|
+
Returns None when there's only one class present."""
|
|
52
|
+
scores = []
|
|
53
|
+
for a in attempts:
|
|
54
|
+
if not a.get("feats"):
|
|
55
|
+
continue
|
|
56
|
+
y = 1.0 if a.get("correct") else 0.0
|
|
57
|
+
scores.append((y, model.predict_correct(a["feats"])))
|
|
58
|
+
if len(scores) < 2:
|
|
59
|
+
return None
|
|
60
|
+
scores.sort(key=lambda x: x[1])
|
|
61
|
+
n_neg = concordant = n_pos = 0
|
|
62
|
+
for y, _p in scores:
|
|
63
|
+
if y == 0:
|
|
64
|
+
n_neg += 1
|
|
65
|
+
else:
|
|
66
|
+
concordant += n_neg
|
|
67
|
+
n_pos += 1
|
|
68
|
+
if n_pos == 0 or n_neg == 0:
|
|
69
|
+
return None
|
|
70
|
+
return concordant / (n_pos * n_neg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _retention_bonus(progress: dict[str, Any], ch) -> float:
|
|
74
|
+
"""The heuristic's "drill the weakest mistake type" term (mirrors
|
|
75
|
+
``engine._challenge_weight``) so the model path keeps the proven retention
|
|
76
|
+
behavior (G4). Zero for unseen/clean challenges."""
|
|
77
|
+
bonus = 0.0
|
|
78
|
+
dom = engine._dominant_mistake_type(progress, ch.id)
|
|
79
|
+
if dom is not None:
|
|
80
|
+
mt = progress.get("mistakes", {}).get(ch.id, {})
|
|
81
|
+
bonus += mt.get(dom, 0) * 2
|
|
82
|
+
if dom == engine._weakest_mistake_type(progress):
|
|
83
|
+
bonus += 3
|
|
84
|
+
return bonus
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AdaptiveModel:
|
|
88
|
+
"""A fitted logistic regressor over challenge features -> P(correct)."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self, weights: dict[str, float] | None = None, bias: float = 0.0, trained_on: int = 0
|
|
92
|
+
):
|
|
93
|
+
self.weights = weights or {k: 0.0 for k in _FEATURE_KEYS}
|
|
94
|
+
self.bias = bias
|
|
95
|
+
self.trained_on = trained_on
|
|
96
|
+
|
|
97
|
+
def predict_correct(self, feats: dict[str, float]) -> float:
|
|
98
|
+
z = self.bias
|
|
99
|
+
for k in _FEATURE_KEYS:
|
|
100
|
+
z += self.weights.get(k, 0.0) * feats.get(k, 0.0)
|
|
101
|
+
return _sigmoid(z)
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
return {"weights": dict(self.weights), "bias": self.bias, "trained_on": self.trained_on}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, d: dict[str, Any]) -> AdaptiveModel:
|
|
108
|
+
return cls(d.get("weights", {}), d.get("bias", 0.0), d.get("trained_on", 0))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def train_model(
|
|
112
|
+
attempts: list[dict[str, Any]], iters: int = 60, lr: float = 0.3
|
|
113
|
+
) -> AdaptiveModel | None:
|
|
114
|
+
"""Fit the logistic model on recorded attempts. Returns None if there is
|
|
115
|
+
not enough data to learn anything meaningful yet."""
|
|
116
|
+
samples = [a for a in attempts if isinstance(a, dict) and a.get("feats")]
|
|
117
|
+
if len(samples) < MIN_TRAIN_ATTEMPTS:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
feats_list = [a["feats"] for a in samples]
|
|
121
|
+
ys = [1.0 if a.get("correct") else 0.0 for a in samples]
|
|
122
|
+
w = {k: 0.0 for k in _FEATURE_KEYS}
|
|
123
|
+
b = 0.0
|
|
124
|
+
n = len(feats_list)
|
|
125
|
+
for _ in range(iters):
|
|
126
|
+
gw = {k: 0.0 for k in _FEATURE_KEYS}
|
|
127
|
+
gb = 0.0
|
|
128
|
+
for x, y in zip(feats_list, ys):
|
|
129
|
+
p = _sigmoid(b + sum(w[k] * x.get(k, 0.0) for k in _FEATURE_KEYS))
|
|
130
|
+
err = p - y
|
|
131
|
+
for k in _FEATURE_KEYS:
|
|
132
|
+
gw[k] += err * x.get(k, 0.0)
|
|
133
|
+
gb += err
|
|
134
|
+
for k in _FEATURE_KEYS:
|
|
135
|
+
w[k] -= lr * gw[k] / n
|
|
136
|
+
b -= lr * gb / n
|
|
137
|
+
return AdaptiveModel(w, b, len(samples))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def retrain_if_needed(progress: dict[str, Any]) -> None:
|
|
141
|
+
"""Fit (or refresh) the model from recent attempts and store it locally.
|
|
142
|
+
|
|
143
|
+
G5 hardening: the stored model carries a ``version`` so a format change can't
|
|
144
|
+
silently break old saves (``load_model`` rejects mismatches), and retraining is
|
|
145
|
+
throttled to at least ``RETRAIN_MIN_NEW`` new attempts so ``finalize`` stays
|
|
146
|
+
cheap. A held-out AUC is stored for visibility into model effectiveness.
|
|
147
|
+
"""
|
|
148
|
+
total = len(progress.get("attempts", []))
|
|
149
|
+
if total < MIN_TRAIN_ATTEMPTS:
|
|
150
|
+
return
|
|
151
|
+
prev = progress.get("model") or {}
|
|
152
|
+
if (
|
|
153
|
+
prev.get("version") == MODEL_VERSION
|
|
154
|
+
and (total - prev.get("trained_on_count", 0)) < RETRAIN_MIN_NEW
|
|
155
|
+
):
|
|
156
|
+
return # already trained on (almost) all current data
|
|
157
|
+
attempts = progress["attempts"][-MAX_TRAIN_ATTEMPTS:]
|
|
158
|
+
model = train_model(attempts)
|
|
159
|
+
if model is None:
|
|
160
|
+
return
|
|
161
|
+
d = model.to_dict()
|
|
162
|
+
d["version"] = MODEL_VERSION
|
|
163
|
+
d["trained_on_count"] = total
|
|
164
|
+
d["eval_auc"] = _eval_auc(attempts, model)
|
|
165
|
+
progress["model"] = d
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_model(progress: dict[str, Any]) -> AdaptiveModel | None:
|
|
169
|
+
d = progress.get("model")
|
|
170
|
+
if not d or d.get("version") != MODEL_VERSION:
|
|
171
|
+
return None
|
|
172
|
+
model = AdaptiveModel.from_dict(d)
|
|
173
|
+
if model.trained_on < MIN_TRAIN_ATTEMPTS:
|
|
174
|
+
return None
|
|
175
|
+
return model
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def challenge_failure_prob(model: AdaptiveModel, progress: dict[str, Any], ch: Challenge) -> float:
|
|
179
|
+
"""P(the player gets this challenge wrong) under the current model."""
|
|
180
|
+
feats = engine._attempt_features(progress, ch)
|
|
181
|
+
return 1.0 - model.predict_correct(feats)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def adaptive_reason(p_fail: float) -> str:
|
|
185
|
+
pct = int(p_fail * 100)
|
|
186
|
+
if pct >= 60:
|
|
187
|
+
return f"Model: likely to slip ({pct}%) — high-value practice"
|
|
188
|
+
if pct >= 35:
|
|
189
|
+
return f"Model: worth drilling ({pct}%)"
|
|
190
|
+
return f"Model: probably fine ({pct}%) — light review"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def next_best_challenge(
|
|
194
|
+
progress: dict[str, Any], pack: Pack, skill: str | None = None
|
|
195
|
+
) -> Challenge | None:
|
|
196
|
+
"""The single best challenge to practice next for a skill (model-driven)."""
|
|
197
|
+
model = load_model(progress)
|
|
198
|
+
candidates = pack.challenges
|
|
199
|
+
if skill:
|
|
200
|
+
candidates = [c for c in candidates if c.skill == skill]
|
|
201
|
+
if not candidates:
|
|
202
|
+
return None
|
|
203
|
+
if model is None:
|
|
204
|
+
# Not enough data yet — fall back to the proven heuristic weighting.
|
|
205
|
+
return max(candidates, key=lambda c: engine._challenge_weight(progress, c))
|
|
206
|
+
# V4: never target a challenge whose prerequisites aren't satisfied yet —
|
|
207
|
+
# the model walks the next unlockable frontier, not the weakest locked card.
|
|
208
|
+
unlocked = [c for c in candidates if skillgraph.is_unlocked(progress, c)]
|
|
209
|
+
candidates = unlocked or candidates
|
|
210
|
+
# Higher failure probability = better practice target; the retention bonus
|
|
211
|
+
# preserves the heuristic's "drill the weakest mistake type" nudge (G4).
|
|
212
|
+
ranked = sorted(
|
|
213
|
+
candidates,
|
|
214
|
+
key=lambda c: (
|
|
215
|
+
challenge_failure_prob(model, progress, c)
|
|
216
|
+
+ RETENTION_BONUS_WEIGHT * _retention_bonus(progress, c)
|
|
217
|
+
),
|
|
218
|
+
reverse=True,
|
|
219
|
+
)
|
|
220
|
+
return ranked[0]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def select_adaptive_v2(
|
|
224
|
+
pack: Pack, progress: dict[str, Any], session_size: int, model: AdaptiveModel | None = None
|
|
225
|
+
) -> list:
|
|
226
|
+
"""Pick a session of challenges using the trained model.
|
|
227
|
+
|
|
228
|
+
Falls back to the classic weighted sampler when the model isn't trained yet,
|
|
229
|
+
so early players get the same good experience as before.
|
|
230
|
+
"""
|
|
231
|
+
model = model or load_model(progress)
|
|
232
|
+
if model is None:
|
|
233
|
+
return engine.select_challenges(pack, progress, session_size)
|
|
234
|
+
|
|
235
|
+
today = progress_mod.today_str()
|
|
236
|
+
due = []
|
|
237
|
+
for ch in pack.challenges:
|
|
238
|
+
rec = progress["challenges"].get(ch.id, {})
|
|
239
|
+
next_due = rec.get("next_due", "")
|
|
240
|
+
if next_due and next_due > today:
|
|
241
|
+
continue
|
|
242
|
+
due.append(ch)
|
|
243
|
+
if not due:
|
|
244
|
+
due = list(pack.challenges)
|
|
245
|
+
|
|
246
|
+
# V4: respect prerequisites — only model-rank the unlocked frontier so the
|
|
247
|
+
# adaptive session never schedules a card the player isn't ready for.
|
|
248
|
+
due = engine._gate_prerequisites(due, progress)
|
|
249
|
+
|
|
250
|
+
# Expected learning gain: likely-to-fail * difficulty value, plus the
|
|
251
|
+
# heuristic retention nudge so the model path keeps drilling the player's
|
|
252
|
+
# weakest mistake type instead of dropping it (G4).
|
|
253
|
+
scored = []
|
|
254
|
+
for ch in due:
|
|
255
|
+
p_fail = challenge_failure_prob(model, progress, ch)
|
|
256
|
+
gain = p_fail * (0.5 + getattr(ch, "difficulty", 1) / 10.0)
|
|
257
|
+
gain += RETENTION_BONUS_WEIGHT * _retention_bonus(progress, ch)
|
|
258
|
+
scored.append((gain, ch))
|
|
259
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
260
|
+
|
|
261
|
+
# Weighted sample without replacement among the strongest contenders, with
|
|
262
|
+
# a little exploration noise so sessions don't become perfectly repetitive.
|
|
263
|
+
top = scored[: max(session_size, min(len(scored), session_size * 3))]
|
|
264
|
+
pool = [c for _, c in top]
|
|
265
|
+
weights = [max(1, int(g * 100)) for g, _ in top]
|
|
266
|
+
chosen: list = []
|
|
267
|
+
pp, pw = list(pool), list(weights)
|
|
268
|
+
while pp and len(chosen) < session_size:
|
|
269
|
+
pick = random.choices(pp, weights=pw, k=1)[0]
|
|
270
|
+
idx = pp.index(pick)
|
|
271
|
+
chosen.append(pp.pop(idx))
|
|
272
|
+
pw.pop(idx)
|
|
273
|
+
for ch in chosen:
|
|
274
|
+
ch.srs_reason = adaptive_reason(challenge_failure_prob(model, progress, ch))
|
|
275
|
+
return chosen
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def select_adaptive_v2_pack(packs: list[Pack], progress: dict[str, Any], session_size: int) -> list:
|
|
279
|
+
"""Like ``select_mixed`` but scored by the adaptive model across all packs."""
|
|
280
|
+
all_ch = [c for p in packs for c in p.challenges]
|
|
281
|
+
synth = Pack(
|
|
282
|
+
id="adaptive",
|
|
283
|
+
name="Adaptive",
|
|
284
|
+
version="0.0.0",
|
|
285
|
+
skill="mixed",
|
|
286
|
+
description="Model-selected challenges across all your skills.",
|
|
287
|
+
difficulty="mixed",
|
|
288
|
+
author="core",
|
|
289
|
+
challenges=all_ch,
|
|
290
|
+
)
|
|
291
|
+
return select_adaptive_v2(synth, progress, session_size)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""V2 - Project / capstone mode (W1): session practice -> portfolio outcomes.
|
|
2
|
+
|
|
3
|
+
A capstone pack is a chain of challenges (ordered by the V4 prerequisite DAG)
|
|
4
|
+
that together build a single real artifact - a small CLI, a module, a tiny API.
|
|
5
|
+
As the player solves the code-producing challenges, their own correct solutions
|
|
6
|
+
are stitched together into a runnable file saved under the portfolio directory,
|
|
7
|
+
so a learning session leaves behind something tangible.
|
|
8
|
+
|
|
9
|
+
All functions here are pure except `build_artifact`, which writes the artifact
|
|
10
|
+
file and updates `progress["portfolio"]` in memory (the caller persists, per the
|
|
11
|
+
engine's save invariant).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import progress as progress_mod
|
|
20
|
+
from .loader import Pack
|
|
21
|
+
|
|
22
|
+
# Validation modes whose answer is code we can assemble into an artifact.
|
|
23
|
+
_CODE_MODES = ("test_cases", "freeform")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_capstone(pack: Pack) -> bool:
|
|
27
|
+
return bool(getattr(pack, "capstone", False))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def capstone_packs(packs: list[Pack]) -> list[Pack]:
|
|
31
|
+
return [p for p in packs if is_capstone(p)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _topo_order(pack: Pack) -> list:
|
|
35
|
+
"""Order the pack's challenges so prerequisites come before dependents (Kahn).
|
|
36
|
+
Cross-pack prerequisites are ignored for ordering (placed at the end)."""
|
|
37
|
+
by_id = {c.id: c for c in pack.challenges}
|
|
38
|
+
indeg = {c.id: 0 for c in pack.challenges}
|
|
39
|
+
adj: dict[str, list[str]] = {c.id: [] for c in pack.challenges}
|
|
40
|
+
for c in pack.challenges:
|
|
41
|
+
for pid in getattr(c, "prerequisites", None) or []:
|
|
42
|
+
if pid in by_id:
|
|
43
|
+
adj[pid].append(c.id)
|
|
44
|
+
indeg[c.id] += 1
|
|
45
|
+
queue = [cid for cid, d in indeg.items() if d == 0]
|
|
46
|
+
order: list[str] = []
|
|
47
|
+
while queue:
|
|
48
|
+
n = queue.pop(0)
|
|
49
|
+
order.append(n)
|
|
50
|
+
for m in adj[n]:
|
|
51
|
+
indeg[m] -= 1
|
|
52
|
+
if indeg[m] == 0:
|
|
53
|
+
queue.append(m)
|
|
54
|
+
for cid in by_id: # safety: append any leftovers (should be none - DAG-checked)
|
|
55
|
+
if cid not in order:
|
|
56
|
+
order.append(cid)
|
|
57
|
+
return [by_id[cid] for cid in order]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def code_challenges(pack: Pack) -> list:
|
|
61
|
+
return [c for c in _topo_order(pack) if c.validation.get("mode") in _CODE_MODES]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _solution_for(progress: dict[str, Any], ch) -> str:
|
|
65
|
+
"""The player's own correct code if recorded, else the reference solution."""
|
|
66
|
+
sol = progress.get("solutions", {}).get(ch.id)
|
|
67
|
+
if sol:
|
|
68
|
+
return sol
|
|
69
|
+
return (ch.answer.get("reference_code") or "").strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def capstone_progress(progress: dict[str, Any], pack: Pack) -> dict[str, int]:
|
|
73
|
+
"""Summary for the UI: how many code challenges solved and if built."""
|
|
74
|
+
code = code_challenges(pack)
|
|
75
|
+
solved = sum(1 for c in code if c.id in progress.get("solutions", {}))
|
|
76
|
+
built = 1 if progress.get("portfolio", {}).get(pack.id) else 0
|
|
77
|
+
return {"total": len(code), "solved": solved, "built": built}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ensure_guard(code: str, lang: str) -> str:
|
|
81
|
+
"""For python artifacts, ensure a defined `main` is actually invoked."""
|
|
82
|
+
if lang != "python":
|
|
83
|
+
return code
|
|
84
|
+
if "def main(" not in code:
|
|
85
|
+
return code
|
|
86
|
+
if 'if __name__ == "__main__"' in code or "if __name__ == '__main__'" in code:
|
|
87
|
+
return code
|
|
88
|
+
return code + '\n\nif __name__ == "__main__":\n main()\n'
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_artifact(
|
|
92
|
+
progress: dict[str, Any], pack: Pack, data_dir: Path | None = None
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Assemble the capstone's code challenges (in prerequisite order) into one
|
|
95
|
+
artifact file under `<data_dir>/portfolio/<filename>`.
|
|
96
|
+
|
|
97
|
+
Uses the player's own correct solutions where available, falling back to each
|
|
98
|
+
challenge's reference code so the artifact is always buildable. Returns a
|
|
99
|
+
result dict; side effects are limited to writing the file and updating
|
|
100
|
+
`progress["portfolio"][pack.id]` in memory (caller persists).
|
|
101
|
+
"""
|
|
102
|
+
data_dir = data_dir or progress_mod.DATA_DIR
|
|
103
|
+
artifact = getattr(pack, "artifact", {}) or {}
|
|
104
|
+
filename = artifact.get("filename") or f"{pack.id}.py"
|
|
105
|
+
lang = artifact.get("lang", "python")
|
|
106
|
+
code_chs = code_challenges(pack)
|
|
107
|
+
|
|
108
|
+
parts: list[str] = [f"# {pack.name} - assembled skillplay capstone artifact"]
|
|
109
|
+
solved = 0
|
|
110
|
+
for ch in code_chs:
|
|
111
|
+
sol = _solution_for(progress, ch)
|
|
112
|
+
if not sol:
|
|
113
|
+
continue
|
|
114
|
+
if ch.id in progress.get("solutions", {}):
|
|
115
|
+
solved += 1
|
|
116
|
+
parts.append(f"\n# --- {ch.id}: {ch.title} ---\n{sol}")
|
|
117
|
+
body = "\n".join(parts) + "\n"
|
|
118
|
+
body = _ensure_guard(body, lang)
|
|
119
|
+
|
|
120
|
+
out_dir = Path(data_dir) / "portfolio"
|
|
121
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
path = out_dir / filename
|
|
123
|
+
path.write_text(body, encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
progress.setdefault("portfolio", {})[pack.id] = {
|
|
126
|
+
"path": str(path),
|
|
127
|
+
"filename": filename,
|
|
128
|
+
"built": progress_mod.today_str(),
|
|
129
|
+
"solved": solved,
|
|
130
|
+
"total": len(code_chs),
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
"ok": True,
|
|
134
|
+
"path": str(path),
|
|
135
|
+
"filename": filename,
|
|
136
|
+
"lang": lang,
|
|
137
|
+
"solved": solved,
|
|
138
|
+
"total": len(code_chs),
|
|
139
|
+
"code": body,
|
|
140
|
+
}
|