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.

Files changed (129) hide show
  1. skillplay/__init__.py +3 -0
  2. skillplay/__main__.py +24 -0
  3. skillplay/core/__init__.py +39 -0
  4. skillplay/core/achievements.py +73 -0
  5. skillplay/core/adaptive.py +291 -0
  6. skillplay/core/capstone.py +140 -0
  7. skillplay/core/cli.py +823 -0
  8. skillplay/core/config.py +61 -0
  9. skillplay/core/demo.py +138 -0
  10. skillplay/core/engine.py +620 -0
  11. skillplay/core/exam.py +122 -0
  12. skillplay/core/generate.py +642 -0
  13. skillplay/core/goals.py +138 -0
  14. skillplay/core/hlr.py +73 -0
  15. skillplay/core/i18n.py +112 -0
  16. skillplay/core/leaderboard_server.py +288 -0
  17. skillplay/core/loader.py +214 -0
  18. skillplay/core/mentor.py +214 -0
  19. skillplay/core/progress.py +184 -0
  20. skillplay/core/registry.py +314 -0
  21. skillplay/core/registry_index.json +8 -0
  22. skillplay/core/schema.py +177 -0
  23. skillplay/core/share.py +61 -0
  24. skillplay/core/skillgraph.py +161 -0
  25. skillplay/core/sound.py +34 -0
  26. skillplay/core/stats.py +88 -0
  27. skillplay/core/streak.py +33 -0
  28. skillplay/core/sync.py +182 -0
  29. skillplay/core/telemetry.py +66 -0
  30. skillplay/core/validators.py +568 -0
  31. skillplay/packs/algorithms/challenges/01-fib.yaml +36 -0
  32. skillplay/packs/algorithms/challenges/02-factorial.yaml +34 -0
  33. skillplay/packs/algorithms/challenges/03-prime.yaml +38 -0
  34. skillplay/packs/algorithms/challenges/04-max.yaml +31 -0
  35. skillplay/packs/algorithms/pack.yaml +10 -0
  36. skillplay/packs/css-basics/challenges/01-p.yaml +16 -0
  37. skillplay/packs/css-basics/challenges/02-id.yaml +16 -0
  38. skillplay/packs/css-basics/challenges/03-class.yaml +16 -0
  39. skillplay/packs/css-basics/challenges/04-desc.yaml +17 -0
  40. skillplay/packs/css-basics/challenges/05-color.yaml +20 -0
  41. skillplay/packs/css-basics/challenges/06-em.yaml +20 -0
  42. skillplay/packs/css-basics/pack.yaml +10 -0
  43. skillplay/packs/data-structures/challenges/01-first.yaml +41 -0
  44. skillplay/packs/data-structures/challenges/02-last.yaml +41 -0
  45. skillplay/packs/data-structures/challenges/03-reverse.yaml +41 -0
  46. skillplay/packs/data-structures/challenges/04-count.yaml +41 -0
  47. skillplay/packs/data-structures/pack.yaml +14 -0
  48. skillplay/packs/fix-bug/challenges/01-sum.yaml +31 -0
  49. skillplay/packs/fix-bug/challenges/02-greet.yaml +31 -0
  50. skillplay/packs/fix-bug/challenges/03-max.yaml +31 -0
  51. skillplay/packs/fix-bug/challenges/04-even.yaml +31 -0
  52. skillplay/packs/fix-bug/pack.yaml +12 -0
  53. skillplay/packs/fix-bug-js/challenges/01-double.yaml +33 -0
  54. skillplay/packs/fix-bug-js/challenges/02-sum.yaml +36 -0
  55. skillplay/packs/fix-bug-js/challenges/03-palindrome.yaml +34 -0
  56. skillplay/packs/fix-bug-js/pack.yaml +12 -0
  57. skillplay/packs/freeform-intro/challenges/01-greet.yaml +32 -0
  58. skillplay/packs/freeform-intro/challenges/02-sum_even.yaml +34 -0
  59. skillplay/packs/freeform-intro/challenges/03-palindrome-js.yaml +33 -0
  60. skillplay/packs/freeform-intro/pack.yaml +12 -0
  61. skillplay/packs/git-basics/challenges/01-init.yaml +19 -0
  62. skillplay/packs/git-basics/challenges/02-status.yaml +20 -0
  63. skillplay/packs/git-basics/challenges/03-add.yaml +20 -0
  64. skillplay/packs/git-basics/challenges/04-commit.yaml +21 -0
  65. skillplay/packs/git-basics/challenges/05-branch.yaml +20 -0
  66. skillplay/packs/git-basics/challenges/06-mc-stage.yaml +28 -0
  67. skillplay/packs/git-basics/challenges/07-mc-clone.yaml +28 -0
  68. skillplay/packs/git-basics/challenges/08-mc-merge-rebase.yaml +28 -0
  69. skillplay/packs/git-basics/challenges/09-mc-undo.yaml +29 -0
  70. skillplay/packs/git-basics/challenges/10-mc-remote.yaml +28 -0
  71. skillplay/packs/git-basics/pack.yaml +10 -0
  72. skillplay/packs/http-rest/challenges/01-get.yaml +20 -0
  73. skillplay/packs/http-rest/challenges/02-post.yaml +20 -0
  74. skillplay/packs/http-rest/challenges/03-put.yaml +20 -0
  75. skillplay/packs/http-rest/challenges/04-404.yaml +20 -0
  76. skillplay/packs/http-rest/challenges/05-ctype.yaml +16 -0
  77. skillplay/packs/http-rest/challenges/06-auth.yaml +21 -0
  78. skillplay/packs/http-rest/pack.yaml +10 -0
  79. skillplay/packs/mini-cli/challenges/01-greet.yaml +33 -0
  80. skillplay/packs/mini-cli/challenges/02-add.yaml +33 -0
  81. skillplay/packs/mini-cli/challenges/03-main.yaml +32 -0
  82. skillplay/packs/mini-cli/pack.yaml +17 -0
  83. skillplay/packs/regex-101/challenges/01-literal.yaml +20 -0
  84. skillplay/packs/regex-101/challenges/02-digit.yaml +20 -0
  85. skillplay/packs/regex-101/challenges/03-nondigit.yaml +20 -0
  86. skillplay/packs/regex-101/challenges/04-start.yaml +20 -0
  87. skillplay/packs/regex-101/challenges/05-end.yaml +20 -0
  88. skillplay/packs/regex-101/challenges/06-plus.yaml +20 -0
  89. skillplay/packs/regex-101/challenges/07-star.yaml +20 -0
  90. skillplay/packs/regex-101/challenges/08-optional.yaml +20 -0
  91. skillplay/packs/regex-101/challenges/09-class.yaml +20 -0
  92. skillplay/packs/regex-101/challenges/10-negclass.yaml +20 -0
  93. skillplay/packs/regex-101/challenges/11-boundary.yaml +20 -0
  94. skillplay/packs/regex-101/challenges/12-alt.yaml +20 -0
  95. skillplay/packs/regex-101/challenges/13-range.yaml +20 -0
  96. skillplay/packs/regex-101/challenges/14-group.yaml +20 -0
  97. skillplay/packs/regex-101/challenges/15-anchored.yaml +20 -0
  98. skillplay/packs/regex-101/pack.yaml +10 -0
  99. skillplay/packs/schemas/challenge.schema.json +80 -0
  100. skillplay/packs/schemas/pack.schema.json +31 -0
  101. skillplay/packs/shell-basics/challenges/01-ls.yaml +16 -0
  102. skillplay/packs/shell-basics/challenges/02-pwd.yaml +16 -0
  103. skillplay/packs/shell-basics/challenges/03-cd.yaml +16 -0
  104. skillplay/packs/shell-basics/challenges/04-rm.yaml +16 -0
  105. skillplay/packs/shell-basics/challenges/05-grep.yaml +17 -0
  106. skillplay/packs/shell-basics/challenges/06-find.yaml +17 -0
  107. skillplay/packs/shell-basics/pack.yaml +10 -0
  108. skillplay/packs/sql-basics/challenges/01-select.yaml +24 -0
  109. skillplay/packs/sql-basics/challenges/02-where.yaml +24 -0
  110. skillplay/packs/sql-basics/challenges/03-order.yaml +24 -0
  111. skillplay/packs/sql-basics/challenges/04-limit.yaml +24 -0
  112. skillplay/packs/sql-basics/challenges/05-count.yaml +24 -0
  113. skillplay/packs/sql-basics/challenges/06-distinct.yaml +24 -0
  114. skillplay/packs/sql-basics/challenges/07-and.yaml +24 -0
  115. skillplay/packs/sql-basics/challenges/08-or.yaml +24 -0
  116. skillplay/packs/sql-basics/challenges/09-in.yaml +24 -0
  117. skillplay/packs/sql-basics/challenges/10-between.yaml +24 -0
  118. skillplay/packs/sql-basics/challenges/11-like.yaml +24 -0
  119. skillplay/packs/sql-basics/challenges/12-join.yaml +27 -0
  120. skillplay/packs/sql-basics/challenges/13-group.yaml +27 -0
  121. skillplay/packs/sql-basics/challenges/14-having.yaml +26 -0
  122. skillplay/packs/sql-basics/challenges/15-subquery.yaml +24 -0
  123. skillplay/packs/sql-basics/pack.yaml +10 -0
  124. skillplay/tui/__init__.py +3 -0
  125. skillplay/tui/app.py +1108 -0
  126. skillplay-1.1.1.dist-info/METADATA +13 -0
  127. skillplay-1.1.1.dist-info/RECORD +129 -0
  128. skillplay-1.1.1.dist-info/WHEEL +4 -0
  129. skillplay-1.1.1.dist-info/entry_points.txt +2 -0
skillplay/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """skillplay — learn-by-play TUI for dev skills."""
2
+
3
+ __all__ = []
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
+ }