cosmicmicra commited on
Commit
e34f815
Β·
verified Β·
1 Parent(s): 87a78e6

Add adaptive engine (Elo + BKT + Thompson Sampling orchestrator)

Browse files
Files changed (1) hide show
  1. adaptive_engine.py +628 -0
adaptive_engine.py ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MathLingua β€” Adaptive Engine
3
+
4
+ Hybrid adaptive algorithm combining:
5
+ 1. Elo Rating β€” overall ability tracking with hint-weighted outcomes
6
+ 2. Bayesian Knowledge Tracing (BKT) β€” per-topic mastery estimation
7
+ 3. Thompson Sampling β€” intelligent question-level selection with ZPD windowing
8
+
9
+ The orchestrator combines all three to produce progression decisions:
10
+ SKIP (+2), INCREASE (+1), MAINTAIN (0), DECREASE (-1), RAPID_DECREASE (-2)
11
+
12
+ Reference: MathLingua Technical Specification Β§6
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import random
19
+ from dataclasses import dataclass, field
20
+ from typing import Optional
21
+
22
+ from feature_engineering import (
23
+ FeatureEngineer,
24
+ EngineeredFeatures,
25
+ InteractionSignals,
26
+ )
27
+
28
+
29
+ # ────────────────────────────────────────────────────────
30
+ # Constants
31
+ # ────────────────────────────────────────────────────────
32
+
33
+ LEVELS = [
34
+ "1.1", "1.2", "1.3", "1.4", "1.5",
35
+ "2.1", "2.2", "2.3", "2.4", "2.5",
36
+ "3.1", "3.2", "3.3", "3.4", "3.5",
37
+ ]
38
+
39
+ LEVEL_TO_ELO: dict[str, int] = {
40
+ "1.1": 820, "1.2": 870, "1.3": 920, "1.4": 970, "1.5": 1020,
41
+ "2.1": 1070, "2.2": 1120, "2.3": 1170, "2.4": 1220, "2.5": 1270,
42
+ "3.1": 1320, "3.2": 1370, "3.3": 1420, "3.4": 1470, "3.5": 1520,
43
+ }
44
+
45
+ ELO_TO_LEVEL = sorted(LEVEL_TO_ELO.items(), key=lambda x: x[1])
46
+
47
+ TOPICS = ["arithmetic", "fractions", "percentages", "algebra", "geometry", "statistics"]
48
+
49
+ INITIAL_STUDENT_ELO = 1000
50
+
51
+
52
+ # ────────────────────────────────────────────────────────
53
+ # Elo Engine
54
+ # ────────────────────────────────────────────────────────
55
+
56
+ class EloEngine:
57
+ """
58
+ Elo rating system adapted for education with hint-weighted outcomes.
59
+
60
+ Weighted outcomes: 1.00 (no hint), 0.75 (L1), 0.50 (L2), 0.25 (L3), 0.00 (L4/incorrect)
61
+ K-factor schedule: 48 (first 10), 32 (11–30), 24 (30+)
62
+ """
63
+
64
+ def __init__(self):
65
+ pass
66
+
67
+ @staticmethod
68
+ def expected_score(student_elo: float, question_elo: float) -> float:
69
+ """E_s = 1 / (1 + 10^((R_q - R_s) / 400))"""
70
+ return 1.0 / (1.0 + math.pow(10.0, (question_elo - student_elo) / 400.0))
71
+
72
+ @staticmethod
73
+ def k_factor_student(interaction_count: int) -> float:
74
+ if interaction_count <= 10:
75
+ return 48.0
76
+ elif interaction_count <= 30:
77
+ return 32.0
78
+ else:
79
+ return 24.0
80
+
81
+ @staticmethod
82
+ def k_factor_question(interaction_count: int) -> float:
83
+ if interaction_count <= 10:
84
+ return 8.0
85
+ elif interaction_count <= 30:
86
+ return 6.0
87
+ else:
88
+ return 4.0
89
+
90
+ def update(
91
+ self,
92
+ student_elo: float,
93
+ question_elo: float,
94
+ weighted_outcome: float,
95
+ student_interactions: int,
96
+ ) -> tuple[float, float]:
97
+ """
98
+ Update student and question Elo ratings.
99
+
100
+ Returns: (new_student_elo, new_question_elo)
101
+ """
102
+ expected = self.expected_score(student_elo, question_elo)
103
+ ks = self.k_factor_student(student_interactions)
104
+ kq = self.k_factor_question(student_interactions)
105
+
106
+ new_student = student_elo + ks * (weighted_outcome - expected)
107
+ new_question = question_elo + kq * (expected - weighted_outcome)
108
+
109
+ return round(new_student, 1), round(new_question, 1)
110
+
111
+
112
+ # ────────────────────────────────────────────────────────
113
+ # Bayesian Knowledge Tracing (BKT)
114
+ # ────────────────────────────────────────────────────────
115
+
116
+ @dataclass
117
+ class BKTParams:
118
+ """BKT parameters for one topic."""
119
+ p_know: float = 0.10 # P(L_0) β€” prior knowledge
120
+ p_learn: float = 0.15 # P(T) β€” learn rate
121
+ p_slip: float = 0.10 # P(S) β€” slip
122
+ p_guess: float = 0.25 # P(G) β€” guess
123
+
124
+
125
+ class BKTEngine:
126
+ """
127
+ Bayesian Knowledge Tracing with slip adjustment for scaffold usage.
128
+
129
+ P(S)_adj = P(S) Γ— (1 + 0.5 Γ— hint_depth_normalized)
130
+
131
+ This makes BKT more skeptical of scaffold-assisted correctness.
132
+ """
133
+
134
+ def __init__(self, topics: Optional[list[str]] = None):
135
+ self.topics = topics or TOPICS
136
+ self.params: dict[str, BKTParams] = {
137
+ t: BKTParams() for t in self.topics
138
+ }
139
+
140
+ def get_mastery(self, topic: str) -> float:
141
+ """Return P(know) for a topic."""
142
+ return self.params.get(topic, BKTParams()).p_know
143
+
144
+ def update(
145
+ self,
146
+ topic: str,
147
+ weighted_outcome: float,
148
+ hint_depth_normalized: float,
149
+ ) -> float:
150
+ """
151
+ Update P(know) for a topic given an interaction outcome.
152
+
153
+ Args:
154
+ topic: Math topic string
155
+ weighted_outcome: 0.0–1.0 hint-weighted outcome
156
+ hint_depth_normalized: h_i / 4 (0.0–1.0)
157
+
158
+ Returns: New P(know)
159
+ """
160
+ if topic not in self.params:
161
+ self.params[topic] = BKTParams()
162
+
163
+ p = self.params[topic]
164
+
165
+ # Adjust slip probability based on hint depth
166
+ p_slip_adj = p.p_slip * (1.0 + 0.5 * hint_depth_normalized)
167
+ p_slip_adj = min(p_slip_adj, 0.5) # cap at 0.5
168
+
169
+ # Determine if "correct" or "incorrect" for BKT purposes
170
+ is_correct = weighted_outcome >= 0.5
171
+
172
+ if is_correct:
173
+ # P(L_n | correct) = P(L) * (1-P(S)_adj) / [P(L)*(1-P(S)_adj) + (1-P(L))*P(G)]
174
+ numerator = p.p_know * (1.0 - p_slip_adj)
175
+ denominator = numerator + (1.0 - p.p_know) * p.p_guess
176
+ else:
177
+ # P(L_n | incorrect) = P(L) * P(S)_adj / [P(L)*P(S)_adj + (1-P(L))*(1-P(G))]
178
+ numerator = p.p_know * p_slip_adj
179
+ denominator = numerator + (1.0 - p.p_know) * (1.0 - p.p_guess)
180
+
181
+ if denominator > 0:
182
+ p_know_given_obs = numerator / denominator
183
+ else:
184
+ p_know_given_obs = p.p_know
185
+
186
+ # Learning transition: P(L_n) = P(L_n|O) + (1 - P(L_n|O)) * P(T)
187
+ new_p_know = p_know_given_obs + (1.0 - p_know_given_obs) * p.p_learn
188
+ new_p_know = max(0.01, min(0.99, new_p_know)) # clamp
189
+
190
+ p.p_know = round(new_p_know, 4)
191
+ return p.p_know
192
+
193
+
194
+ # ────────────────────────────────────────────────────────
195
+ # Thompson Sampling
196
+ # ────────────────────────────────────────────────────────
197
+
198
+ @dataclass
199
+ class BetaPrior:
200
+ alpha: float = 1.0
201
+ beta: float = 1.0
202
+
203
+
204
+ class ThompsonSampler:
205
+ """
206
+ Beta-Bernoulli Thompson Sampling with ZPD window and proximity bonus.
207
+
208
+ ZPD window: [current_level - 2, current_level + 3] (asymmetric upward)
209
+ Proximity bonus: Gaussian centered on student Elo, Οƒ = 100
210
+ """
211
+
212
+ def __init__(self):
213
+ self.priors: dict[str, BetaPrior] = {
214
+ level: BetaPrior() for level in LEVELS
215
+ }
216
+
217
+ def update(self, level: str, weighted_outcome: float) -> None:
218
+ """Update Beta prior for a level based on weighted outcome."""
219
+ if level not in self.priors:
220
+ self.priors[level] = BetaPrior()
221
+ self.priors[level].alpha += weighted_outcome
222
+ self.priors[level].beta += (1.0 - weighted_outcome)
223
+
224
+ def select(self, current_level: str, student_elo: float) -> str:
225
+ """
226
+ Select next question level via Thompson Sampling within ZPD window.
227
+ """
228
+ current_idx = LEVELS.index(current_level) if current_level in LEVELS else 5
229
+ # ZPD window: -2 to +3
230
+ lo = max(0, current_idx - 2)
231
+ hi = min(len(LEVELS), current_idx + 4) # +4 because slice is exclusive
232
+ candidate_levels = LEVELS[lo:hi]
233
+
234
+ best_score = -1.0
235
+ best_level = current_level
236
+
237
+ for level in candidate_levels:
238
+ prior = self.priors.get(level, BetaPrior())
239
+
240
+ # Sample from Beta distribution
241
+ sampled_theta = random.betavariate(
242
+ max(prior.alpha, 0.01),
243
+ max(prior.beta, 0.01),
244
+ )
245
+
246
+ # Gaussian proximity bonus
247
+ level_elo = LEVEL_TO_ELO.get(level, 1000)
248
+ proximity = math.exp(
249
+ -0.5 * ((level_elo - student_elo) / 100.0) ** 2
250
+ )
251
+
252
+ score = sampled_theta * proximity
253
+
254
+ if score > best_score:
255
+ best_score = score
256
+ best_level = level
257
+
258
+ return best_level
259
+
260
+
261
+ # ────────────────────────────────────────────────────────
262
+ # Feature Predictor (for P(isSolved))
263
+ # ────────────────────────────────────────────────────────
264
+
265
+ class FeaturePredictor:
266
+ """
267
+ Simple logistic model predicting P(isSolved) from features.
268
+ Weights from spec Β§5.6 (logistic regression on simulated data).
269
+ """
270
+
271
+ # Feature importance weights (from spec)
272
+ W_MCS: float = 0.42
273
+ W_ELO_GAP: float = 0.28
274
+ W_LDS: float = -0.18
275
+ W_BKT: float = 0.15
276
+ W_STREAK: float = 0.08
277
+ BIAS: float = -0.30
278
+
279
+ @staticmethod
280
+ def _sigmoid(x: float) -> float:
281
+ return 1.0 / (1.0 + math.exp(-x))
282
+
283
+ def predict(
284
+ self,
285
+ mcs_avg: float,
286
+ elo_gap: float, # student_elo - question_elo (normalized by /400)
287
+ lds_avg: float,
288
+ p_know: float,
289
+ streak: int,
290
+ ) -> float:
291
+ """
292
+ Predict probability that the student solves the next problem without L4.
293
+ """
294
+ z = (
295
+ self.BIAS
296
+ + self.W_MCS * mcs_avg
297
+ + self.W_ELO_GAP * elo_gap
298
+ + self.W_LDS * lds_avg
299
+ + self.W_BKT * p_know
300
+ + self.W_STREAK * min(streak, 5) / 5.0
301
+ )
302
+ return round(self._sigmoid(z), 4)
303
+
304
+
305
+ # ────────────────────────────────────────────────────────
306
+ # Adaptive Engine (Orchestrator)
307
+ # ────────────────────────────────────────────────────────
308
+
309
+ @dataclass
310
+ class AdaptiveState:
311
+ """Complete adaptive state for one student."""
312
+ student_elo: float = INITIAL_STUDENT_ELO
313
+ current_level: str = "2.1" # start at center
314
+ total_interactions: int = 0
315
+ streak_correct: int = 0 # consecutive weighted_outcome >= 0.75
316
+ streak_wrong: int = 0 # consecutive weighted_outcome < 0.40
317
+ recent_lds: list[float] = field(default_factory=list) # last 5
318
+ recent_mcs: list[float] = field(default_factory=list) # last 5
319
+ enhanced_scaffold: bool = False
320
+
321
+
322
+ class AdaptiveEngine:
323
+ """
324
+ Main orchestrator combining Elo, BKT, Thompson Sampling, and feature engineering.
325
+
326
+ Decision logic (from spec Β§6.5):
327
+ weighted_outcome β‰₯ 0.85 AND streak β‰₯ 3 β†’ SKIP (+2)
328
+ weighted_outcome β‰₯ 0.75 AND P(know) β‰₯ 0.7 β†’ INCREASE (+1)
329
+ weighted_outcome β‰₯ 0.40 β†’ MAINTAIN (0)
330
+ weighted_outcome β‰₯ 0.25 OR streak_wrong < 2 β†’ DECREASE (-1)
331
+ else (outcome < 0.25 AND P(know) < 0.30) β†’ RAPID_DECREASE (-2)
332
+ """
333
+
334
+ def __init__(self, seed: Optional[int] = None):
335
+ self.elo_engine = EloEngine()
336
+ self.bkt_engine = BKTEngine()
337
+ self.thompson = ThompsonSampler()
338
+ self.feature_eng = FeatureEngineer()
339
+ self.predictor = FeaturePredictor()
340
+ self.state = AdaptiveState()
341
+
342
+ if seed is not None:
343
+ random.seed(seed)
344
+
345
+ def _elo_to_level(self, elo: float) -> str:
346
+ """Map an Elo rating to the nearest sub-level."""
347
+ best_level = LEVELS[0]
348
+ best_dist = abs(elo - LEVEL_TO_ELO[LEVELS[0]])
349
+ for level, level_elo in ELO_TO_LEVEL:
350
+ dist = abs(elo - level_elo)
351
+ if dist < best_dist:
352
+ best_dist = dist
353
+ best_level = level
354
+ return best_level
355
+
356
+ def _shift_level(self, level: str, delta: int) -> str:
357
+ """Shift a level by delta sub-levels, clamped to valid range."""
358
+ idx = LEVELS.index(level) if level in LEVELS else 5
359
+ new_idx = max(0, min(len(LEVELS) - 1, idx + delta))
360
+ return LEVELS[new_idx]
361
+
362
+ def _update_rolling(self, lst: list[float], value: float, window: int = 5):
363
+ lst.append(value)
364
+ if len(lst) > window:
365
+ lst.pop(0)
366
+
367
+ def process_interaction(
368
+ self,
369
+ signals: InteractionSignals,
370
+ question_elo: float,
371
+ topic: str,
372
+ ) -> dict:
373
+ """
374
+ Process a single student-question interaction.
375
+
376
+ Returns a dict with:
377
+ - features: EngineeredFeatures
378
+ - weighted_outcome: float
379
+ - new_student_elo: float
380
+ - new_p_know: float
381
+ - decision: str
382
+ - next_level: str
383
+ - enhanced_scaffold: bool
384
+ """
385
+ s = self.state
386
+
387
+ # 1. Compute engineered features
388
+ features = self.feature_eng.compute(signals)
389
+ weighted_outcome = self.feature_eng.compute_weighted_outcome(
390
+ signals.is_correct, signals.max_hint_level
391
+ )
392
+
393
+ # 2. Update Elo
394
+ s.total_interactions += 1
395
+ new_elo, new_q_elo = self.elo_engine.update(
396
+ s.student_elo, question_elo, weighted_outcome, s.total_interactions
397
+ )
398
+ s.student_elo = new_elo
399
+
400
+ # 3. Update BKT
401
+ hint_depth = signals.max_hint_level / 4.0
402
+ new_p_know = self.bkt_engine.update(topic, weighted_outcome, hint_depth)
403
+
404
+ # 4. Update Thompson priors
405
+ self.thompson.update(signals.question_level, weighted_outcome)
406
+
407
+ # 5. Update streaks
408
+ if weighted_outcome >= 0.75:
409
+ s.streak_correct += 1
410
+ s.streak_wrong = 0
411
+ elif weighted_outcome < 0.40:
412
+ s.streak_wrong += 1
413
+ s.streak_correct = 0
414
+ else:
415
+ s.streak_correct = 0
416
+ s.streak_wrong = 0
417
+
418
+ # 6. Update rolling averages
419
+ self._update_rolling(s.recent_lds, features.lds)
420
+ self._update_rolling(s.recent_mcs, features.mcs)
421
+
422
+ # 7. Progression decision
423
+ if weighted_outcome >= 0.85 and s.streak_correct >= 3:
424
+ decision = "SKIP"
425
+ level_delta = 2
426
+ elif weighted_outcome >= 0.75 and new_p_know >= 0.70:
427
+ decision = "INCREASE"
428
+ level_delta = 1
429
+ elif weighted_outcome >= 0.40:
430
+ decision = "MAINTAIN"
431
+ level_delta = 0
432
+ elif weighted_outcome >= 0.25 or s.streak_wrong < 2:
433
+ decision = "DECREASE"
434
+ level_delta = -1
435
+ else:
436
+ decision = "RAPID_DECREASE"
437
+ level_delta = -2
438
+
439
+ # 8. LDS/MCS diagnostic overlay
440
+ avg_lds = sum(s.recent_lds) / max(len(s.recent_lds), 1)
441
+ avg_mcs = sum(s.recent_mcs) / max(len(s.recent_mcs), 1)
442
+ s.enhanced_scaffold = False
443
+
444
+ if avg_lds > 0.6 and avg_mcs > 0.6:
445
+ # Language gap: knows math, needs scaffold β€” don't decrease
446
+ if level_delta < 0:
447
+ decision = "MAINTAIN"
448
+ level_delta = 0
449
+ s.enhanced_scaffold = True
450
+
451
+ # 9. Apply level change
452
+ decision_level = self._shift_level(s.current_level, level_delta)
453
+
454
+ # 10. Thompson sampling for fine-grained selection
455
+ thompson_level = self.thompson.select(decision_level, s.student_elo)
456
+
457
+ # 11. Override if Thompson and decision disagree strongly
458
+ dec_idx = LEVELS.index(decision_level) if decision_level in LEVELS else 5
459
+ th_idx = LEVELS.index(thompson_level) if thompson_level in LEVELS else 5
460
+
461
+ if level_delta < 0 and th_idx > dec_idx + 1:
462
+ # Decision says decrease but Thompson wants to increase significantly
463
+ next_level = decision_level
464
+ else:
465
+ next_level = thompson_level
466
+
467
+ s.current_level = next_level
468
+
469
+ return {
470
+ "features": features,
471
+ "weighted_outcome": weighted_outcome,
472
+ "new_student_elo": s.student_elo,
473
+ "new_p_know": new_p_know,
474
+ "decision": decision,
475
+ "decision_level": decision_level,
476
+ "next_level": next_level,
477
+ "enhanced_scaffold": s.enhanced_scaffold,
478
+ "avg_lds": round(avg_lds, 4),
479
+ "avg_mcs": round(avg_mcs, 4),
480
+ "quadrant": features.quadrant,
481
+ }
482
+
483
+
484
+ # ────────────────────────────────────────────────────────
485
+ # Simulation
486
+ # ────────────────────────────────────────────────────────
487
+
488
+ def simulate_student_profile(
489
+ profile_name: str,
490
+ true_level_idx: int,
491
+ base_p_correct: float,
492
+ hint_tendency: float,
493
+ n_interactions: int = 20,
494
+ seed: int = 42,
495
+ ) -> dict:
496
+ """
497
+ Simulate a student profile through n_interactions.
498
+
499
+ Args:
500
+ profile_name: Label for this profile
501
+ true_level_idx: Index into LEVELS of the student's true ability
502
+ base_p_correct: Base probability of getting correct answer
503
+ hint_tendency: Probability of requesting hints (0=never, 1=always)
504
+ n_interactions: Number of practice interactions
505
+ seed: Random seed
506
+ """
507
+ random.seed(seed)
508
+ engine = AdaptiveEngine(seed=seed)
509
+ true_elo = LEVEL_TO_ELO[LEVELS[true_level_idx]]
510
+
511
+ results = []
512
+
513
+ for i in range(n_interactions):
514
+ current_level = engine.state.current_level
515
+ question_elo = LEVEL_TO_ELO.get(current_level, 1000)
516
+
517
+ # Simulate difficulty effect on correctness
518
+ elo_diff = true_elo - question_elo
519
+ difficulty_modifier = 1.0 / (1.0 + math.exp(-elo_diff / 200.0))
520
+ p_correct = base_p_correct * difficulty_modifier + 0.1 * (1 - difficulty_modifier)
521
+
522
+ # Simulate hint usage
523
+ if random.random() < hint_tendency:
524
+ max_hint = random.choices(
525
+ [1, 2, 3, 4],
526
+ weights=[0.3, 0.3, 0.25, 0.15],
527
+ )[0]
528
+ else:
529
+ max_hint = 0
530
+
531
+ is_correct = random.random() < p_correct
532
+ if max_hint == 4:
533
+ is_correct = False # L4 = solution reveal
534
+
535
+ # Generate plausible timing
536
+ base_time = 30 + true_level_idx * 5
537
+ total_time = max(10, base_time + random.gauss(0, 10))
538
+
539
+ scaffold_total = 0
540
+ t_l1, t_l2, t_l3, t_l4 = 0.0, 0.0, 0.0, 0.0
541
+ if max_hint >= 1:
542
+ t_l1 = random.uniform(3, 10)
543
+ scaffold_total += t_l1
544
+ if max_hint >= 2:
545
+ t_l2 = random.uniform(5, 15)
546
+ scaffold_total += t_l2
547
+ if max_hint >= 3:
548
+ t_l3 = random.uniform(8, 20)
549
+ scaffold_total += t_l3
550
+ if max_hint >= 4:
551
+ t_l4 = random.uniform(10, 25)
552
+ scaffold_total += t_l4
553
+
554
+ total_time = max(total_time, scaffold_total + 5)
555
+
556
+ topic = random.choice(TOPICS)
557
+
558
+ signals = InteractionSignals(
559
+ max_hint_level=max_hint,
560
+ time_before_first_hint=random.uniform(2, 15) if max_hint > 0 else 0,
561
+ total_time=total_time,
562
+ time_at_L1=t_l1,
563
+ time_at_L2=t_l2,
564
+ time_at_L3=t_l3,
565
+ time_at_L4=t_l4,
566
+ num_attempts=1 if is_correct and max_hint == 0 else random.randint(1, 3),
567
+ is_correct=is_correct,
568
+ question_level=current_level,
569
+ )
570
+
571
+ result = engine.process_interaction(signals, question_elo, topic)
572
+ results.append(result)
573
+
574
+ # Summary
575
+ final_elo = engine.state.student_elo
576
+ final_level = engine.state.current_level
577
+ avg_wo = sum(r["weighted_outcome"] for r in results) / len(results)
578
+ avg_lds = sum(r["features"].lds for r in results) / len(results)
579
+ avg_mcs = sum(r["features"].mcs for r in results) / len(results)
580
+
581
+ decisions = {}
582
+ for r in results:
583
+ d = r["decision"]
584
+ decisions[d] = decisions.get(d, 0) + 1
585
+
586
+ return {
587
+ "profile": profile_name,
588
+ "true_level": LEVELS[true_level_idx],
589
+ "start_elo": INITIAL_STUDENT_ELO,
590
+ "final_elo": round(final_elo, 1),
591
+ "final_level": final_level,
592
+ "avg_weighted_outcome": round(avg_wo, 3),
593
+ "avg_lds": round(avg_lds, 3),
594
+ "avg_mcs": round(avg_mcs, 3),
595
+ "decisions": decisions,
596
+ }
597
+
598
+
599
+ def _run_simulation():
600
+ print("=" * 70)
601
+ print("MathLingua Adaptive Engine β€” Simulation Results")
602
+ print("=" * 70)
603
+
604
+ profiles = [
605
+ ("Strong Student (true ~2.5)", 9, 0.85, 0.15),
606
+ ("Struggling Student (true ~1.2)", 1, 0.45, 0.70),
607
+ ("Average Student (true ~1.5)", 4, 0.65, 0.40),
608
+ ]
609
+
610
+ for name, true_idx, p_correct, hint_tend in profiles:
611
+ result = simulate_student_profile(name, true_idx, p_correct, hint_tend)
612
+ print(f"\n{'─' * 50}")
613
+ print(f"Profile: {result['profile']}")
614
+ print(f" True level: {result['true_level']}")
615
+ print(f" Elo: {result['start_elo']} β†’ {result['final_elo']}")
616
+ print(f" Level: 2.1 β†’ {result['final_level']}")
617
+ print(f" Avg weighted outcome: {result['avg_weighted_outcome']}")
618
+ print(f" Avg LDS: {result['avg_lds']}")
619
+ print(f" Avg MCS: {result['avg_mcs']}")
620
+ print(f" Decisions: {result['decisions']}")
621
+
622
+ print(f"\n{'=' * 70}")
623
+ print("Simulation completed successfully βœ“")
624
+ print(f"{'=' * 70}")
625
+
626
+
627
+ if __name__ == "__main__":
628
+ _run_simulation()