From 1d9a3955919ffc52a0b5db5861c0c1674c7b64ed Mon Sep 17 00:00:00 2001 From: alvis Date: Wed, 6 May 2026 06:05:51 +0000 Subject: [PATCH] feat(agents): quiet window + peak hours + tz prefs for time-of-day agent (#112) Adds four InferredParams (all TTL=24h, min_history=50 except preferred_hour=10): - quiet_start / quiet_end: longest contiguous below-baseline hour run (HH:MM) - peak_hours: top-quartile done-event hours, sorted ascending - tz: cold-start only ("UTC"); populated from auth provider, no inference function compute() updated: - in_quiet check (quiet window) takes precedence over peak hours - in_peak emits "peak productivity hour" language when current hour is in peak_hours - approaching peak (within 2h) surfaces for orchestrator timing - tz surfaced in snippet header when not UTC - snapshot adds peak_hours, in_quiet, in_peak, tz - Agent bumped to v1.2.0 - 21 new tests: night-owl, early-bird, shift-worker, quiet/peak snippet rendering - Fixed test_snapshot_keys in test_agents.py to include new snapshot fields Closes #112 Co-Authored-By: Claude Sonnet 4.6 --- ml/agents/tests/test_agents.py | 3 +- ml/agents/tests/test_inference.py | 2 +- ml/agents/tests/test_per_agent_inference.py | 147 ++++++++++++++++++- ml/agents/time_of_day.py | 149 +++++++++++++++++++- 4 files changed, 291 insertions(+), 10 deletions(-) diff --git a/ml/agents/tests/test_agents.py b/ml/agents/tests/test_agents.py index d7283d7..509c0f8 100644 --- a/ml/agents/tests/test_agents.py +++ b/ml/agents/tests/test_agents.py @@ -153,7 +153,8 @@ class TestTimeOfDayAgent: def test_snapshot_keys(self): out = self.agent.compute(_inp()) - assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end"} == set(out.signals_snapshot) + assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end", + "peak_hours", "in_quiet", "in_peak", "tz"} == set(out.signals_snapshot) # ── RecentPatternsAgent ─────────────────────────────────────────────────────── diff --git a/ml/agents/tests/test_inference.py b/ml/agents/tests/test_inference.py index 320f707..3a2727c 100644 --- a/ml/agents/tests/test_inference.py +++ b/ml/agents/tests/test_inference.py @@ -113,7 +113,7 @@ class TestTimeOfDayAgentWithInference: assert "peak" in out.prompt_text def test_version_bumped(self): - assert MANIFEST.version == "1.1.0" + assert MANIFEST.version == "1.2.0" def test_manifest_has_preferred_hour_param(self): keys = {p.key for p in MANIFEST.inferred_params} diff --git a/ml/agents/tests/test_per_agent_inference.py b/ml/agents/tests/test_per_agent_inference.py index ab3c2a9..66643a9 100644 --- a/ml/agents/tests/test_per_agent_inference.py +++ b/ml/agents/tests/test_per_agent_inference.py @@ -1,5 +1,5 @@ """Per-agent inference tests: momentum (#114), overdue-task (#115), recent-patterns (#116), -and focus-area (#113) preferred_areas wiring.""" +time-of-day (#112), and focus-area (#113) preferred_areas wiring.""" from __future__ import annotations import sys, os @@ -13,6 +13,7 @@ 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 @@ -482,6 +483,150 @@ class TestRecentPatternsDailyCycle: 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: diff --git a/ml/agents/time_of_day.py b/ml/agents/time_of_day.py index cdb69fa..6a29402 100644 --- a/ml/agents/time_of_day.py +++ b/ml/agents/time_of_day.py @@ -1,5 +1,6 @@ from __future__ import annotations +import statistics from collections import Counter from typing import ClassVar @@ -9,6 +10,9 @@ from .manifest import AgentManifest, InferredParam _DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] +# min_history required before quiet/peak inference is meaningful (issue #112) +_MIN_HISTORY = 50 + def _infer_preferred_hour(history: UserHistory) -> int: """Mode hour of day across all 'done' feedback events; falls back to 9.""" @@ -18,9 +22,75 @@ def _infer_preferred_hour(history: UserHistory) -> int: return Counter(done_hours).most_common(1)[0][0] +def _quiet_window_hours(history: UserHistory) -> tuple[int, int]: + """Return (start_hour, end_hour) of the longest below-baseline quiet window. + + Counts all engagement events by hour. Baseline = mean hourly count. + Finds the longest contiguous run of below-baseline hours on the circular + clock; that run defines the quiet window. + """ + by_hour: Counter[int] = Counter(e.hour for e in history.events) + total = sum(by_hour.values()) + baseline = total / 24 + + # Mark each of the 24 hours as below-baseline (True = quiet) + quiet: list[bool] = [by_hour.get(h, 0) < baseline for h in range(24)] + + # Find longest contiguous run in circular array + best_start, best_len = 0, 0 + run_start, run_len = 0, 0 + # Double the sequence to handle wrap-around + for i in range(48): + h = i % 24 + if quiet[h]: + if run_len == 0: + run_start = i + run_len += 1 + if run_len > best_len: + best_len = run_len + best_start = run_start + else: + run_len = 0 + + if best_len == 0: + return (22, 7) # fallback + + start = best_start % 24 + end = (best_start + best_len) % 24 + return (start, end) + + +def _infer_quiet_start(history: UserHistory) -> str: + start, _ = _quiet_window_hours(history) + return f"{start:02d}:00" + + +def _infer_quiet_end(history: UserHistory) -> str: + _, end = _quiet_window_hours(history) + return f"{end:02d}:00" + + +def _infer_peak_hours(history: UserHistory) -> list[int]: + """Top-quartile hours by done-event count. + + Computes done_count per hour, then returns hours above the 75th percentile + of non-zero hourly counts, sorted ascending. + """ + done_by_hour: Counter[int] = Counter( + e.hour for e in history.events if e.action == "done" + ) + if not done_by_hour: + return [9, 14, 20] + + counts = list(done_by_hour.values()) + threshold = statistics.quantiles(counts, n=4)[-1] # 75th percentile + + return sorted(h for h, c in done_by_hour.items() if c >= threshold) + + MANIFEST = AgentManifest( id="time-of-day", - version="1.1.0", # bumped: inferred_params added (ADR-0014 §3, #112) + version="1.2.0", # #112: quiet_start/end + peak_hours + tz inference description="Frames the current moment relative to the user's productive peak and quiet hours.", pref_schema={ "type": "object", @@ -36,6 +106,23 @@ MANIFEST = AgentManifest( "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$", "description": "HH:MM end of quiet hours.", }, + "peak_hours": { + "type": "array", + "items": {"type": "integer", "minimum": 0, "maximum": 23}, + "default": [9, 14, 20], + "description": "Hours (0–23) with top-quartile completion density.", + }, + "tz": { + "type": "string", + "default": "UTC", + "description": "IANA timezone; populated from auth provider, fallback UTC.", + }, + "preferred_hour": { + "type": "integer", + "minimum": 0, + "maximum": 23, + "description": "Mode done-hour (legacy; superseded by peak_hours).", + }, }, }, context_schema=["profile.features"], @@ -45,11 +132,40 @@ MANIFEST = AgentManifest( inferred_params=[ InferredParam( key="preferred_hour", - ttl_sec=3_600, # recompute hourly + ttl_sec=3_600, cold_start_default=None, - min_history=10, # need at least 10 feedback events to be meaningful + min_history=10, infer=_infer_preferred_hour, ), + InferredParam( + key="quiet_start", + ttl_sec=86_400, + cold_start_default="22:00", + min_history=_MIN_HISTORY, + infer=_infer_quiet_start, + ), + InferredParam( + key="quiet_end", + ttl_sec=86_400, + cold_start_default="07:00", + min_history=_MIN_HISTORY, + infer=_infer_quiet_end, + ), + InferredParam( + key="peak_hours", + ttl_sec=86_400, + cold_start_default=[9, 14, 20], + min_history=_MIN_HISTORY, + infer=_infer_peak_hours, + ), + # tz is populated from the auth provider; no infer function. + InferredParam( + key="tz", + ttl_sec=86_400, + cold_start_default="UTC", + min_history=999_999, # effectively never inferred — always cold_start + infer=None, + ), ], ) @@ -62,18 +178,23 @@ class TimeOfDayAgent(BaseAgent): def compute(self, inp: AgentInput) -> AgentOutput: hour = inp.now.hour - dow = inp.now.weekday() # 0=Monday … 6=Sunday + dow = inp.now.weekday() is_weekend = dow >= 5 - # agent_prefs (inferred or user-set) take precedence over ML profile features. preferred_raw = inp.agent_prefs.get("preferred_hour", inp.profile.get("preferred_hour")) preferred = int(preferred_raw) if preferred_raw is not None else None quiet_start: str | None = inp.agent_prefs.get("quiet_start") quiet_end: str | None = inp.agent_prefs.get("quiet_end") + peak_hours: list[int] = inp.agent_prefs.get("peak_hours", []) + tz: str = inp.agent_prefs.get("tz", "UTC") + in_quiet = self._in_quiet_window(hour, quiet_start, quiet_end) + in_peak = hour in peak_hours parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."] + if tz != "UTC": + parts[0] = f"It is {hour:02d}:00 ({tz}) on {_DOW_NAMES[dow]} ({self._label(hour)})." if is_weekend: parts.append("Weekend context — prefer personal or reflective tips over work tasks.") @@ -83,8 +204,18 @@ class TimeOfDayAgent(BaseAgent): f"User is in their quiet window ({quiet_start}–{quiet_end}) — " "avoid urgent or demanding tips." ) - - if preferred is not None: + elif in_peak: + parts.append( + f"Hour {hour:02d}:00 is a peak productivity hour for this user — " + "a high-impact or challenging tip is appropriate." + ) + elif peak_hours: + # Report nearest peak so orchestrator can time advice accordingly. + nearest = min(peak_hours, key=lambda p: min(abs(p - hour), 24 - abs(p - hour))) + delta = min(abs(nearest - hour), 24 - abs(nearest - hour)) + if delta <= 2: + parts.append(f"Approaching peak productivity window ({nearest:02d}:00).") + elif preferred is not None: delta = min(abs(hour - preferred), 24 - abs(hour - preferred)) if delta == 0: parts.append( @@ -103,6 +234,10 @@ class TimeOfDayAgent(BaseAgent): "preferred_hour": preferred, "quiet_start": quiet_start, "quiet_end": quiet_end, + "peak_hours": peak_hours, + "in_quiet": in_quiet, + "in_peak": in_peak, + "tz": tz, } return self._make_output(inp, prompt, snapshot)