kdallash commited on
Commit
b9ea63d
·
verified ·
1 Parent(s): 2958fd3

Upload 4 files

Browse files
recommendation/__init__.py ADDED
File without changes
recommendation/features.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared feature engineering.
3
+
4
+ CRITICAL: this module is imported by BOTH generate_data.py (to build training
5
+ rows) and ml_policy.py (to build live scoring rows). Using the exact same
6
+ functions in both places is what guarantees train/serve parity for
7
+ `current_level_*`, rolling stats, the estimated-ability proxy, etc.
8
+
9
+ No function here ever reads Child.ability, Child.learning_rate, or
10
+ Child.fatigue_factor (the hidden simulator ground truth). Everything is
11
+ derived from observable history only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from recommendation.simulator import (
22
+ CATEGORIES,
23
+ CATEGORY_METRIC,
24
+ DIFFICULTY_B,
25
+ DISCRIMINATION_EASY,
26
+ GAME_BY_CATEGORY,
27
+ GAME_IDS,
28
+ GAMES,
29
+ ROLLING_WINDOWS,
30
+ Child,
31
+ logit,
32
+ )
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Rolling / history helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _metric_key(category: str) -> str:
40
+ return "accuracy_score" if CATEGORY_METRIC[category] == "accuracy" else "efficiency_score"
41
+
42
+
43
+ def rolling_values(history, category, n):
44
+ key = _metric_key(category)
45
+ vals = [r[key] for r in history if r["game_category"] == category and pd.notna(r[key])]
46
+ return vals[-n:]
47
+
48
+
49
+ def rolling_mean(history, category, n):
50
+ vals = rolling_values(history, category, n)
51
+ return float(np.mean(vals)) if vals else np.nan
52
+
53
+
54
+ def rolling_std(history, category, n):
55
+ vals = rolling_values(history, category, n)
56
+ return float(np.std(vals)) if len(vals) >= 2 else np.nan
57
+
58
+
59
+ def rolling_mean_by_game(history, game_id, n):
60
+ cfg = GAMES[game_id]
61
+ key = "accuracy_score" if cfg["metric"] == "accuracy" else "efficiency_score"
62
+ vals = [r[key] for r in history if r["game_id"] == game_id and pd.notna(r[key])]
63
+ vals = vals[-n:]
64
+ return float(np.mean(vals)) if vals else np.nan
65
+
66
+
67
+ def weakest_category(history, excluded: set) -> str:
68
+ eligible = [c for c in CATEGORIES if c not in excluded] or list(CATEGORIES)
69
+ scores = {}
70
+ for c in eligible:
71
+ m = rolling_mean(history, c, 5)
72
+ scores[c] = m if not np.isnan(m) else -1.0
73
+ return min(scores, key=scores.get)
74
+
75
+
76
+ def pick_game_in_category(history, category) -> str:
77
+ candidates = GAME_BY_CATEGORY[category]
78
+ if len(candidates) == 1:
79
+ return candidates[0]
80
+
81
+ def game_score(gid):
82
+ v = rolling_mean_by_game(history, gid, 5)
83
+ return v if not np.isnan(v) else -1.0
84
+
85
+ return min(candidates, key=game_score)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Estimated ability (observable proxy for hidden theta — NOT ground truth)
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def estimate_theta(history, category) -> float:
94
+ """
95
+ Statistical point estimate of the child's theta in `category`, derived
96
+ only from observed (difficulty, score) pairs in history. Uses a simple
97
+ inverse-IRT approximation (assumes the "easy" discrimination constant,
98
+ which is an approximation but a reasonable, stable proxy — this is a
99
+ feature, not a claim of ground truth).
100
+
101
+ Falls back to 0.0 (== ability 0.5) when there is no history yet.
102
+ """
103
+ metric = CATEGORY_METRIC[category]
104
+ key = "accuracy_score" if metric == "accuracy" else "efficiency_score"
105
+ obs = [
106
+ (r["difficulty"], r[key])
107
+ for r in history
108
+ if r["game_category"] == category and pd.notna(r[key])
109
+ ]
110
+ obs = obs[-10:] # recency-weighted via truncation, simple and stable
111
+ if not obs:
112
+ return 0.0
113
+ thetas = []
114
+ for difficulty, score in obs:
115
+ b = DIFFICULTY_B[difficulty]
116
+ thetas.append(b + logit(score) / DISCRIMINATION_EASY)
117
+ return float(np.mean(thetas))
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Learner-state (context) features — identical across all candidates for a
122
+ # given decision point.
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ def build_learner_context(child: Child, timestamp: datetime) -> dict:
127
+ feats = {
128
+ "age": child.age,
129
+ "plays_in_session": child.plays_in_session,
130
+ "is_onboarding": int(bool(child.onboarding_queue)),
131
+ "last_game_category": (child.history[-1]["game_category"] if child.history else "none"),
132
+ }
133
+
134
+ for gid in GAME_IDS:
135
+ idx = child.game_last_index.get(gid)
136
+ feats[f"games_since_played_{gid}"] = (
137
+ child.games_played_count - idx if idx is not None else np.nan
138
+ )
139
+
140
+ for cat in CATEGORIES:
141
+ idx = child.category_last_index.get(cat)
142
+ since_played = child.games_played_count - idx if idx is not None else np.nan
143
+ feats[f"category_since_played_{cat}"] = since_played
144
+ feats[f"is_first_play_{cat}"] = int(idx is None)
145
+
146
+ for n in ROLLING_WINDOWS:
147
+ feats[f"rolling_{cat}_last{n}"] = rolling_mean(child.history, cat, n)
148
+
149
+ feats[f"rolling_std_{cat}_last5"] = rolling_std(child.history, cat, 5)
150
+ last3 = feats[f"rolling_{cat}_last3"]
151
+ last10 = feats[f"rolling_{cat}_last10"]
152
+ feats[f"trend_{cat}"] = (
153
+ last3 - last10 if not (np.isnan(last3) or np.isnan(last10)) else 0.0
154
+ )
155
+
156
+ feats[f"sessions_played_{cat}"] = len(child.category_sessions_played[cat])
157
+
158
+ last_ts = child.category_last_ts.get(cat)
159
+ feats[f"days_since_last_played_{cat}"] = (
160
+ (timestamp - last_ts).total_seconds() / 86400.0 if last_ts is not None else np.nan
161
+ )
162
+
163
+ feats[f"current_level_{cat}"] = child.category_level[cat]
164
+ feats[f"estimated_theta_{cat}"] = estimate_theta(child.history, cat)
165
+
166
+ return feats
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Candidate-action + interaction features
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def build_candidate_row(learner_ctx: dict, game_id: str, difficulty: int) -> dict:
175
+ cfg = GAMES[game_id]
176
+ category = cfg["category"]
177
+ b = DIFFICULTY_B[difficulty]
178
+ est_theta = learner_ctx[f"estimated_theta_{category}"]
179
+ current_level = learner_ctx[f"current_level_{category}"]
180
+
181
+ row = dict(learner_ctx)
182
+ row.update(
183
+ {
184
+ "game_id": game_id,
185
+ "game_category": category,
186
+ "difficulty": difficulty,
187
+ "difficulty_b": b,
188
+ "theta_minus_difficulty_b": est_theta - b,
189
+ "abs_theta_minus_difficulty_b": abs(est_theta - b),
190
+ "candidate_level_diff": difficulty - current_level,
191
+ "candidate_matches_current_level": int(difficulty == current_level),
192
+ "candidate_category_staleness": learner_ctx[f"category_since_played_{category}"],
193
+ "candidate_category_trend": learner_ctx[f"trend_{category}"],
194
+ }
195
+ )
196
+ return row
197
+
198
+
199
+ ALL_CANDIDATES = [(gid, d) for gid in GAME_IDS for d in (1, 2, 3)]
200
+
201
+
202
+ FEATURE_COLS = [
203
+ "game_id",
204
+ "game_category",
205
+ "difficulty",
206
+ "difficulty_b",
207
+ "theta_minus_difficulty_b",
208
+ "abs_theta_minus_difficulty_b",
209
+ "candidate_level_diff",
210
+ "candidate_matches_current_level",
211
+ "candidate_category_staleness",
212
+ "candidate_category_trend",
213
+ "age",
214
+ "is_onboarding",
215
+ "plays_in_session",
216
+ "last_game_category",
217
+ ] + [f"games_since_played_{g}" for g in GAME_IDS] + sum(
218
+ (
219
+ [
220
+ f"category_since_played_{c}",
221
+ f"is_first_play_{c}",
222
+ f"rolling_{c}_last3",
223
+ f"rolling_{c}_last5",
224
+ f"rolling_{c}_last10",
225
+ f"rolling_std_{c}_last5",
226
+ f"trend_{c}",
227
+ f"sessions_played_{c}",
228
+ f"days_since_last_played_{c}",
229
+ f"current_level_{c}",
230
+ f"estimated_theta_{c}",
231
+ ]
232
+ for c in CATEGORIES
233
+ ),
234
+ [],
235
+ )
236
+
237
+ CATEGORICAL_COLS = ["game_id", "game_category", "last_game_category"]
238
+ TARGET_COL = "reward"
239
+ GROUP_COL = "learner_id"
recommendation/recommendation_routes.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Dict, Optional
4
+
5
+ import joblib
6
+ import numpy as np
7
+ import pandas as pd
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import BaseModel, Field, validator
10
+
11
+ from recommendation.simulator import (
12
+ GAMES,
13
+ CATEGORIES,
14
+ GAME_IDS,
15
+ DIFFICULTIES,
16
+ )
17
+ from recommendation.features import build_candidate_row
18
+
19
+ logger = logging.getLogger("recommendation")
20
+
21
+ router = APIRouter()
22
+
23
+ ALL_CANDIDATES = [
24
+ (game_id, difficulty)
25
+ for game_id in GAME_IDS
26
+ for difficulty in DIFFICULTIES
27
+ ]
28
+
29
+ VALID_LAST_GAME_CATEGORY = set(CATEGORIES) | {"none"}
30
+
31
+ # ============================================================
32
+ # Model
33
+ # ============================================================
34
+
35
+ # Quantile-75 is the deployed policy (chosen for category balance --
36
+ # 14.4% memory coverage vs. 3.2% for the mean-regression baseline, at a
37
+ # measured, accepted reward trade-off). Do NOT point this back at
38
+ # reward_model.joblib (the mean baseline) without re-approving that
39
+ # trade-off first.
40
+ BASE_DIR = Path(__file__).resolve().parent.parent
41
+ MODEL_PATH = BASE_DIR / "models" / "deployed_model.joblib"
42
+
43
+ try:
44
+ bundle = joblib.load(MODEL_PATH)
45
+ except Exception as e:
46
+ bundle = None
47
+ logger.error(f"Could not load model from {MODEL_PATH}: {e}")
48
+
49
+
50
+ # ============================================================
51
+ # Request schema
52
+ # ============================================================
53
+
54
+ class LearnerFeatures(BaseModel):
55
+ """
56
+ The 33 learner-state columns not already top-level on
57
+ RecommendationRequest (age, plays_in_session, last_game_category) and
58
+ not part of current_level. Field names match features.py:FEATURE_COLS
59
+ exactly. Nulls are accepted only where BACKEND_FEATURE_SPEC.md allows
60
+ them (first play in a category / game never played).
61
+ """
62
+
63
+ games_since_played_prob_maze: Optional[int] = None
64
+ games_since_played_att_odd_one_out: Optional[int] = None
65
+ games_since_played_mem_recognition: Optional[int] = None
66
+
67
+ category_since_played_attention: Optional[int] = None
68
+ category_since_played_memory: Optional[int] = None
69
+ category_since_played_problem_solving: Optional[int] = None
70
+
71
+ is_first_play_attention: int
72
+ is_first_play_memory: int
73
+ is_first_play_problem_solving: int
74
+
75
+ rolling_attention_last3: Optional[float] = None
76
+ rolling_memory_last3: Optional[float] = None
77
+ rolling_problem_solving_last3: Optional[float] = None
78
+
79
+ rolling_attention_last5: Optional[float] = None
80
+ rolling_memory_last5: Optional[float] = None
81
+ rolling_problem_solving_last5: Optional[float] = None
82
+
83
+ rolling_attention_last10: Optional[float] = None
84
+ rolling_memory_last10: Optional[float] = None
85
+ rolling_problem_solving_last10: Optional[float] = None
86
+
87
+ rolling_std_attention_last5: Optional[float] = None
88
+ rolling_std_memory_last5: Optional[float] = None
89
+ rolling_std_problem_solving_last5: Optional[float] = None
90
+
91
+ trend_attention: float = 0.0
92
+ trend_memory: float = 0.0
93
+ trend_problem_solving: float = 0.0
94
+
95
+ sessions_played_attention: int
96
+ sessions_played_memory: int
97
+ sessions_played_problem_solving: int
98
+
99
+ days_since_last_played_attention: Optional[float] = None
100
+ days_since_last_played_memory: Optional[float] = None
101
+ days_since_last_played_problem_solving: Optional[float] = None
102
+
103
+ estimated_theta_attention: float = 0.0
104
+ estimated_theta_memory: float = 0.0
105
+ estimated_theta_problem_solving: float = 0.0
106
+
107
+
108
+ class RecommendationRequest(BaseModel):
109
+ learner_id: str
110
+ # Accepted for logging/audit only. days_since_last_played_* is computed
111
+ # by the backend and sent inside `features`, so timestamp does not feed
112
+ # into any feature computation here.
113
+ timestamp: str
114
+ age: int
115
+ plays_in_session: int = 0
116
+ last_game_category: str = "none"
117
+ features: LearnerFeatures
118
+ current_level: Dict[str, int]
119
+
120
+ @validator("last_game_category")
121
+ def _valid_last_game_category(cls, v):
122
+ if v not in VALID_LAST_GAME_CATEGORY:
123
+ raise ValueError(
124
+ f"last_game_category must be one of {sorted(VALID_LAST_GAME_CATEGORY)}, got {v!r}"
125
+ )
126
+ return v
127
+
128
+ @validator("current_level")
129
+ def _valid_current_level(cls, v):
130
+ missing = [c for c in CATEGORIES if c not in v]
131
+ if missing:
132
+ raise ValueError(f"current_level is missing required categories: {missing}")
133
+ bad = {c: lvl for c, lvl in v.items() if lvl not in (1, 2, 3)}
134
+ if bad:
135
+ raise ValueError(f"current_level values must be 1, 2 or 3, got: {bad}")
136
+ return v
137
+
138
+
139
+ # ============================================================
140
+ # Health check
141
+ # ============================================================
142
+
143
+ @router.get("/health")
144
+ def health_check():
145
+ return {
146
+ "status": "ok",
147
+ "model_loaded": bundle is not None,
148
+ "model_path": str(MODEL_PATH),
149
+ }
150
+
151
+
152
+ # ============================================================
153
+ # Recommendation
154
+ # ============================================================
155
+
156
+ @router.post("/recommend")
157
+ def recommend(request: RecommendationRequest):
158
+
159
+ if bundle is None:
160
+ raise HTTPException(
161
+ status_code=500,
162
+ detail="ML model is not loaded.",
163
+ )
164
+
165
+ # ----------------------------------------------------
166
+ # 1. Onboarding check -- bypass the model entirely.
167
+ #
168
+ # The model was trained on data where the first play of each of the
169
+ # 3 games was ALWAYS forced (never chosen by the reward model), so
170
+ # calling the model for a learner who hasn't played all 3 games yet
171
+ # would feed it a context distribution it never saw in training.
172
+ # "Never played" is read from games_since_played_<game> being null,
173
+ # rather than trusting a client-supplied is_onboarding flag.
174
+ # ----------------------------------------------------
175
+
176
+ games_since_played = {
177
+ "prob_maze": request.features.games_since_played_prob_maze,
178
+ "att_odd_one_out": request.features.games_since_played_att_odd_one_out,
179
+ "mem_recognition": request.features.games_since_played_mem_recognition,
180
+ }
181
+ unplayed_games = [g for g in GAME_IDS if games_since_played[g] is None]
182
+
183
+ if unplayed_games:
184
+ # Deterministic order (GAME_IDS order) rather than the random
185
+ # shuffle used in training data generation -- onboarding order
186
+ # doesn't affect the reward objective, only which game happens
187
+ # to go first, so determinism here is a reasonable simplification.
188
+ game_id = unplayed_games[0]
189
+ difficulty = 1
190
+ category = GAMES[game_id]["category"]
191
+
192
+ logger.info(f"learner {request.learner_id}: onboarding -> {game_id} d{difficulty}")
193
+
194
+ return {
195
+ "learner_id": request.learner_id,
196
+ "game_id": game_id,
197
+ "category": category,
198
+ "difficulty": difficulty,
199
+ "predicted_reward": None,
200
+ "is_onboarding": True,
201
+ "candidates": None,
202
+ }
203
+
204
+ try:
205
+ # ----------------------------------------------------
206
+ # 2. Build learner context
207
+ # ----------------------------------------------------
208
+
209
+ learner_ctx = {
210
+ "age": request.age,
211
+ "plays_in_session": request.plays_in_session,
212
+ "is_onboarding": 0, # always 0 here -- onboarding returned above
213
+ "last_game_category": request.last_game_category,
214
+ }
215
+ learner_ctx.update(request.features.dict())
216
+
217
+ for category in CATEGORIES:
218
+ learner_ctx[f"current_level_{category}"] = request.current_level[category]
219
+
220
+ # ----------------------------------------------------
221
+ # 3. Generate candidate rows
222
+ # ----------------------------------------------------
223
+
224
+ rows = [
225
+ build_candidate_row(learner_ctx, game_id, difficulty)
226
+ for game_id, difficulty in ALL_CANDIDATES
227
+ ]
228
+
229
+ # ----------------------------------------------------
230
+ # 4. Build feature matrix, same column order as training
231
+ # ----------------------------------------------------
232
+
233
+ X = pd.DataFrame(rows)
234
+ X = X[bundle["feature_cols"]]
235
+
236
+ # ----------------------------------------------------
237
+ # 5. Handle categorical features
238
+ # ----------------------------------------------------
239
+
240
+ lib = bundle.get("lib")
241
+
242
+ for column in bundle.get("cat_cols", []):
243
+ if lib == "catboost":
244
+ X[column] = X[column].astype(str)
245
+ else:
246
+ X[column] = X[column].astype("category")
247
+
248
+ # ----------------------------------------------------
249
+ # 6. Generate predictions (quantile/mean/UCB bundle types supported;
250
+ # Softmax changes selection policy, not reward prediction)
251
+ # ----------------------------------------------------
252
+
253
+ if bundle.get("type") == "ucb":
254
+ q10 = bundle["models"][0.1].predict(X)
255
+ q50 = bundle["models"][0.5].predict(X)
256
+ q90 = bundle["models"][0.9].predict(X)
257
+ predictions = q50 + bundle["k"] * (q90 - q10)
258
+ else:
259
+ predictions = bundle["model"].predict(X)
260
+
261
+ # ----------------------------------------------------
262
+ # 7. Apply category calibrators if this bundle uses them
263
+ # ----------------------------------------------------
264
+
265
+ calibrators = bundle.get("calibrators")
266
+
267
+ if calibrators:
268
+ categories = [row["game_category"] for row in rows]
269
+ predictions = np.array(
270
+ [
271
+ calibrators[category].predict([prediction])[0]
272
+ if category in calibrators
273
+ else prediction
274
+ for prediction, category in zip(predictions, categories)
275
+ ]
276
+ )
277
+
278
+ # ----------------------------------------------------
279
+ # 8. Select candidate
280
+ # ----------------------------------------------------
281
+ # Default policy: deterministic argmax.
282
+ # Softmax policy: sample from a probability distribution derived
283
+ # from the predicted rewards. The model still predicts a reward
284
+ # for every candidate; Softmax only changes how the final action
285
+ # is selected.
286
+ #
287
+ # Expected bundle format for Softmax:
288
+ # {
289
+ # "type": "softmax",
290
+ # "T": 0.05,
291
+ # ...
292
+ # }
293
+ if bundle.get("type") == "softmax":
294
+ T = float(bundle["T"])
295
+ if T <= 0:
296
+ raise ValueError("Softmax temperature T must be > 0.")
297
+
298
+ # Subtract max for numerical stability.
299
+ z = (predictions - predictions.max()) / T
300
+ exp_z = np.exp(z)
301
+ probabilities = exp_z / exp_z.sum()
302
+
303
+ best_idx = int(
304
+ np.random.choice(
305
+ len(ALL_CANDIDATES),
306
+ p=probabilities,
307
+ )
308
+ )
309
+ else:
310
+ best_idx = int(np.argmax(predictions))
311
+
312
+ game_id, difficulty = ALL_CANDIDATES[best_idx]
313
+ category = GAMES[game_id]["category"]
314
+
315
+ candidates = [
316
+ {
317
+ "game_id": gid,
318
+ "difficulty": diff,
319
+ "predicted_reward": round(float(pred), 4),
320
+ }
321
+ for (gid, diff), pred in zip(ALL_CANDIDATES, predictions)
322
+ ]
323
+
324
+ logger.info(
325
+ f"learner {request.learner_id}: recommended {game_id} d{difficulty} "
326
+ f"(reward={predictions[best_idx]:.4f})"
327
+ )
328
+
329
+ # ----------------------------------------------------
330
+ # 9. Return recommendation
331
+ # ----------------------------------------------------
332
+
333
+ return {
334
+ "learner_id": request.learner_id,
335
+ "game_id": game_id,
336
+ "category": category,
337
+ "difficulty": difficulty,
338
+ "predicted_reward": round(float(predictions[best_idx]), 4),
339
+ "is_onboarding": False,
340
+ "candidates": candidates,
341
+ }
342
+
343
+ except KeyError as e:
344
+ # a required feature/category was missing after validation --
345
+ # should be rare given the schema above, but keep this specific
346
+ # so it's obviously a data problem, not a model problem
347
+ logger.exception(f"learner {request.learner_id}: missing field {e}")
348
+ raise HTTPException(
349
+ status_code=422,
350
+ detail=f"Request is missing a required field: {e}",
351
+ )
352
+
353
+ except Exception as e:
354
+ # log the real exception server-side for debugging, but don't
355
+ # leak internal details (stack traces, file paths, library
356
+ # internals) to the caller
357
+ logger.exception(f"learner {request.learner_id}: recommendation failed")
358
+ raise HTTPException(
359
+ status_code=500,
360
+ detail="Recommendation failed due to an internal error.",
361
+ )
recommendation/simulator.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aiko environment / simulator core.
3
+
4
+ This module preserves the original simulator's IRT-based game mechanics
5
+ and behavior policy unchanged (per the review's instruction to keep
6
+ proven simulator concepts intact). What changes lives in generate_data.py
7
+ (counterfactual, all-9-candidate labeling) and features.py (feature
8
+ engineering shared between training-time and inference-time).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+
16
+ import numpy as np
17
+ from scipy.stats import truncnorm
18
+
19
+ DIFFICULTIES = (1, 2, 3)
20
+
21
+ GAMES = {
22
+ "prob_maze": {
23
+ "category": "problem_solving",
24
+ "metric": "efficiency",
25
+ "comfort_target": 0.75,
26
+ "optimal_moves": {1: 5, 2: 9, 3: 14},
27
+ "seconds_per_move_mean": 3.0,
28
+ "difficulty_b": {1: -1.45, 2: -0.55, 3: 0.35},
29
+ },
30
+ "att_odd_one_out": {
31
+ "category": "attention",
32
+ "metric": "accuracy",
33
+ "comfort_target": 0.78,
34
+ "n_trials_range": (10, 16),
35
+ "difficulty_b": {1: -1.45, 2: -0.55, 3: 0.35},
36
+ },
37
+ "mem_recognition": {
38
+ "category": "memory",
39
+ "metric": "accuracy",
40
+ "comfort_target": 0.72,
41
+ "n_trials_range": (10, 18),
42
+ "difficulty_b": {1: -1.45, 2: -0.55, 3: 0.35},
43
+ },
44
+ }
45
+
46
+ DIFFICULTY_B = {1: -1.45, 2: -0.55, 3: 0.35}
47
+
48
+ CATEGORIES = sorted({g["category"] for g in GAMES.values()})
49
+ GAME_IDS = list(GAMES.keys())
50
+ GAME_BY_CATEGORY = {c: [g for g, cfg in GAMES.items() if cfg["category"] == c] for c in CATEGORIES}
51
+ CATEGORY_METRIC = {c: GAMES[GAME_BY_CATEGORY[c][0]]["metric"] for c in CATEGORIES}
52
+ CATEGORY_OF_GAME = {g: cfg["category"] for g, cfg in GAMES.items()}
53
+
54
+ EXPLORATION_RATE = 0.30
55
+ PASS_THRESHOLD = 0.50
56
+ HARD_FAIL_THRESHOLD = 0.20
57
+
58
+ FLOW_WIDTH_LOW = 0.13
59
+ FLOW_WIDTH_HIGH = 0.20
60
+ REWARD_FLOOR = 0.2
61
+
62
+ STALENESS_SATURATION_GAMES = 20.0
63
+ STALENESS_REWARD_WEIGHT = 0.15 # kept only as a diagnostic/feature weight now, not the training target
64
+
65
+ THETA_SCALE = 4.0
66
+ DISCRIMINATION_HARD = 3.5
67
+ DISCRIMINATION_EASY = 2.0
68
+
69
+ ROLLING_WINDOWS = (3, 5, 10)
70
+
71
+
72
+ def sigmoid(x):
73
+ return 1.0 / (1.0 + np.exp(-x))
74
+
75
+
76
+ def logit(p):
77
+ p = np.clip(p, 1e-4, 1 - 1e-4)
78
+ return float(np.log(p / (1 - p)))
79
+
80
+
81
+ def ability_to_theta(ability: float) -> float:
82
+ return (ability - 0.5) * THETA_SCALE
83
+
84
+
85
+ def irt_probability(theta: float, b: float) -> float:
86
+ diff = theta - b
87
+ a = DISCRIMINATION_HARD if diff < 0 else DISCRIMINATION_EASY
88
+ return sigmoid(a * diff)
89
+
90
+
91
+ for _cfg in GAMES.values():
92
+ _cfg["match_offset"] = logit(_cfg["comfort_target"]) / DISCRIMINATION_EASY
93
+
94
+
95
+ def natural_level(theta: float, cfg: dict) -> int:
96
+ adjusted = theta - cfg["match_offset"]
97
+ return min(DIFFICULTIES, key=lambda lvl: abs(adjusted - cfg["difficulty_b"][lvl]))
98
+
99
+
100
+ def flow_reward(score: float, target: float) -> float:
101
+ width = FLOW_WIDTH_LOW if score < target else FLOW_WIDTH_HIGH
102
+ gaussian = np.exp(-((score - target) ** 2) / (2 * width ** 2))
103
+ return REWARD_FLOOR + (1 - REWARD_FLOOR) * gaussian
104
+
105
+
106
+ def shape_reward_with_staleness(flow: float, category_since_played) -> float:
107
+ """Diagnostic-only shaped reward (NOT the training target anymore)."""
108
+ if category_since_played is None or (
109
+ isinstance(category_since_played, float) and np.isnan(category_since_played)
110
+ ):
111
+ staleness = 1.0
112
+ else:
113
+ staleness = min(1.0, float(category_since_played) / STALENESS_SATURATION_GAMES)
114
+ base = (1.0 - STALENESS_REWARD_WEIGHT) + STALENESS_REWARD_WEIGHT * staleness
115
+ return float(np.clip(flow * base, REWARD_FLOOR, 1.0))
116
+
117
+
118
+ def trunc_normal(mean, sd, low, high, size, rng):
119
+ a, b = (low - mean) / sd, (high - mean) / sd
120
+ return truncnorm.rvs(a, b, loc=mean, scale=sd, size=size, random_state=rng)
121
+
122
+
123
+ @dataclass
124
+ class Child:
125
+ learner_id: str
126
+ age: int
127
+ ability: dict # hidden ground truth — simulator-only, never exposed to model
128
+ learning_rate: float # hidden ground truth — simulator-only
129
+ fatigue_factor: float # hidden ground truth — simulator-only
130
+
131
+ category_level: dict = field(default_factory=lambda: {c: 1 for c in CATEGORIES})
132
+ onboarding_queue: list = field(default_factory=list)
133
+
134
+ games_played_count: int = 0
135
+ game_last_index: dict = field(default_factory=dict)
136
+ category_last_index: dict = field(default_factory=dict)
137
+ category_last_ts: dict = field(default_factory=dict)
138
+ category_sessions_played: dict = field(default_factory=lambda: {c: set() for c in CATEGORIES})
139
+ history: list = field(default_factory=list)
140
+
141
+ session_id: int = -1
142
+ position_in_session: int = 0
143
+ plays_in_session: int = 0
144
+
145
+
146
+ def sample_child(idx: int, rng: np.random.Generator) -> Child:
147
+ age = int(rng.integers(4, 10))
148
+ ability = {c: float(trunc_normal(0.5, 0.16, 0.05, 0.95, None, rng)) for c in CATEGORIES}
149
+ learning_rate = float(trunc_normal(0.02, 0.012, 0.0, 0.08, None, rng))
150
+ fatigue_factor = float(trunc_normal(0.03, 0.015, 0.0, 0.1, None, rng))
151
+
152
+ child = Child(
153
+ learner_id=f"child_{idx:04d}",
154
+ age=age,
155
+ ability=ability,
156
+ learning_rate=learning_rate,
157
+ fatigue_factor=fatigue_factor,
158
+ )
159
+ order = list(GAME_IDS)
160
+ rng.shuffle(order)
161
+ child.onboarding_queue = order
162
+ return child
163
+
164
+
165
+ def simulate_accuracy_game(child: Child, game_id: str, level: int, fatigue: float, rng: np.random.Generator) -> dict:
166
+ cfg = GAMES[game_id]
167
+ ability = child.ability[cfg["category"]]
168
+ theta = ability_to_theta(ability) - fatigue * THETA_SCALE
169
+ b = cfg["difficulty_b"][level]
170
+ p_correct = float(np.clip(irt_probability(theta, b), 0.02, 0.98))
171
+ n_trials = int(rng.integers(*cfg["n_trials_range"]))
172
+ correct = int(rng.binomial(n_trials, p_correct))
173
+ accuracy_score = correct / n_trials
174
+ return {
175
+ "accuracy_score": accuracy_score,
176
+ "efficiency_score": np.nan,
177
+ "time_taken_seconds": np.nan,
178
+ "n_trials": n_trials,
179
+ }
180
+
181
+
182
+ def simulate_efficiency_game(child: Child, game_id: str, level: int, fatigue: float, rng: np.random.Generator) -> dict:
183
+ cfg = GAMES[game_id]
184
+ ability = child.ability[cfg["category"]]
185
+ theta = ability_to_theta(ability) - fatigue * THETA_SCALE
186
+ b = cfg["difficulty_b"][level]
187
+ base_efficiency = irt_probability(theta, b)
188
+ noise = float(trunc_normal(0.0, 0.06, -0.2, 0.2, None, rng))
189
+ efficiency_score = float(np.clip(base_efficiency + noise, 0.03, 1.0))
190
+ effective_ability = float(np.clip(ability - fatigue, 0.05, 0.98))
191
+ optimal_moves = cfg["optimal_moves"][level]
192
+ moves_taken = max(optimal_moves, int(round(optimal_moves / efficiency_score)))
193
+ per_move_time = max(
194
+ 0.5,
195
+ float(
196
+ trunc_normal(
197
+ cfg["seconds_per_move_mean"] * (1.15 - 0.3 * effective_ability), 0.6, 0.5, 8.0, None, rng
198
+ )
199
+ ),
200
+ )
201
+ time_taken_seconds = round(moves_taken * per_move_time, 1)
202
+ return {
203
+ "accuracy_score": np.nan,
204
+ "efficiency_score": efficiency_score,
205
+ "time_taken_seconds": time_taken_seconds,
206
+ "n_trials": np.nan,
207
+ }
208
+
209
+
210
+ def simulate_game(child: Child, game_id: str, level: int, fatigue: float, rng: np.random.Generator) -> dict:
211
+ cfg = GAMES[game_id]
212
+ if cfg["metric"] == "accuracy":
213
+ return simulate_accuracy_game(child, game_id, level, fatigue, rng)
214
+ return simulate_efficiency_game(child, game_id, level, fatigue, rng)
215
+
216
+
217
+ def score_and_reward(game_id: str, outcome: dict) -> tuple[float, float]:
218
+ """Return (raw_score, raw_flow_reward) for a simulated outcome."""
219
+ cfg = GAMES[game_id]
220
+ score = outcome["accuracy_score"] if cfg["metric"] == "accuracy" else outcome["efficiency_score"]
221
+ reward = flow_reward(score, cfg["comfort_target"])
222
+ return float(score), float(reward)
223
+
224
+
225
+ def update_behavior_policy_state(child: Child, category: str, score: float) -> None:
226
+ if score >= PASS_THRESHOLD:
227
+ child.category_level[category] = min(3, child.category_level[category] + 1)
228
+ elif score < HARD_FAIL_THRESHOLD:
229
+ child.category_level[category] = max(1, child.category_level[category] - 1)
230
+
231
+
232
+ def apply_learning(child: Child, categories_played) -> None:
233
+ for cat in categories_played:
234
+ child.ability[cat] = min(0.98, child.ability[cat] + child.learning_rate * (1 - child.ability[cat]))