"""Per-agent inference tests: momentum (#114), overdue-task (#115), recent-patterns (#116), time-of-day (#112), and focus-area (#113) preferred_areas wiring.""" from __future__ import annotations import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) from datetime import datetime, timezone import pytest from ml.agents.inference.history import FeedbackEvent, TaskCompletion, UserHistory from ml.agents.inference.framework import run_inference from ml.agents.momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST from ml.agents.overdue_task import OverdueTaskAgent, MANIFEST as OVERDUE_MANIFEST from ml.agents.recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_MANIFEST from ml.agents.time_of_day import TimeOfDayAgent, MANIFEST as TOD_MANIFEST from ml.agents.focus_area import FocusAreaAgent from ml.agents.base import AgentInput _NOW = datetime(2026, 5, 8, 14, 0, 0, tzinfo=timezone.utc) def _inp(**kwargs) -> AgentInput: defaults = dict(user_id="u1", tasks=[], profile={}, now=_NOW, agent_prefs={}) defaults.update(kwargs) return AgentInput(**defaults) def _event(action: str, days_ago: float = 1.0) -> FeedbackEvent: from datetime import timedelta ts = (_NOW - timedelta(days=days_ago)).isoformat() dwell = 60_000 if action == "done" else 500 return FeedbackEvent(action=action, dwell_ms=dwell, created_at=ts) def _history(*events: FeedbackEvent, completions: list[TaskCompletion] | None = None) -> UserHistory: return UserHistory(user_id="u1", events=list(events), task_completions=completions or []) def _completion(project_id: str | None, lateness_days: float) -> TaskCompletion: """Build a TaskCompletion where completed_at is lateness_days after due_at.""" from datetime import timedelta due = _NOW - timedelta(days=30) completed = due + timedelta(days=lateness_days) return TaskCompletion( project_id=project_id, completed_at=completed.isoformat(), due_at=due.isoformat(), ) # ── momentum helpers ───────────────────────────────────────────────────────── def _neutral_prefs(**extra) -> dict: """Prefs that put z-score in the normal range so trend label can show.""" return {"baseline_completions_per_day": 0.0, "stdev": 1.0, "momentum_window": 7, **extra} def _feedback_done(n: int, days_ago: float = 1.0) -> list[dict]: from datetime import timedelta ts = (_NOW - timedelta(days=days_ago)).isoformat() return [{"action": "done", "dwell_ms": 60_000, "created_at": ts}] * n # ── momentum: engagement_trend inference ───────────────────────────────────── class TestMomentumTrendInference: def test_cold_start_below_min_history(self): history = _history(*[_event("done", days_ago=i) for i in range(5)]) result = run_inference(MOMENTUM_MANIFEST, history) assert result["engagement_trend"] == "stable" # cold_start_default def test_trend_up_when_recent_done_rate_higher(self): recent = [_event("done", days_ago=i) for i in range(1, 9)] older = [_event("dismiss", days_ago=i) for i in range(8, 15)] older[0] = _event("done", days_ago=8) history = _history(*recent, *older) result = run_inference(MOMENTUM_MANIFEST, history) assert result["engagement_trend"] == "up" def test_trend_down_when_recent_done_rate_lower(self): recent = [_event("dismiss", days_ago=i) for i in range(1, 8)] older = [_event("done", days_ago=i) for i in range(8, 15)] history = _history(*recent, *older) result = run_inference(MOMENTUM_MANIFEST, history) assert result["engagement_trend"] == "down" def test_trend_stable_when_similar(self): events = [_event("done" if i % 2 == 0 else "dismiss", days_ago=i) for i in range(1, 15)] history = _history(*events) result = run_inference(MOMENTUM_MANIFEST, history) assert result["engagement_trend"] == "stable" def test_trend_shown_when_z_score_normal(self): # baseline=0 so z≈0 → no z label → trend label falls through out = MomentumAgent().compute(_inp(agent_prefs=_neutral_prefs(engagement_trend="up"))) assert "trending up" in out.prompt_text def test_trend_down_shown_when_z_score_normal(self): out = MomentumAgent().compute(_inp(agent_prefs=_neutral_prefs(engagement_trend="down"))) assert "trending down" in out.prompt_text def test_snapshot_includes_trend(self): out = MomentumAgent().compute(_inp(agent_prefs=_neutral_prefs(engagement_trend="stable"))) assert "engagement_trend" in out.signals_snapshot # ── momentum: baseline + stdev inference (#114) ─────────────────────────────── class TestMomentumBaselineInference: def _events_n_per_day(self, done_per_day: int, n_days: int) -> list[FeedbackEvent]: """Generate done events spread across n_days.""" events = [] for d in range(n_days): for _ in range(done_per_day): events.append(_event("done", days_ago=d + 0.5)) return events def test_cold_start_when_few_events(self): history = _history(*[_event("done", days_ago=i) for i in range(5)]) result = run_inference(MOMENTUM_MANIFEST, history) assert result["baseline_completions_per_day"] == 1.0 assert result["stdev"] == 1.0 def test_power_user_baseline_high(self): # 5 done events per day for 20 days → baseline ≈ 5/day (over 28d window, zeros fill rest) events = self._events_n_per_day(5, 20) history = _history(*events) result = run_inference(MOMENTUM_MANIFEST, history) assert result["baseline_completions_per_day"] > 2.0 def test_casual_user_baseline_low(self): # 1 done every 3 days + dismiss filler to clear min_history=14 → baseline ≈ 0.33/day done_events = [_event("done", days_ago=d * 3 + 0.5) for d in range(7)] filler = [_event("dismiss", days_ago=d + 0.5) for d in range(10)] history = _history(*done_events, *filler) result = run_inference(MOMENTUM_MANIFEST, history) assert result["baseline_completions_per_day"] < 0.5 def test_stdev_reflects_variability(self): # Alternating 0 and 4 done events → high stdev events = [] for d in range(14): if d % 2 == 0: for _ in range(4): events.append(_event("done", days_ago=d + 0.5)) history = _history(*events) result = run_inference(MOMENTUM_MANIFEST, history) assert result["stdev"] > 1.0 def test_consistent_user_lower_stdev_than_variable(self): # Consistent 2/day for 28 days has lower stdev than alternating 0/4 consistent = self._events_n_per_day(2, 28) variable = [] for d in range(14): if d % 2 == 0: for _ in range(4): variable.append(_event("done", days_ago=d + 0.5)) else: variable.append(_event("dismiss", days_ago=d + 0.5)) r_consistent = run_inference(MOMENTUM_MANIFEST, _history(*consistent)) r_variable = run_inference(MOMENTUM_MANIFEST, _history(*variable)) assert r_consistent["stdev"] < r_variable["stdev"] # ── momentum: z-score snippet language ─────────────────────────────────────── class TestMomentumZScore: def _prefs(self, baseline: float, stdev: float = 1.0) -> dict: return {"baseline_completions_per_day": baseline, "stdev": stdev, "momentum_window": 7, "engagement_trend": "stable"} def test_power_user_above_baseline_says_above_usual(self): # baseline=3/day, stdev=1.0, window=7 → expected rate=3; user did 35 → rate=5, z=2 prefs = self._prefs(baseline=3.0, stdev=1.0) feedback = _feedback_done(35, days_ago=1.0) out = MomentumAgent().compute(_inp(feedback_history=feedback, agent_prefs=prefs)) assert "above your usual" in out.prompt_text def test_casual_user_slowing_down(self): # baseline=1/day, user did 0 in 7d → z = (0 - 1) / 1 = -1 → below usual prefs = self._prefs(baseline=1.0, stdev=1.0) out = MomentumAgent().compute(_inp(feedback_history=[], agent_prefs=prefs)) assert "below your usual" in out.prompt_text def test_returning_from_break_at_normal_rate(self): # User just came back: 1 done, baseline=1/day, window=7 → z=(1/7-1)/1≈-0.86, within normal prefs = self._prefs(baseline=1.0, stdev=1.0) feedback = _feedback_done(1, days_ago=0.5) out = MomentumAgent().compute(_inp(feedback_history=feedback, agent_prefs=prefs)) # z ≈ -0.86 → no z label, falls back to trend (stable → no extra sentence) assert "above your usual" not in out.prompt_text assert "below your usual" not in out.prompt_text def test_snapshot_includes_z_score(self): prefs = self._prefs(baseline=1.0) out = MomentumAgent().compute(_inp(agent_prefs=prefs)) assert "z_score" in out.signals_snapshot assert "recent_done_count" in out.signals_snapshot def test_version_bumped(self): assert MOMENTUM_MANIFEST.version == "1.2.0" # ── overdue-task: lateness_tolerance_days + project_realness (#115) ────────── class TestOverdueTaskInference: # -- lateness_tolerance_days inference -- def test_cold_start_returns_zero_when_few_completions(self): # Below min_history=10 task completions → cold start cs = [_completion("p1", 2.0) for _ in range(5)] history = _history(*[_event("done")] * 5, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["lateness_tolerance_days"] == 0.0 def test_punctual_user_zero_tolerance(self): # User always finishes early or on time (negative lateness) → tolerance 0 cs = [_completion("p1", -1.0) for _ in range(12)] history = _history(*[_event("done")] * 12, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["lateness_tolerance_days"] == 0.0 def test_chronic_late_user_positive_tolerance(self): # User consistently finishes 5 days late → p50 = 5 cs = [_completion("p1", 5.0) for _ in range(12)] history = _history(*[_event("done")] * 12, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["lateness_tolerance_days"] == pytest.approx(5.0) def test_mixed_lateness_uses_median(self): # 6 tasks at +1d, 6 tasks at +3d → median = 2 cs = [_completion("p1", 1.0)] * 6 + [_completion("p1", 3.0)] * 6 history = _history(*[_event("done")] * 12, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["lateness_tolerance_days"] == pytest.approx(2.0) # -- project_realness inference -- def test_project_realness_cold_start_empty(self): cs = [_completion("p1", 1.0) for _ in range(5)] # below min_history history = _history(*[_event("done")] * 5, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["project_realness"] == {} def test_project_realness_punctual_project_scores_high(self): # p1 always on time (0d late), p2 always 10d late → p1 should be realness ≈ 1 cs = [_completion("p1", 0.0)] * 6 + [_completion("p2", 10.0)] * 6 history = _history(*[_event("done")] * 12, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) assert result["project_realness"]["p1"] > result["project_realness"]["p2"] def test_project_realness_values_clipped_01(self): cs = [_completion("p1", 0.0)] * 6 + [_completion("p2", 100.0)] * 6 history = _history(*[_event("done")] * 12, completions=cs) result = run_inference(OVERDUE_MANIFEST, history) for v in result["project_realness"].values(): assert 0.0 <= v <= 1.0 # -- compute() reads inferred prefs -- def test_tolerance_filters_tasks(self): tasks = [ {"content": "Fresh overdue", "is_overdue": True, "task_age_days": 0.5}, {"content": "Old overdue", "is_overdue": True, "task_age_days": 3.0}, ] out = OverdueTaskAgent().compute(_inp(tasks=tasks, agent_prefs={"lateness_tolerance_days": 2})) assert "1 overdue task" in out.prompt_text assert "Old overdue" in out.prompt_text def test_low_realness_softens_language(self): tasks = [{"content": "Wishlist", "is_overdue": True, "task_age_days": 3.0, "project_id": "aspirational"}] prefs = {"lateness_tolerance_days": 0, "project_realness": {"aspirational": 0.2}} out = OverdueTaskAgent().compute(_inp(tasks=tasks, agent_prefs=prefs)) assert "target date" in out.prompt_text def test_high_realness_uses_overdue_language(self): tasks = [{"content": "Critical", "is_overdue": True, "task_age_days": 3.0, "project_id": "work"}] prefs = {"lateness_tolerance_days": 0, "project_realness": {"work": 0.9}} out = OverdueTaskAgent().compute(_inp(tasks=tasks, agent_prefs=prefs)) assert "overdue" in out.prompt_text def test_snapshot_includes_realness(self): tasks = [{"content": "T", "is_overdue": True, "task_age_days": 1.0, "project_id": "p1"}] prefs = {"lateness_tolerance_days": 0, "project_realness": {"p1": 0.8}} out = OverdueTaskAgent().compute(_inp(tasks=tasks, agent_prefs=prefs)) assert "realness" in out.signals_snapshot["top_overdue"][0] def test_version_bumped(self): assert OVERDUE_MANIFEST.version == "1.2.0" # ── recent-patterns: lookback_days + weekly_cycle + daily_cycle (#116) ──────── def _done_at(days_ago: float, hour: int = 10) -> FeedbackEvent: """Done event at a specific hour, N days ago.""" from datetime import timedelta ts = (_NOW - timedelta(days=days_ago)).replace(hour=hour, minute=0, second=0, microsecond=0) return FeedbackEvent(action="done", dwell_ms=60_000, created_at=ts.isoformat()) class TestRecentPatternsLookbackInference: def test_cold_start_below_min_history(self): history = _history(*[_event("done") for _ in range(3)]) result = run_inference(RECENT_MANIFEST, history) assert result["lookback_days"] == 7 # cold_start_default def test_sparse_done_history_returns_30(self): # Only 10 done events → fewer than 30 → returns cap of 30 history = _history(*[_event("done") for _ in range(10)]) result = run_inference(RECENT_MANIFEST, history) assert result["lookback_days"] == 30 def test_dense_done_history_returns_short_window(self): # 30 done events all within the last 2 days → lookback_days = 1 or 2 events = [_event("done", days_ago=i * 0.05) for i in range(30)] history = _history(*events) result = run_inference(RECENT_MANIFEST, history) assert result["lookback_days"] <= 2 def test_spread_history_spans_window_correctly(self): # 30 done events spread over 15 days (1 per 0.5d) → window should be ≈15 events = [_event("done", days_ago=i * 0.5) for i in range(30)] history = _history(*events) result = run_inference(RECENT_MANIFEST, history) assert result["lookback_days"] <= 16 def test_agent_respects_lookback_days_pref(self): from datetime import timedelta feedback = [ {"action": "done", "dwell_ms": 60000, "created_at": (_NOW - timedelta(days=10)).isoformat()} ] * 5 out_narrow = RecentPatternsAgent().compute( _inp(feedback_history=feedback, agent_prefs={"lookback_days": 7}) ) out_wide = RecentPatternsAgent().compute( _inp(feedback_history=feedback, agent_prefs={"lookback_days": 14}) ) assert "No tip reactions" in out_narrow.prompt_text assert "5 tip reactions" in out_wide.prompt_text def test_legacy_window_days_pref_still_works(self): from datetime import timedelta feedback = [ {"action": "done", "dwell_ms": 60000, "created_at": (_NOW - timedelta(days=10)).isoformat()} ] * 5 out = RecentPatternsAgent().compute( _inp(feedback_history=feedback, agent_prefs={"window_days": 14}) ) assert "5 tip reactions" in out.prompt_text def test_snapshot_includes_lookback_days(self): out = RecentPatternsAgent().compute(_inp(agent_prefs={"lookback_days": 14})) assert out.signals_snapshot["lookback_days"] == 14 class TestRecentPatternsWeeklyCycle: def test_cold_start_returns_empty(self): history = _history(*[_event("done") for _ in range(5)]) # below min_history=21 result = run_inference(RECENT_MANIFEST, history) assert result["weekly_cycle"] == [] def _events_on_dow(self, target_dow: int, count: int, n_weeks: int = 4) -> list[FeedbackEvent]: """Generate `count` done events per week on `target_dow` (0=Mon…6=Sun). _NOW is Thursday (weekday=3). days_back = (now_dow - target_dow) % 7 gives the offset to the most recent occurrence of target_dow. """ now_dow = _NOW.weekday() # 3 = Thursday days_back = (now_dow - target_dow) % 7 if days_back == 0: days_back = 7 # avoid "today" — use the previous occurrence events = [] for week in range(n_weeks): offset = days_back + week * 7 for _ in range(count): events.append(_done_at(offset + 0.1, hour=11)) return events def _weekend_warrior_history(self) -> UserHistory: """Many done events on Sat/Sun (dow 5 & 6), few on Tuesday (dow 1).""" events = [] events += self._events_on_dow(5, count=5) # Saturday events += self._events_on_dow(6, count=5) # Sunday events += self._events_on_dow(1, count=1) # Tuesday — one per week return _history(*events) def test_weekend_warrior_strong_on_weekends(self): history = self._weekend_warrior_history() result = run_inference(RECENT_MANIFEST, history) by_dow = {e["dow"]: e["strength"] for e in result["weekly_cycle"]} assert by_dow.get(5, 0) > 1.0 # Saturday assert by_dow.get(6, 0) > 1.0 # Sunday def test_weekday_only_low_weekend_strength(self): events = [] for dow in range(5): # Monday–Friday events += self._events_on_dow(dow, count=3) # Saturday (5) and Sunday (6) get zero events history = _history(*events) result = run_inference(RECENT_MANIFEST, history) by_dow = {e["dow"]: e["strength"] for e in result["weekly_cycle"]} assert by_dow.get(5, 0) == 0.0 # Saturday assert by_dow.get(6, 0) == 0.0 # Sunday def test_snippet_includes_cycle_hint_when_strong(self): # Inject a strong weekly_cycle pref directly prefs = { "lookback_days": 7, "weekly_cycle": [{"dow": 1, "strength": 2.0, "sample": "completes most Tuesdays"}], "daily_cycle": [], } out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs)) assert "Tuesday" in out.prompt_text def test_snippet_omits_cycle_hint_when_weak(self): prefs = { "lookback_days": 7, "weekly_cycle": [{"dow": 1, "strength": 0.3, "sample": "completes most Tuesdays"}], "daily_cycle": [], } out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs)) assert "Tuesday" not in out.prompt_text class TestRecentPatternsDailyCycle: def test_cold_start_returns_empty(self): history = _history(*[_event("done") for _ in range(5)]) # below min_history=14 result = run_inference(RECENT_MANIFEST, history) assert result["daily_cycle"] == [] def _evening_person_history(self) -> UserHistory: """Many done events at 20:00–21:00, few in the morning.""" events = [] for d in range(20): for _ in range(4): events.append(_done_at(d + 0.5, hour=20)) events.append(_done_at(d + 0.5, hour=9)) return _history(*events) def test_evening_person_strong_at_evening_hours(self): history = self._evening_person_history() result = run_inference(RECENT_MANIFEST, history) by_hour = {e["hour"]: e["strength"] for e in result["daily_cycle"]} assert by_hour.get(20, 0) > 1.0 assert by_hour.get(9, 0) < by_hour.get(20, 0) def test_snippet_includes_daily_hint_when_strong(self): prefs = { "lookback_days": 7, "weekly_cycle": [], "daily_cycle": [{"hour": 20, "strength": 3.0}], } out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs)) assert "8pm" in out.prompt_text def test_snippet_omits_daily_hint_when_weak(self): prefs = { "lookback_days": 7, "weekly_cycle": [], "daily_cycle": [{"hour": 20, "strength": 0.4}], } out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs)) assert "8pm" not in out.prompt_text def test_no_pattern_user_no_hints(self): # Uniform distribution across all hours → strength ≈ 1.0 everywhere → no strong peaks events = [_done_at(d + 0.5, hour=h) for d in range(3) for h in range(24)] history = _history(*events) result = run_inference(RECENT_MANIFEST, history) strong = [e for e in result["daily_cycle"] if e["strength"] > 0.5] # Uniform distribution → all strengths ≈ 1.0; but none dramatically above threshold # Since strength = count/mean and all counts are equal, all = 1.0 exactly # 1.0 is not > 0.5 threshold in snippet rendering, but IS > 0.5 so they'd show. # For a flat distribution the caller sees no meaningful peak — verify no strength > 2 assert all(e["strength"] <= 1.1 for e in result["daily_cycle"]) def test_version_bumped(self): assert RECENT_MANIFEST.version == "1.2.0" # ── time-of-day: quiet_start/end + peak_hours inference (#112) ─────────────── def _tod_event(action: str, hour: int, days_ago: float = 1.0) -> FeedbackEvent: """Feedback event at a specific hour N days ago.""" from datetime import timedelta dt = (_NOW - timedelta(days=days_ago)).replace(hour=hour, minute=0, second=0, microsecond=0) return FeedbackEvent(action=action, dwell_ms=60_000, created_at=dt.isoformat()) def _tod_history(*events: FeedbackEvent) -> UserHistory: return UserHistory(user_id="u1", events=list(events)) class TestTimeOfDayQuietWindow: def test_cold_start_below_min_history(self): history = _tod_history(*[_tod_event("done", 10) for _ in range(10)]) result = run_inference(TOD_MANIFEST, history) assert result["quiet_start"] == "22:00" assert result["quiet_end"] == "07:00" def _night_owl_history(self) -> UserHistory: """Active 20:00–23:00, quiet 02:00–14:00.""" events = [] for d in range(10): for h in [20, 21, 22, 23, 0, 1]: events.append(_tod_event("done", h, days_ago=d + 0.5)) # Sparse during day events.append(_tod_event("done", 15, days_ago=d + 0.5)) return _tod_history(*events) def _early_bird_history(self) -> UserHistory: """Active 06:00–10:00, quiet 21:00–05:00.""" events = [] for d in range(10): for h in [6, 7, 8, 9, 10]: events.append(_tod_event("done", h, days_ago=d + 0.5)) events.append(_tod_event("done", 14, days_ago=d + 0.5)) return _tod_history(*events) def test_early_bird_quiet_in_evening(self): history = self._early_bird_history() result = run_inference(TOD_MANIFEST, history) # Quiet window should be in the evening/night range start_h = int(result["quiet_start"].split(":")[0]) end_h = int(result["quiet_end"].split(":")[0]) # Quiet window spans from some evening hour into morning assert start_h >= 18 or end_h <= 10 # covers night def test_quiet_window_wraps_midnight(self): # Night owl: heavy activity in evening, quiet 02:00–14:00 history = self._night_owl_history() result = run_inference(TOD_MANIFEST, history) start_h = int(result["quiet_start"].split(":")[0]) end_h = int(result["quiet_end"].split(":")[0]) # The quiet window should span across midnight or be in daylight # (start > end means wraps midnight) is_wrapping = start_h > end_h is_daytime = 2 <= start_h <= 14 assert is_wrapping or is_daytime def test_format_is_hhmm(self): history = self._early_bird_history() result = run_inference(TOD_MANIFEST, history) import re assert re.match(r"^\d{2}:00$", result["quiet_start"]) assert re.match(r"^\d{2}:00$", result["quiet_end"]) class TestTimeOfDayPeakHours: def _evening_person_history(self, n: int = 60) -> UserHistory: """Heavy done events at 19:00 and 20:00, light elsewhere.""" events = [] for i in range(n): events.append(_tod_event("done", 19, days_ago=i * 0.5)) events.append(_tod_event("done", 20, days_ago=i * 0.5)) events.append(_tod_event("done", 10, days_ago=i * 0.5)) # low volume return _tod_history(*events) def test_cold_start_returns_default(self): history = _tod_history(*[_tod_event("done", 10) for _ in range(5)]) result = run_inference(TOD_MANIFEST, history) assert result["peak_hours"] == [9, 14, 20] def test_evening_person_peak_hours_in_evening(self): history = self._evening_person_history() result = run_inference(TOD_MANIFEST, history) assert 19 in result["peak_hours"] or 20 in result["peak_hours"] def test_peak_hours_sorted(self): history = self._evening_person_history() result = run_inference(TOD_MANIFEST, history) assert result["peak_hours"] == sorted(result["peak_hours"]) def test_shift_worker_peaks_at_unusual_hours(self): """Shift worker active at 02:00 and 03:00.""" events = [_tod_event("done", h, days_ago=i * 0.5) for i in range(30) for h in [2, 3]] events += [_tod_event("done", 14, days_ago=i * 0.5) for i in range(5)] history = _tod_history(*events) result = run_inference(TOD_MANIFEST, history) assert 2 in result["peak_hours"] or 3 in result["peak_hours"] class TestTimeOfDaySnippet: agent = TimeOfDayAgent() def _inp_at(self, hour: int, **prefs) -> AgentInput: from datetime import timedelta now = _NOW.replace(hour=hour) return _inp(now=now, agent_prefs=prefs) def test_in_peak_hour_says_peak(self): out = self.agent.compute(self._inp_at(20, peak_hours=[20])) assert "peak productivity hour" in out.prompt_text def test_approaching_peak_says_approaching(self): out = self.agent.compute(self._inp_at(18, peak_hours=[20])) assert "approaching" in out.prompt_text.lower() def test_quiet_window_overrides_peak(self): # Even if hour is in peak_hours, quiet window wins out = self.agent.compute( self._inp_at(23, quiet_start="22:00", quiet_end="07:00", peak_hours=[23]) ) assert "quiet window" in out.prompt_text def test_tz_shown_when_not_utc(self): out = self.agent.compute(self._inp_at(10, tz="Europe/Moscow")) assert "Europe/Moscow" in out.prompt_text def test_snapshot_includes_peak_and_quiet(self): out = self.agent.compute(self._inp_at(10, peak_hours=[10], quiet_start="22:00", quiet_end="07:00")) assert "peak_hours" in out.signals_snapshot assert "in_quiet" in out.signals_snapshot assert "in_peak" in out.signals_snapshot def test_version_bumped(self): assert TOD_MANIFEST.version == "1.2.0" def test_manifest_has_new_params(self): keys = {p.key for p in TOD_MANIFEST.inferred_params} assert {"quiet_start", "quiet_end", "peak_hours", "tz"}.issubset(keys) # ── focus-area: preferred_areas wiring ─────────────────────────────────────── class TestFocusAreaPreferredAreas: agent = FocusAreaAgent() def _task(self, content: str, project_id: str, is_overdue: bool = False) -> dict: return {"id": "t1", "content": content, "is_overdue": is_overdue, "task_age_days": 2.0, "priority": 1, "project_id": project_id} def test_preferred_area_wins_tie(self): tasks = [ self._task("Work thing", "work"), self._task("Home thing", "home"), ] out = self.agent.compute(_inp(tasks=tasks, agent_prefs={"preferred_areas": ["work"]})) assert "work" in out.prompt_text assert "matches the user's stated focus preferences" in out.prompt_text def test_no_preferred_areas_uses_congestion_score(self): tasks = [ self._task("W1", "work"), self._task("H1", "home"), self._task("H2", "home"), ] out = self.agent.compute(_inp(tasks=tasks)) # home has more tasks → wins without any preference assert "home" in out.prompt_text def test_snapshot_includes_preferred_areas(self): tasks = [self._task("T", "work")] out = self.agent.compute(_inp(tasks=tasks, agent_prefs={"preferred_areas": ["work"]})) assert out.signals_snapshot["preferred_areas"] == ["work"] def test_version_bumped(self): from ml.agents.focus_area import MANIFEST as FA_MANIFEST assert FA_MANIFEST.version == "2.0.0" def test_snapshot_uses_cluster_keys(self): tasks = [self._task("T", "work")] out = self.agent.compute(_inp(tasks=tasks)) assert "top_cluster_label" in out.signals_snapshot assert "cluster_count" in out.signals_snapshot assert "strategy" in out.signals_snapshot # ── focus-area: preferred_areas inference from task_completions (#113) ──────── class TestFocusAreaPreferredAreasInference: from ml.agents.focus_area import MANIFEST as _FA_MANIFEST def _completion(self, project_id: str) -> TaskCompletion: return _completion(project_id, lateness_days=0.0) def test_cold_start_no_completions(self): history = _history(completions=[]) from ml.agents.focus_area import MANIFEST as FA_MANIFEST result = run_inference(FA_MANIFEST, history) assert result["preferred_areas"] == [] def test_top_two_projects_returned(self): completions = ( [_completion("p1", 0)] * 8 + [_completion("p2", 0)] * 5 + [_completion("p3", 0)] * 2 ) history = _history(completions=completions) from ml.agents.focus_area import MANIFEST as FA_MANIFEST result = run_inference(FA_MANIFEST, history) assert result["preferred_areas"] == ["p1", "p2"] def test_single_project_returns_one(self): completions = [_completion("work", 0)] * 6 history = _history(completions=completions) from ml.agents.focus_area import MANIFEST as FA_MANIFEST result = run_inference(FA_MANIFEST, history) assert result["preferred_areas"] == ["work"] def test_none_project_id_ignored(self): completions = [_completion(None, 0)] * 5 + [_completion("real", 0)] * 3 history = _history(completions=completions) from ml.agents.focus_area import MANIFEST as FA_MANIFEST result = run_inference(FA_MANIFEST, history) assert result["preferred_areas"] == ["real"]