hale-api / app /services /rl_engine.py
Raunak211006's picture
Deploy HALE API v3.1
cc38116 verified
Raw
History Blame Contribute Delete
50.3 kB
# app/services/rl_engine.py
"""
Contextual Multi-Armed Bandit RL engine — v4.0
v4.0 changes:
- Fixed get_next_day_signal() _thompson_sample unpack crash (was 2-tuple, needs 3)
- Fixed consecutive_same_action counter (elif/pass logic bug)
- Moved _compute_circadian_bonus() outside action loop (5x DB query reduction)
- Added propensity_array to get_stable_signal() response (KeyError fix)
- Fixed state_updated_at timezone comparison (midnight re-computation bug)
- Corrected Normal mode debt cost: 1.0 -> 0.3 (sustainable mode)
- Improved overwork signal: normalized weighted formula [0,1]
- Improved burnout_warning: added single high-fatigue override
- Removed dual-decay conflict between _load_bandit_params and _apply_memory_decay
- Reordered _apply_safety_guard for deload -> fatigue ceiling -> circuit breaker clarity
- Updated version docstring
"""
import math
import json
import random
import logging
from datetime import datetime, timezone, date, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.user import User
logger = logging.getLogger("hale.rl")
ACTIONS = ["Recovery", "Light Review", "Normal", "Deep Work", "Exploration"]
ACTION_INTENSITIES: Dict[str, Tuple[float, float]] = {
"Recovery": (0.10, 0.30),
"Light Review": (0.25, 0.50),
"Normal": (0.45, 0.70),
"Deep Work": (0.65, 0.95),
"Exploration": (0.40, 0.65),
}
ACTION_FATIGUE_CEILING: Dict[str, float] = {
"Deep Work": 0.75,
"Exploration": 0.80,
"Normal": 0.85,
}
PARAM_FLOOR = 1.0
PARAM_CAP = 50.0
DEFAULT_BANDIT_PARAMS: Dict[str, Dict[str, float]] = {
action: {"alpha": 2.0, "beta": 2.0} for action in ACTIONS
}
DEFAULT_BANDIT_PARAMS["Normal"]["alpha"] = 3.0
PLATEAU_WINDOW = 7
PLATEAU_VAR_THRESHOLD = 0.04
COMPETENCE_DECAY_PER_DAY = 0.95
HISTORY_WINDOW_DAYS = 14
def _get_default_params() -> Dict:
return json.loads(json.dumps(DEFAULT_BANDIT_PARAMS))
def _clamp_params(params: Dict) -> Dict:
for action in ACTIONS:
if action in params:
params[action]["alpha"] = max(PARAM_FLOOR, min(PARAM_CAP, params[action]["alpha"]))
params[action]["beta"] = max(PARAM_FLOOR, min(PARAM_CAP, params[action]["beta"]))
return params
def _load_bandit_params(user: User) -> Dict:
"""Load stored bandit params. Does NOT apply time decay (handled by _apply_memory_decay weekly)."""
params = _get_default_params()
if getattr(user, "rl_bandit_params", None):
try:
stored = (
user.rl_bandit_params
if isinstance(user.rl_bandit_params, dict)
else json.loads(user.rl_bandit_params)
)
for action in ACTIONS:
if action in stored and "alpha" in stored[action] and "beta" in stored[action]:
params[action] = stored[action]
# Carry over debt and metadata keys
if "_recovery_debt" in stored:
params["_recovery_debt"] = stored["_recovery_debt"]
if "_last_updated" in stored:
params["_last_updated"] = stored["_last_updated"]
except (json.JSONDecodeError, TypeError, KeyError):
logger.warning("Corrupted bandit params for %s — using defaults", user.user_id)
# Natural debt recovery: debt decays passively each day without any session
last_updated = params.get("_last_updated")
if last_updated:
try:
last_dt = datetime.fromisoformat(last_updated)
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
elapsed_days = max(0.0, (datetime.now(timezone.utc) - last_dt).total_seconds() / 86400.0)
debt = params.get("_recovery_debt", 0.0)
params["_recovery_debt"] = max(0.0, debt - (elapsed_days * 2.0)) # Slower natural recovery
except Exception:
pass
return _clamp_params(params)
def _compute_historical_features(user: User, db: Optional[Session] = None) -> Dict[str, float]:
"""Query Progress table for last 14 days and extract real behavioral patterns."""
defaults = {
"consecutive_hard_days": 0.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,
"consecutive_same_action": 0.0,
"ema_fatigue": 0.0,
"ema_mood": 0.5,
"last_action": None,
"max_goal_completion": 0.0,
"weekend_rate": 0.5,
"weekday_rate": 0.5,
}
if db is None:
return defaults
try:
from app.models.progress import Progress
cutoff = (date.today() - timedelta(days=HISTORY_WINDOW_DAYS)).isoformat()
rows = (
db.query(Progress)
.filter(Progress.user_id == user.user_id, Progress.date >= cutoff)
.order_by(Progress.date.asc())
.all()
)
if not rows:
return defaults
by_date: Dict[str, list] = {}
for r in rows:
by_date.setdefault(r.date, []).append(r)
sorted_dates = sorted(by_date.keys())
today_str = date.today().isoformat()
# Feature 1: Exponential Recency Weighting
recent_cut = (date.today() - timedelta(days=7)).isoformat()
recent_dates = [d for d in sorted_dates if d >= recent_cut]
lambda_decay = 0.15
total_weight_comp, done_weight = 0.0, 0.0
total_weight_sat, sat_weight_sum = 0.0, 0.0
for d in recent_dates:
days_ago = max(0, (date.today() - date.fromisoformat(d)).days)
weight = math.exp(-lambda_decay * days_ago)
for r in by_date[d]:
total_weight_comp += weight
if r.completed:
done_weight += weight
if r.satisfaction is not None and r.completed:
total_weight_sat += weight
sat_weight_sum += r.satisfaction * weight
completion_rate = (done_weight / total_weight_comp) if total_weight_comp > 0 else 0.7
avg_sat = (sat_weight_sum / total_weight_sat) if total_weight_sat > 0 else 0.6
# Feature 3: Goal Completion Proximity Effect
max_completion = 0.0
try:
from app.models.goal import Goal
active_goals = db.query(Goal).filter(Goal.user_id == user.user_id, Goal.is_active == True).all()
if active_goals:
max_completion = max((g.completion_pct or 0.0) for g in active_goals)
except Exception as e:
logger.debug("Goal proximity fetch failed: %s", e)
# Feature 4: Weekend vs Weekday Profile (over full history window)
weekend_done, weekend_total = 0, 0
weekday_done, weekday_total = 0, 0
for d in sorted_dates:
is_weekend = date.fromisoformat(d).weekday() >= 5
for r in by_date[d]:
if is_weekend:
weekend_total += 1
if r.completed: weekend_done += 1
else:
weekday_total += 1
if r.completed: weekday_done += 1
weekend_rate = (weekend_done / weekend_total) if weekend_total > 0 else 0.5
weekday_rate = (weekday_done / weekday_total) if weekday_total > 0 else 0.5
# Consecutive hard days (actual study time >= 60 min)
consecutive_hard = 0
for d in reversed(sorted_dates):
if d == today_str:
continue
total_min = sum(r.actual_duration_min or 0 for r in by_date[d] if r.completed)
if total_min >= 60:
consecutive_hard += 1
else:
break
# Average daily minutes
comp_rows = [r for r in rows if r.completed and r.actual_duration_min]
avg_daily_min = sum(r.actual_duration_min for r in comp_rows) / len(comp_rows) if comp_rows else 45.0
# Fatigue trend (slope over last 7 days)
fatigue_pts = [r.fatigue for d in sorted_dates[-7:] for r in by_date[d] if r.fatigue is not None]
fatigue_trend = 0.0
if len(fatigue_pts) >= 3:
n = len(fatigue_pts)
xs = list(range(n))
mx, my = sum(xs) / n, sum(fatigue_pts) / n
num = sum((xs[i] - mx) * (fatigue_pts[i] - my) for i in range(n))
den = sum((xs[i] - mx) ** 2 for i in range(n))
fatigue_trend = num / den if den != 0 else 0.0
# Deep Work streak & consecutive_same_action counter — FIXED
# Bug: elif/pass meant consecutive_same_action was never incremented beyond 1
deep_streak = 0
consecutive_same_action = 0
last_action = None
current_run_action = None
current_run_count = 0
for d in reversed(sorted_dates):
if d == today_str:
continue
labels = [r.action_label for r in by_date[d] if r.action_label]
if not labels:
break
daily_action = labels[0]
# Track deep work streak
if daily_action == "Deep Work":
deep_streak += 1
else:
# Deep work streak only counts consecutive days
if deep_streak > 0:
pass # already broken, keep counting done
# Track consecutive same action
if current_run_action is None:
current_run_action = daily_action
current_run_count = 1
last_action = daily_action
elif daily_action == current_run_action:
current_run_count += 1
else:
break # Streak ended
consecutive_same_action = current_run_count
# Recovery debt acceleration heuristic (last 3 days)
last_3_days = [d for d in sorted_dates if d >= (date.today() - timedelta(days=3)).isoformat()]
deep_mins_3d = 0
recov_mins_3d = 0
for d in last_3_days:
for r in by_date[d]:
if r.completed:
if r.action_label == "Deep Work":
deep_mins_3d += (r.actual_duration_min or 0)
elif r.action_label in ("Recovery", "Light Review", "Rest"):
recov_mins_3d += (r.actual_duration_min or 0)
recovery_debt_accel = deep_mins_3d - recov_mins_3d
# Temporal State Memory (EMAs)
ema_fatigue = 0.0
ema_mood = 0.5
alpha_ema = 0.3
for d in sorted_dates:
daily_f = [r.fatigue for r in by_date[d] if r.fatigue is not None]
daily_m = [r.mood for r in by_date[d] if r.mood is not None]
if daily_f: ema_fatigue = alpha_ema * (sum(daily_f)/len(daily_f)) + (1-alpha_ema) * ema_fatigue
if daily_m: ema_mood = alpha_ema * (sum(daily_m)/len(daily_m)) + (1-alpha_ema) * ema_mood
# Tuned Burnout Warning — less false positives, added single-signal override
burnout_warning = (
(fatigue_trend > 0.1 and recovery_debt_accel > 0 and ema_fatigue > 0.7)
or ema_fatigue > 0.88 # Single-signal override: extreme fatigue alone is enough
)
# Composite overwork signal — properly normalized to [0, 1]
# Weighted: hard_days (max ~5) contributes 35%, deep_streak (max ~5) 35%, fatigue_trend 30%
hard_component = min(1.0, consecutive_hard / 5.0) * 0.35
streak_component = min(1.0, deep_streak / 5.0) * 0.35
trend_component = min(1.0, max(0.0, fatigue_trend) * 5.0) * 0.30
overwork = round(hard_component + streak_component + trend_component, 3)
return {
"consecutive_hard_days": float(consecutive_hard),
"recent_completion_rate": float(completion_rate),
"avg_satisfaction_7d": float(avg_sat),
"fatigue_trend": float(fatigue_trend),
"overwork_signal": float(overwork),
"deep_work_streak": float(deep_streak),
"avg_daily_minutes": float(avg_daily_min),
"consecutive_same_action": float(consecutive_same_action),
"ema_fatigue": float(ema_fatigue),
"ema_mood": float(ema_mood),
"last_action": last_action,
"burnout_warning": burnout_warning,
"max_goal_completion": float(max_completion),
"weekend_rate": float(weekend_rate),
"weekday_rate": float(weekday_rate),
}
except Exception as exc:
logger.warning("Historical features failed for %s: %s", user.user_id, exc)
return defaults
def get_user_state_vector(user: User) -> List[float]:
return [
user.competence or 0.7,
user.mood or 0.5,
user.fatigue or 0.0,
user.sleep_quality or 0.8,
user.shock or 0.0,
user.sick or 0.0,
user.stress or 0.1,
]
def _compute_context_features(state: List[float], user: User, hist: Dict[str, float]) -> Dict[str, float]:
competence, mood, fatigue, sleep, shock, sick, stress = state[:7]
energy = mood * 0.3 + sleep * 0.3 + (1 - fatigue) * 0.25 + (1 - stress) * 0.15
disruption = max(shock, sick, stress * 0.8)
readiness = energy * (1 - disruption * 0.6) * (0.5 + competence * 0.5)
hour = datetime.now().hour
if 9 <= hour <= 11 or 14 <= hour <= 17: time_factor = 1.0
elif 7 <= hour <= 9 or 11 <= hour <= 14: time_factor = 0.85
else: time_factor = 0.6
day_of_week = datetime.now().weekday()
# Feature 4: Weekend Warrior dynamic factor
if day_of_week >= 5:
weekend_rate = hist.get("weekend_rate", 0.5)
weekday_rate = hist.get("weekday_rate", 0.5)
if weekend_rate > weekday_rate + 0.05: # Noticeably better on weekends
weekend_factor = 1.1
else:
weekend_factor = 0.85
else:
weekend_factor = 1.0
streak = user.current_streak or 0
streak_factor = min(1.0, 0.5 + streak * 0.05)
idle_days = user.days_since_last_login
inactivity_factor = max(0.3, 1.0 - idle_days * 0.1)
return {
"energy": energy, "disruption": disruption, "readiness": readiness,
"time_factor": time_factor, "weekend_factor": weekend_factor,
"streak_factor": streak_factor, "inactivity_factor": inactivity_factor,
"competence": competence, "mood": mood, "fatigue": fatigue,
"idle_days": float(idle_days),
}
def _detect_plateau(user: User) -> bool:
history = getattr(user, "rl_state", None) or []
if not isinstance(history, list):
return False
recent = [float(v) for v in history[-PLATEAU_WINDOW:] if isinstance(v, (int, float))]
if len(recent) < PLATEAU_WINDOW:
return False
mean = sum(recent) / len(recent)
variance = sum((r - mean) ** 2 for r in recent) / len(recent)
return variance < PLATEAU_VAR_THRESHOLD
# ─────────────────────────────────────────────────────────────────
# Feature 1: Circadian Rhythm Sensing
# Learns whether the user is a Morning Lark, Night Owl, etc.
# and nudges Deep Work toward their proven peak performance window.
# ─────────────────────────────────────────────────────────────────
CIRCADIAN_WINDOWS = {
"morning": (5, 12), # 05:00–11:59
"afternoon": (12, 17), # 12:00–16:59
"evening": (17, 21), # 17:00–20:59
"night": (21, 5), # 21:00–04:59 (wraps)
}
def _compute_circadian_bonus(user: User, db: Optional[Session], current_action: str) -> float:
"""Return a nudge bonus (+) or penalty (-) based on user's proven peak performance hour.
This is called ONCE per signal computation (outside the action loop) and the
result is applied only to the Deep Work arm, avoiding redundant DB queries.
"""
if db is None or current_action != "Deep Work":
return 0.0
try:
from app.models.progress import Progress
cutoff = (date.today() - timedelta(days=28)).isoformat() # 4 weeks
rows = (
db.query(Progress)
.filter(
Progress.user_id == user.user_id,
Progress.completed == True,
Progress.completed_at.isnot(None),
Progress.date >= cutoff,
Progress.satisfaction.isnot(None),
)
.all()
)
if len(rows) < 5: # Need at least 5 data points
return 0.0
# Aggregate satisfaction by time window
window_scores: Dict[str, list] = {k: [] for k in CIRCADIAN_WINDOWS}
for r in rows:
completed_at = r.completed_at
if completed_at.tzinfo is None:
completed_at = completed_at.replace(tzinfo=timezone.utc)
hour = completed_at.hour
for wname, (wstart, wend) in CIRCADIAN_WINDOWS.items():
if wstart < wend:
if wstart <= hour < wend:
window_scores[wname].append(r.satisfaction)
else: # wraps midnight
if hour >= wstart or hour < wend:
window_scores[wname].append(r.satisfaction)
avgs = {
k: sum(v) / len(v)
for k, v in window_scores.items()
if len(v) >= 2
}
if len(avgs) < 2:
return 0.0
best_window = max(avgs, key=avgs.__getitem__)
worst_window = min(avgs, key=avgs.__getitem__)
current_hour = datetime.now().hour
current_window = "afternoon"
for wname, (wstart, wend) in CIRCADIAN_WINDOWS.items():
if wstart < wend:
if wstart <= current_hour < wend:
current_window = wname
else:
if current_hour >= wstart or current_hour < wend:
current_window = wname
if current_window == best_window:
bonus = 2.0 * (avgs[best_window] - avgs.get(worst_window, 0.5))
logger.info("Circadian boost +%.2f for Deep Work (peak window: %s)", bonus, best_window)
return min(3.0, bonus)
elif current_window == worst_window:
logger.info("Circadian penalty -1.5 for Deep Work (off-peak: %s)", worst_window)
return -1.5
return 0.0
except Exception as exc:
logger.debug("Circadian computation failed: %s", exc)
return 0.0
# ─────────────────────────────────────────────────────────────────
# Feature 2: Dynamic Memory Decay (Exponential Forgetting)
# Applied weekly — allows RL to forget old habits and adapt to
# lifestyle changes quickly without manual resets.
# ─────────────────────────────────────────────────────────────────
MEMORY_DECAY_GAMMA = 0.88 # 88% retention per week (12% forgotten)
MEMORY_DECAY_INTERVAL = 7 # Apply once per 7 days
def _apply_memory_decay(user: User, params: Dict, db: Session) -> Dict:
"""Decay old bandit params toward the prior (defaults) once per week."""
try:
today_str = date.today().isoformat()
last_decay = getattr(user, "rl_last_decay_date", None)
if last_decay:
days_since = (date.today() - date.fromisoformat(last_decay)).days
if days_since < MEMORY_DECAY_INTERVAL:
return params # Not yet time
defaults = _get_default_params()
for action in ACTIONS:
if action not in params:
continue
# Blend alpha/beta toward default by (1-gamma) each week
params[action]["alpha"] = (
params[action]["alpha"] * MEMORY_DECAY_GAMMA
+ defaults[action]["alpha"] * (1 - MEMORY_DECAY_GAMMA)
)
params[action]["beta"] = (
params[action]["beta"] * MEMORY_DECAY_GAMMA
+ defaults[action]["beta"] * (1 - MEMORY_DECAY_GAMMA)
)
user.rl_last_decay_date = today_str
db.commit()
logger.info("Memory decay applied for %s (gamma=%.2f)", user.user_id, MEMORY_DECAY_GAMMA)
return _clamp_params(params)
except Exception as exc:
logger.debug("Memory decay skipped: %s", exc)
return params
# ─────────────────────────────────────────────────────────────────
# Feature 3: Goal-Specific Bandit Params
# Each goal has its own alpha/beta profile so the AI learns that
# the user may handle Coding at 80% intensity but Music at 30%.
# ─────────────────────────────────────────────────────────────────
def _load_goal_bandit_params(user: User, goal_id: Optional[int]) -> Dict:
"""Load per-goal bandit params, blended with global params."""
if goal_id is None:
return _get_default_params()
try:
stored = (
user.rl_bandit_params
if isinstance(user.rl_bandit_params, dict)
else json.loads(user.rl_bandit_params or "{}")
)
goal_key = f"goal_{goal_id}"
goal_data = stored.get(goal_key, {})
global_params = _load_bandit_params(user)
# Blend goal-specific with global (60/40)
blended = {}
for action in ACTIONS:
g_alpha = goal_data.get(action, {}).get("alpha", global_params[action]["alpha"])
g_beta = goal_data.get(action, {}).get("beta", global_params[action]["beta"])
blended[action] = {
"alpha": 0.6 * g_alpha + 0.4 * global_params[action]["alpha"],
"beta": 0.6 * g_beta + 0.4 * global_params[action]["beta"],
}
return _clamp_params(blended)
except Exception:
return _get_default_params()
def _save_goal_bandit_params(user: User, goal_id: int, action: str, reward: float) -> None:
"""Update per-goal bandit params after a task completion."""
try:
stored = (
user.rl_bandit_params
if isinstance(user.rl_bandit_params, dict)
else json.loads(user.rl_bandit_params or "{}")
)
goal_key = f"goal_{goal_id}"
goal_data = stored.get(goal_key, {a: {"alpha": 2.0, "beta": 2.0} for a in ACTIONS})
if action in goal_data:
if reward > 0.5:
goal_data[action]["alpha"] = min(PARAM_CAP, goal_data[action]["alpha"] + reward)
else:
goal_data[action]["beta"] = min(PARAM_CAP, goal_data[action]["beta"] + (1 - reward))
stored[goal_key] = goal_data
user.rl_bandit_params = stored
except Exception as exc:
logger.debug("Goal bandit save failed: %s", exc)
# ─────────────────────────────────────────────────────────────────
# Feature 4: 1-Step Lookahead (Transition Dynamics)
# Estimates tomorrow's fatigue based on today's action and pre-
# emptively penalizes actions that will cause tomorrow's burnout.
# ─────────────────────────────────────────────────────────────────
ACTION_FATIGUE_COST: Dict[str, float] = {
"Deep Work": 0.25,
"Exploration": 0.15,
"Normal": 0.10,
"Light Review": 0.05,
"Recovery": -0.15, # Recovery reduces fatigue
}
def _compute_lookahead_penalty(action: str, current_fatigue: float) -> float:
"""If repeating this action for 3 days pushes fatigue over burnout threshold, penalize it."""
cost = ACTION_FATIGUE_COST.get(action, 0.0)
# Feature 5: 3-Day Rolling Lookahead
predicted_3_day_fatigue = min(1.0, current_fatigue + (3 * cost))
burnout_threshold = ACTION_FATIGUE_CEILING.get("Normal", 0.85)
if predicted_3_day_fatigue > burnout_threshold and action in ("Deep Work", "Exploration", "Normal"):
severity = (predicted_3_day_fatigue - burnout_threshold) * 3.0
logger.debug("Lookahead penalty %.2f for %s (pred_3d_fatigue=%.2f)", severity, action, predicted_3_day_fatigue)
return -severity
return 0.0
def _context_adjusted_params(
base_params: Dict, ctx: Dict[str, float], hist: Dict[str, float],
is_cold_start: bool, is_plateau: bool,
db: Optional[Session] = None, user: Optional[User] = None,
goal_id: Optional[int] = None,
) -> Dict[str, Dict[str, float]]:
adjusted = {}
overwork = hist["overwork_signal"]
hard_days = hist["consecutive_hard_days"]
comp_rate = hist["recent_completion_rate"]
avg_sat = hist["avg_satisfaction_7d"]
deep_stk = hist["deep_work_streak"]
ema_fatigue = hist.get("ema_fatigue", 0.0)
cons_action = hist.get("consecutive_same_action", 0.0)
last_action = hist.get("last_action")
# Behavioral Archetype heuristic
is_high_achiever = comp_rate > 0.8 and hard_days >= 3 and ema_fatigue < 0.6
is_burnout_prone = ema_fatigue > 0.6 and overwork > 0.6 and avg_sat < 0.5
is_avoidant = comp_rate < 0.4 and ctx["idle_days"] > 2
# Feature 3: Circadian bonus computed ONCE outside the loop (avoids 5x DB queries)
circadian_deep_work_bonus = 0.0
if user is not None and db is not None:
circadian_deep_work_bonus = _compute_circadian_bonus(user, db, "Deep Work")
for action in ACTIONS:
alpha = base_params[action]["alpha"]
beta = base_params[action]["beta"]
nudge = 0.0
# Feature 3: Goal Completion Proximity Effect
if action == "Deep Work" and hist.get("max_goal_completion", 0.0) >= 0.8:
nudge += 2.0
logger.debug("Finish Line Boost: +2.0 to Deep Work")
# Action Diversity Entropy Regularization
if action == last_action and cons_action >= 3:
nudge -= 1.5 * (cons_action - 2)
# Archetype Global Adjustments
if is_burnout_prone and action in ("Deep Work", "Normal"):
nudge -= 2.5
if is_high_achiever and action == "Deep Work" and ctx["readiness"] > 0.5:
nudge += 1.5
if is_avoidant and action in ("Light Review", "Exploration"):
nudge += 2.0
if action == "Recovery":
if ctx["disruption"] > 0.5 or ctx["fatigue"] > 0.7:
nudge += 3.0 * ctx["disruption"]
elif ctx["energy"] < 0.3:
nudge += 2.0
if ctx["idle_days"] >= 3:
nudge += 3.0
if hard_days >= 3:
nudge += 3.0 + hard_days * 0.5
if overwork > 0.8:
nudge += overwork * 6.0
elif overwork > 0.6:
nudge += overwork * 4.0
elif action == "Light Review":
if 0.3 < ctx["energy"] < 0.55 and ctx["disruption"] < 0.5:
nudge += 1.5
if ctx["mood"] < 0.4:
nudge += 1.0
if comp_rate < 0.5:
nudge += 2.0
if hard_days >= 2:
nudge += hard_days * 0.8
elif action == "Normal":
if 0.45 < ctx["energy"] < 0.75 and ctx["disruption"] < 0.3:
nudge += 1.0
if comp_rate > 0.6 and avg_sat > 0.5:
nudge += 0.8
elif action == "Deep Work":
if ctx["readiness"] > 0.65 and ctx["time_factor"] > 0.8:
nudge += 2.0 * ctx["readiness"]
if ctx["competence"] > 0.7 and ctx["mood"] > 0.6 and ctx["fatigue"] < 0.3:
nudge += 1.5
if hard_days >= 3:
nudge -= 2.0 + hard_days * 0.4
if deep_stk >= 3:
nudge -= 1.5 * deep_stk
if overwork > 0.7:
nudge -= overwork * 3.0
# Apply pre-computed circadian bonus (already DB-query free)
nudge += circadian_deep_work_bonus
elif action == "Exploration":
if ctx["competence"] < 0.4:
nudge += 2.0
elif ctx["mood"] > 0.6 and ctx["energy"] > 0.5:
nudge += 0.8
if avg_sat < 0.4:
nudge += 2.5
if is_plateau:
nudge += 3.0
# Feature 4: 1-Step Lookahead Penalty (applied to all actions)
nudge += _compute_lookahead_penalty(action, ctx["fatigue"])
if is_cold_start:
alpha += 0.5
beta += 0.5
adjusted[action] = {
"alpha": max(PARAM_FLOOR, alpha + nudge),
"beta": max(PARAM_FLOOR, beta),
}
return adjusted
def _thompson_sample(params: Dict) -> Tuple[str, Dict[str, float], Dict[str, float]]:
samples = {
action: random.betavariate(max(params[action]["alpha"], 0.1), max(params[action]["beta"], 0.1))
for action in ACTIONS
}
# Calculate propensity array (normalized samples to approximate P(A))
total_sample = sum(samples.values())
propensity = {action: round(samples[action] / max(0.001, total_sample), 3) for action in ACTIONS}
return max(samples, key=samples.__getitem__), samples, propensity
def _calculate_weekly_strain(user: User, db: Optional[Session]) -> Tuple[float, bool]:
"""Calculate 7-day Strain Ratio (Deep Work vs Recovery) and return (strain_ratio, is_deload_state)."""
if not db:
return 0.0, False
from app.models.progress import Progress
from datetime import timedelta
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).date().isoformat()
rows = db.query(Progress).filter(
Progress.user_id == user.user_id,
Progress.date >= cutoff,
Progress.completed == True
).all()
deep_work_mins = 0
recovery_mins = 0
for r in rows:
mins = r.actual_duration_min or 0
if r.action_label == "Deep Work":
deep_work_mins += mins
elif r.action_label in ("Recovery", "Light Review", "Rest"):
# Reward Hacking Mitigation:
# Micro-sessions (<20 mins) do not clear deep strain debt.
if mins >= 20:
recovery_mins += mins
strain_ratio = deep_work_mins / max(1.0, float(recovery_mins))
# Deload Hysteresis Logic
currently_deload = getattr(user, "_tmp_is_deload", False)
if currently_deload:
is_deload = strain_ratio > 2.5 # Only exit if ratio drops below 2.5
else:
is_deload = strain_ratio > 4.0 # Enter if ratio spikes above 4.0
return round(strain_ratio, 2), is_deload
def _apply_safety_guard(action: str, fatigue: float, recovery_debt: float = 0.0, is_deload: bool = False, disruption: float = 0.0) -> str:
"""Apply ordered safety rules: disruption → deload → fatigue ceiling → circuit breaker."""
# Rule 0: Disruption (Sick / Extreme Stress)
if disruption >= 0.9 and action not in ("Recovery", "Light Review"):
logger.info("High disruption (%.2f): Forcing %s → Recovery", disruption, action)
return "Recovery"
# Rule 1: Deload State — weekly strain too high, cap intensive actions
if is_deload and action in ("Deep Work", "Exploration", "Normal"):
if fatigue > 0.6:
logger.info("Deload+HighFatigue: Downshifting %s → Light Review", action)
action = "Light Review"
elif action in ("Deep Work", "Exploration"):
logger.info("Deload: Downshifting %s → Normal", action)
action = "Normal"
# Rule 2: Fatigue Ceiling — action-specific hard cap
ceiling = ACTION_FATIGUE_CEILING.get(action)
if ceiling is not None and fatigue > ceiling:
order = ["Deep Work", "Exploration", "Normal", "Light Review", "Recovery"]
idx = order.index(action) if action in order else 0
for safer in order[idx + 1:]:
if fatigue <= ACTION_FATIGUE_CEILING.get(safer, 1.0):
logger.info("Fatigue ceiling: %s → %s (fatigue=%.2f)", action, safer, fatigue)
return safer
return "Recovery"
# Rule 3: Circuit Breaker — lockout Deep Work on critical debt
if recovery_debt > 15.0 and action == "Deep Work":
logger.info("Circuit Breaker: debt=%.1f, forcing Recovery", recovery_debt)
return "Recovery"
return action
def apply_competence_decay(db: Session, user: User) -> float:
idle_days = user.days_since_last_login
if idle_days <= 1:
return user.competence or 0.7
decayed = round(max(0.1, min(1.0, (user.competence or 0.7) * (COMPETENCE_DECAY_PER_DAY ** idle_days))), 4)
if decayed != user.competence:
user.competence = decayed
user.competence_updated_at = datetime.now(timezone.utc)
db.commit()
return decayed
def get_behavior_signal(
user: User, state_override: Optional[List[float]] = None, db: Optional[Session] = None,
) -> Dict:
"""Compute adaptive behavior signal using Contextual Thompson Sampling + historical data."""
try:
if db is not None and user.days_since_last_login > 1:
apply_competence_decay(db, user)
_apply_inactivity_adjustment(user, db)
state = state_override or get_user_state_vector(user)
while len(state) < 7: state.append(0.0)
state = [max(0.0, min(1.0, float(v))) for v in state[:7]]
competence, mood, fatigue = state[0], state[1], state[2]
base_params = _load_bandit_params(user)
# Feature 2: Apply Memory Decay (once per week)
if db is not None:
base_params = _apply_memory_decay(user, base_params, db)
hist = _compute_historical_features(user, db)
ctx = _compute_context_features(state, user, hist)
history_count = getattr(user, "rl_history_count", 0) or 0
is_cold_start = history_count < 10
is_plateau = _detect_plateau(user)
adjusted_params = _context_adjusted_params(
base_params, ctx, hist, is_cold_start, is_plateau,
db=db, user=user
)
# Smart Adaptive Entropy / Repetition Penalty
# If stuck in a loop > 3 days AND (mood is dropping OR fatigue is rising), penalize.
# This protects highly disciplined users who are thriving in Deep Work loops.
consecutive = hist.get("consecutive_same_action", 0)
last_action = hist.get("last_action")
if consecutive >= 3 and last_action in adjusted_params:
if hist.get("ema_mood", 0.5) < 0.6 or hist.get("fatigue_trend", 0.0) > 0:
logger.info("Adaptive Entropy Triggered: Penalizing %s due to %d day repetition with poor state", last_action, consecutive)
adjusted_params[last_action]["alpha"] = max(0.1, adjusted_params[last_action]["alpha"] - 2.0)
action_label, samples, propensity = _thompson_sample(adjusted_params)
recovery_debt = base_params.get("_recovery_debt", 0.0)
strain_ratio, is_deload = _calculate_weekly_strain(user, db)
action_label = _apply_safety_guard(action_label, fatigue, recovery_debt, is_deload, ctx.get("disruption", 0.0))
lo, hi = ACTION_INTENSITIES[action_label]
raw_intensity = ctx["energy"] * ctx["time_factor"] * ctx["weekend_factor"] * ctx["inactivity_factor"]
if hist["overwork_signal"] > 0.5:
raw_intensity *= (1.0 - hist["overwork_signal"] * 0.4)
intensity = lo + (hi - lo) * max(0.0, min(1.0, raw_intensity))
intensity += random.uniform(-0.03, 0.03)
intensity = round(max(0.0, min(1.0, intensity)), 3)
except Exception:
logger.exception("RL signal failed, using fallback")
state = [0.7, 0.5, 0.0, 0.8, 0.0, 0.0, 0.1]
competence, mood, fatigue = state[0], state[1], state[2]
action_label = "Normal"
intensity = 0.5
samples = {a: 0.5 for a in ACTIONS}
propensity = {a: 0.2 for a in ACTIONS}
history_count, is_cold_start, is_plateau = 0, True, False
hist = _compute_historical_features(user, None)
if db is not None:
try:
user.current_action_label = action_label
user.current_intensity = intensity
db.commit()
except Exception:
pass
logger.info("RL signal for %s: mode=%s intensity=%.2f overwork=%.2f", user.user_id, action_label, intensity, hist.get("overwork_signal", 0))
try:
import os
import json
trace_dir = "data"
os.makedirs(trace_dir, exist_ok=True)
trace_file = os.path.join(trace_dir, "traces.jsonl")
prop_arr = locals().get("propensity", {a: 0.2 for a in ACTIONS})
trace_payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"user_id": user.user_id,
"state_vector": state,
"debt_level": locals().get("recovery_debt", 0.0),
"weekly_strain_ratio": locals().get("strain_ratio", 0.0),
"is_deload": locals().get("is_deload", False),
"selected_action": action_label,
"intensity": intensity,
"propensity_array": prop_arr,
"burnout_warning": hist.get("burnout_warning", False),
"weekend_warrior_flag": hist.get("weekend_rate", 0) > hist.get("weekday_rate", 0) + 0.05,
"max_goal_completion": hist.get("max_goal_completion", 0.0),
}
with open(trace_file, "a") as f:
f.write(json.dumps(trace_payload) + "\n")
except Exception as e:
logger.error("Failed to write RL trace: %s", e)
return {
"action_label": action_label,
"intensity": intensity,
"mood": mood,
"fatigue": fatigue,
"raw_action_vector": state,
"rl_samples": {k: round(v, 3) for k, v in samples.items()},
"rl_updates": history_count,
"cold_start": is_cold_start,
"plateau_detected": is_plateau,
"historical": {
"consecutive_hard_days": hist["consecutive_hard_days"],
"recent_completion_rate": hist["recent_completion_rate"],
"avg_satisfaction_7d": hist["avg_satisfaction_7d"],
"overwork_signal": hist["overwork_signal"],
"deep_work_streak": hist["deep_work_streak"],
},
"weekly_strain_ratio": getattr(user, "_tmp_strain_ratio", 0.0) if 'strain_ratio' not in locals() else strain_ratio,
"is_deload_state": getattr(user, "_tmp_is_deload", False) if 'is_deload' not in locals() else is_deload,
"propensity_array": propensity,
"burnout_warning": hist.get("burnout_warning", False)
}
def get_stable_signal(user: User, db: Optional[Session] = None) -> Dict:
"""Return today's RL signal. Recomputes fresh if stale (from a previous day)."""
today = datetime.now(timezone.utc).date()
persisted_label = getattr(user, "current_action_label", None)
persisted_intensity = getattr(user, "current_intensity", None)
state_updated_at = getattr(user, "state_updated_at", None)
if persisted_label and persisted_intensity is not None and state_updated_at is not None:
# Fix: always compare as UTC dates to avoid midnight re-computation bugs
if hasattr(state_updated_at, "date"):
if state_updated_at.tzinfo is None:
state_updated_at = state_updated_at.replace(tzinfo=timezone.utc)
updated_date = state_updated_at.date()
else:
updated_date = today
if updated_date == today:
state = get_user_state_vector(user)
hist = _compute_historical_features(user, db)
strain_ratio, is_deload = _calculate_weekly_strain(user, db)
return {
"action_label": persisted_label,
"intensity": persisted_intensity,
"mood": state[1],
"fatigue": state[2],
"raw_action_vector": state,
"rl_samples": None,
"rl_updates": getattr(user, "rl_history_count", 0) or 0,
"cold_start": (getattr(user, "rl_history_count", 0) or 0) < 10,
"plateau_detected": False,
"historical": {
"consecutive_hard_days": hist["consecutive_hard_days"],
"recent_completion_rate": hist["recent_completion_rate"],
"avg_satisfaction_7d": hist["avg_satisfaction_7d"],
"overwork_signal": hist["overwork_signal"],
"deep_work_streak": hist["deep_work_streak"],
},
"weekly_strain_ratio": strain_ratio,
"is_deload_state": is_deload,
# Fixed: include propensity_array so frontend never gets KeyError
"propensity_array": {a: round(1.0 / len(ACTIONS), 3) for a in ACTIONS},
"burnout_warning": hist.get("burnout_warning", False),
}
else:
# Stale signal from a previous day — recompute fresh
logger.info("Stale RL signal for %s (from %s), recomputing fresh", user.user_id, updated_date)
# No persisted signal or it's stale — compute fresh
return get_behavior_signal(user, db=db)
def refresh_signal(user: User, db: Session) -> Dict:
user.state_updated_at = datetime.now(timezone.utc)
db.flush()
return get_behavior_signal(user, db=db)
def update_reward(db: Session, user: User, action_taken: str, reward: float, goal_id: Optional[int] = None) -> None:
if action_taken not in ACTIONS:
logger.warning("Unknown action '%s' — skipping RL update", action_taken)
return
reward = max(0.0, min(1.0, float(reward)))
hist = _compute_historical_features(user, db)
params = _load_bandit_params(user)
# 1. Delayed Burnout Penalty / Psychological Safety Dampening
if action_taken == "Deep Work" and hist.get("overwork_signal", 0.0) > 0.7:
reward *= 0.6 # Immediate safety dampening
# Feature 6: Outlier / Reward Hacking Detection
if reward > 0.8 and hist.get("ema_fatigue", 0.0) > 0.8:
# User claims extreme satisfaction despite extreme fatigue
reward = 0.5 + (reward - 0.5) * 0.5 # Squash toward 0.5
logger.info("Reward hacked? High satisfaction despite extreme fatigue. Discounted reward to %.2f", reward)
# Longitudinal Reinforcement: Penalize yesterday's intense action if today is crushed
if hist.get("last_action") == "Deep Work" and hist.get("ema_fatigue", 0.0) > 0.7:
params["Deep Work"]["alpha"] = max(1.0, params["Deep Work"]["alpha"] - 1.5)
# Recovery Budget Accounting — corrected costs per mode
debt = params.get("_recovery_debt", 0.0)
if action_taken == "Deep Work":
debt += 3.0 # High cost: intense focus drains reserves
elif action_taken == "Recovery":
debt = max(0.0, debt - 4.0) # Slightly higher recovery credit
elif action_taken in ("Light Review", "Exploration"):
debt += 0.5 # Light burden
elif action_taken == "Normal":
debt += 0.3 # Sustainable mode — was wrongly set to 1.0 before
# (anything else adds 0)
params["_recovery_debt"] = debt
arm = params[action_taken]
if reward > 0.5:
arm["alpha"] += reward
else:
arm["beta"] += (1.0 - reward)
# 2. Soft Normalization (Bounded Confidence) to preserve variance
MAX_CONFIDENCE = 20.0
for action in ACTIONS:
total = params[action]["alpha"] + params[action]["beta"]
if total > MAX_CONFIDENCE:
scale = MAX_CONFIDENCE / total
params[action]["alpha"] = max(1.0, 1.0 + (params[action]["alpha"] - 1.0) * scale)
params[action]["beta"] = max(1.0, 1.0 + (params[action]["beta"] - 1.0) * scale)
# Feature 3: Update Goal-Specific Bandit if goal_id provided
if goal_id is not None:
_save_goal_bandit_params(user, goal_id, action_taken, reward)
params["_last_updated"] = datetime.now(timezone.utc).isoformat()
params = _clamp_params(params)
user.rl_bandit_params = json.dumps(params)
user.rl_history_count = (user.rl_history_count or 0) + 1
history = getattr(user, "rl_state", None) or []
if not isinstance(history, list): history = []
history.append(round(reward, 4))
user.rl_state = history[-50:]
db.commit()
logger.info("RL update for %s: action=%s goal=%s reward=%.2f -> alpha=%.2f beta=%.2f [total %d]",
user.user_id, action_taken, goal_id, reward,
params[action_taken]["alpha"], params[action_taken]["beta"], user.rl_history_count)
def _apply_inactivity_adjustment(user: User, db: Session) -> None:
"""If user was inactive 3+ days, decay bandit params toward defaults to prevent overfit."""
idle_days = user.days_since_last_login
if idle_days < 3:
return
params = _load_bandit_params(user)
defaults = _get_default_params()
# Blend toward defaults: 20% per idle day beyond 2, capped at 80%
blend = min(0.80, (idle_days - 2) * 0.20)
for action in ACTIONS:
params[action]["alpha"] = params[action]["alpha"] * (1 - blend) + defaults[action]["alpha"] * blend
params[action]["beta"] = params[action]["beta"] * (1 - blend) + defaults[action]["beta"] * blend
params = _clamp_params(params)
user.rl_bandit_params = json.dumps(params)
db.commit()
logger.info("Inactivity adjustment for %s: %d idle days, blend=%.0f%%", user.user_id, idle_days, blend * 100)
def reset_daily_state(db: Session, user: User) -> None:
"""Clear persisted RL decision so next GET /api/plan recomputes fresh.
Called after day-close or manual completion."""
user.current_action_label = None
user.current_intensity = None
user.state_updated_at = None
db.commit()
logger.info("Daily state reset for %s", user.user_id)
def get_next_day_signal(user: User, db: Optional[Session] = None) -> Dict:
"""Compute a preview signal for tomorrow (doesn't persist).
Used to show 'tomorrow's plan preview' after day-close."""
try:
if db is not None and user.days_since_last_login > 1:
apply_competence_decay(db, user)
state = get_user_state_vector(user)
while len(state) < 7: state.append(0.0)
state = state[:7]
# Simulate slightly recovered state for tomorrow
# Reduce fatigue by 30%, boost mood slightly
state[1] = min(1.0, state[1] + 0.05) # mood bump
state[2] = max(0.0, state[2] * 0.70) # fatigue recovery
base_params = _load_bandit_params(user)
hist = _compute_historical_features(user, db)
ctx = _compute_context_features(state, user, hist)
history_count = getattr(user, "rl_history_count", 0) or 0
is_cold_start = history_count < 10
is_plateau = _detect_plateau(user)
adjusted_params = _context_adjusted_params(base_params, ctx, hist, is_cold_start, is_plateau)
action_label, samples, propensity = _thompson_sample(adjusted_params) # Fixed: unpack 3 values
action_label = _apply_safety_guard(action_label, state[2])
lo, hi = ACTION_INTENSITIES[action_label]
raw_intensity = ctx["energy"] * ctx["time_factor"] * ctx["weekend_factor"] * ctx["inactivity_factor"]
if hist["overwork_signal"] > 0.5:
raw_intensity *= (1.0 - hist["overwork_signal"] * 0.4)
intensity = lo + (hi - lo) * max(0.0, min(1.0, raw_intensity))
intensity = round(max(0.0, min(1.0, intensity)), 3)
return {
"action_label": action_label,
"intensity": intensity,
"preview": True,
}
except Exception:
logger.exception("Next-day signal preview failed")
return {"action_label": "Normal", "intensity": 0.5, "preview": True}