Spaces:
Running
Running
| # tests/test_rl_engine.py | |
| """Unit tests for the RL engine (Thompson Sampling bandit).""" | |
| import json | |
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| from app.services.rl_engine import ( | |
| ACTIONS, | |
| PARAM_CAP, | |
| PARAM_FLOOR, | |
| ACTION_FATIGUE_CEILING, | |
| _clamp_params, | |
| _get_default_params, | |
| _load_bandit_params, | |
| _compute_context_features, | |
| _context_adjusted_params, | |
| _thompson_sample, | |
| _apply_safety_guard, | |
| _detect_plateau, | |
| get_behavior_signal, | |
| update_reward, | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helpers | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_user( | |
| user_id="testrl", | |
| mood=0.5, fatigue=0.0, sleep_quality=0.8, | |
| stress=0.1, shock=0.0, sick=0.0, competence=0.7, | |
| rl_bandit_params=None, | |
| rl_history_count=0, | |
| rl_state=None, | |
| current_streak=0, | |
| last_login_at=None, | |
| ): | |
| user = MagicMock() | |
| user.user_id = user_id | |
| user.mood = mood | |
| user.fatigue = fatigue | |
| user.sleep_quality = sleep_quality | |
| user.stress = stress | |
| user.shock = shock | |
| user.sick = sick | |
| user.competence = competence | |
| user.rl_bandit_params = rl_bandit_params | |
| user.rl_history_count = rl_history_count | |
| user.rl_state = rl_state or [] | |
| user.current_streak = current_streak | |
| user.last_login_at = last_login_at | |
| user.days_since_last_login = 0 | |
| return user | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Param loading | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_default_params_structure(): | |
| params = _get_default_params() | |
| for action in ACTIONS: | |
| assert action in params | |
| assert "alpha" in params[action] | |
| assert "beta" in params[action] | |
| def test_load_bandit_params_fresh_user(): | |
| user = make_user() | |
| params = _load_bandit_params(user) | |
| assert all(action in params for action in ACTIONS) | |
| def test_load_bandit_params_valid_stored(): | |
| stored = {a: {"alpha": 5.0, "beta": 3.0} for a in ACTIONS} | |
| user = make_user(rl_bandit_params=stored) | |
| params = _load_bandit_params(user) | |
| assert params["Recovery"]["alpha"] == 5.0 | |
| def test_load_bandit_params_corrupted_falls_back(): | |
| user = make_user(rl_bandit_params="not_valid_json!!") | |
| params = _load_bandit_params(user) | |
| # Should not raise; should return defaults | |
| assert all(action in params for action in ACTIONS) | |
| def test_clamp_params_enforces_bounds(): | |
| bloated = {a: {"alpha": 999.0, "beta": 0.0001} for a in ACTIONS} | |
| clamped = _clamp_params(bloated) | |
| for action in ACTIONS: | |
| assert clamped[action]["alpha"] <= PARAM_CAP | |
| assert clamped[action]["beta"] >= PARAM_FLOOR | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Context features | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_context_features_have_expected_keys(): | |
| user = make_user() | |
| state = [0.7, 0.5, 0.0, 0.8, 0.0, 0.0, 0.1] | |
| ctx = _compute_context_features(state, user, {}) | |
| for key in ("energy", "disruption", "readiness", "time_factor", "weekend_factor", | |
| "streak_factor", "inactivity_factor", "competence", "mood", "fatigue"): | |
| assert key in ctx | |
| def test_high_fatigue_context_boosts_disruption(): | |
| user = make_user(fatigue=0.95, mood=0.3, sleep_quality=0.4) | |
| state = [0.7, 0.3, 0.95, 0.4, 0.0, 0.0, 0.1] | |
| ctx = _compute_context_features(state, user, {}) | |
| assert ctx["energy"] < 0.5 # energy should be low with high fatigue, low mood, low sleep | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Contextual adjustment | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_high_fatigue_boosts_recovery_arm(): | |
| user = make_user(fatigue=0.9) | |
| base = _get_default_params() | |
| state = [0.7, 0.3, 0.9, 0.4, 0.0, 0.0, 0.8] | |
| ctx = _compute_context_features(state, user, {}) | |
| _neutral_hist = {"consecutive_hard_days": 0, "recent_completion_rate": 0.7, | |
| "avg_satisfaction_7d": 0.6, "fatigue_trend": 0.0, | |
| "overwork_signal": 0.0, "deep_work_streak": 0.0, "avg_daily_minutes": 45.0} | |
| adjusted = _context_adjusted_params(base, ctx, _neutral_hist, is_cold_start=False, is_plateau=False) | |
| # Recovery should have a higher alpha than default | |
| assert adjusted["Recovery"]["alpha"] > base["Recovery"]["alpha"] | |
| def test_plateau_boosts_exploration_arm(): | |
| user = make_user() | |
| base = _get_default_params() | |
| state = [0.7, 0.5, 0.1, 0.8, 0.0, 0.0, 0.1] | |
| ctx = _compute_context_features(state, user, {}) | |
| _neutral_hist = {"consecutive_hard_days": 0, "recent_completion_rate": 0.7, | |
| "avg_satisfaction_7d": 0.6, "fatigue_trend": 0.0, | |
| "overwork_signal": 0.0, "deep_work_streak": 0.0, "avg_daily_minutes": 45.0} | |
| adjusted_plateau = _context_adjusted_params(base, ctx, _neutral_hist, is_cold_start=False, is_plateau=True) | |
| adjusted_no_plateau = _context_adjusted_params(base, ctx, _neutral_hist, is_cold_start=False, is_plateau=False) | |
| assert adjusted_plateau["Exploration"]["alpha"] > adjusted_no_plateau["Exploration"]["alpha"] | |
| def test_cold_start_flattens_distribution(): | |
| user = make_user() | |
| base = _get_default_params() | |
| state = [0.7, 0.5, 0.0, 0.8, 0.0, 0.0, 0.1] | |
| ctx = _compute_context_features(state, user, {}) | |
| _neutral_hist = {"consecutive_hard_days": 0, "recent_completion_rate": 0.7, | |
| "avg_satisfaction_7d": 0.6, "fatigue_trend": 0.0, | |
| "overwork_signal": 0.0, "deep_work_streak": 0.0, "avg_daily_minutes": 45.0} | |
| adjusted_cold = _context_adjusted_params(base, ctx, _neutral_hist, is_cold_start=True, is_plateau=False) | |
| adjusted_warm = _context_adjusted_params(base, ctx, _neutral_hist, is_cold_start=False, is_plateau=False) | |
| # Cold start should add beta to all arms (flatten the distribution) | |
| for action in ACTIONS: | |
| assert adjusted_cold[action]["beta"] >= adjusted_warm[action]["beta"] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Thompson Sampling | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_thompson_sample_returns_valid_action(): | |
| params = {a: {"alpha": 2.0, "beta": 2.0} for a in ACTIONS} | |
| chosen, samples, propensity = _thompson_sample(params) | |
| assert chosen in ACTIONS | |
| assert set(samples.keys()) == set(ACTIONS) | |
| assert set(propensity.keys()) == set(ACTIONS) | |
| def test_thompson_sample_biased_toward_high_alpha(): | |
| """With very high alpha for Deep Work, should be chosen almost always.""" | |
| params = {a: {"alpha": 1.0, "beta": 100.0} for a in ACTIONS} | |
| params["Deep Work"] = {"alpha": 100.0, "beta": 1.0} | |
| wins = sum(1 for _ in range(50) if _thompson_sample(params)[0] == "Deep Work") | |
| assert wins > 40, f"Expected Deep Work to win most rounds, got {wins}/50" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Safety guard | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_safety_guard_allows_recovery_at_any_fatigue(): | |
| assert _apply_safety_guard("Recovery", 0.99) == "Recovery" | |
| def test_safety_guard_downgrades_deep_work_at_high_fatigue(): | |
| result = _apply_safety_guard("Deep Work", 0.9) | |
| assert result in ["Light Review", "Recovery"] | |
| assert result != "Deep Work" | |
| def test_safety_guard_no_change_at_safe_fatigue(): | |
| result = _apply_safety_guard("Deep Work", 0.3) # Under 0.75 ceiling | |
| assert result == "Deep Work" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Plateau detection | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_plateau_detected_when_variance_low(): | |
| user = make_user(rl_state=[0.7, 0.71, 0.69, 0.70, 0.71, 0.70, 0.69]) | |
| assert _detect_plateau(user) is True | |
| def test_no_plateau_when_variance_high(): | |
| user = make_user(rl_state=[0.2, 0.9, 0.1, 0.8, 0.3, 0.95, 0.15]) | |
| assert _detect_plateau(user) is False | |
| def test_no_plateau_with_insufficient_history(): | |
| user = make_user(rl_state=[0.5, 0.5, 0.5]) # Only 3 entries, need 7 | |
| assert _detect_plateau(user) is False | |
| def test_no_plateau_empty_history(): | |
| user = make_user(rl_state=[]) | |
| assert _detect_plateau(user) is False | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # get_behavior_signal (integration) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_behavior_signal_is_crash_proof(): | |
| """Should never raise regardless of user state.""" | |
| user = make_user() | |
| user.rl_bandit_params = "corrupted{{{{json" | |
| signal = get_behavior_signal(user) | |
| assert "action_label" in signal | |
| assert signal["action_label"] in ACTIONS | |
| def test_behavior_signal_structure(): | |
| user = make_user() | |
| signal = get_behavior_signal(user) | |
| for key in ("action_label", "intensity", "mood", "fatigue", | |
| "rl_samples", "rl_updates", "cold_start", "plateau_detected"): | |
| assert key in signal | |
| def test_behavior_signal_intensity_in_range(): | |
| user = make_user() | |
| for _ in range(20): | |
| signal = get_behavior_signal(user) | |
| assert 0.0 <= signal["intensity"] <= 1.0 | |
| def test_cold_start_flag_on_new_user(): | |
| user = make_user(rl_history_count=0) | |
| signal = get_behavior_signal(user) | |
| assert signal["cold_start"] is True | |
| def test_no_cold_start_after_ten_updates(): | |
| user = make_user(rl_history_count=15) | |
| signal = get_behavior_signal(user) | |
| assert signal["cold_start"] is False | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # update_reward | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_reward_update_increments_history_count(): | |
| db = MagicMock() | |
| user = make_user(rl_history_count=5) | |
| user.rl_state = [] | |
| update_reward(db, user, "Normal", 1.0) | |
| assert user.rl_history_count == 6 | |
| def test_reward_update_unknown_action_skipped(): | |
| db = MagicMock() | |
| user = make_user() | |
| user.rl_state = [] | |
| # Should not raise | |
| update_reward(db, user, "MadeUpAction", 0.5) | |
| assert user.rl_history_count == 0 # Not incremented | |
| def test_reward_updates_alpha_on_success(): | |
| db = MagicMock() | |
| user = make_user() | |
| user.rl_state = [] | |
| update_reward(db, user, "Normal", 1.0) | |
| params = json.loads(user.rl_bandit_params) | |
| # Alpha for Normal should have increased | |
| assert params["Normal"]["alpha"] > _get_default_params()["Normal"]["alpha"] * 0.996 | |
| def test_reward_updates_beta_on_failure(): | |
| db = MagicMock() | |
| user = make_user() | |
| user.rl_state = [] | |
| update_reward(db, user, "Normal", 0.0) | |
| params = json.loads(user.rl_bandit_params) | |
| assert params["Normal"]["beta"] > _get_default_params()["Normal"]["beta"] * 0.996 | |
| def test_reward_clamped_to_valid_range(): | |
| db = MagicMock() | |
| user = make_user() | |
| user.rl_state = [] | |
| # Out-of-range reward should be clamped, not crash | |
| update_reward(db, user, "Normal", 5.0) | |
| params = json.loads(user.rl_bandit_params) | |
| assert params["Normal"]["alpha"] <= PARAM_CAP | |
| def test_reward_history_appended_to_rl_state(): | |
| db = MagicMock() | |
| user = make_user() | |
| user.rl_state = [0.5, 0.6] | |
| update_reward(db, user, "Normal", 0.8) | |
| assert len(user.rl_state) == 3 | |
| assert user.rl_state[-1] == 0.8 | |