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 <noreply@anthropic.com>
121 lines
4.8 KiB
Python
121 lines
4.8 KiB
Python
"""Tests for the inference framework and time-of-day #112 proof."""
|
|
from __future__ import annotations
|
|
|
|
import sys, os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
|
|
from ml.agents.inference.history import FeedbackEvent, UserHistory
|
|
from ml.agents.inference.framework import run_inference
|
|
from ml.agents.time_of_day import TimeOfDayAgent, MANIFEST as TOD_MANIFEST, MANIFEST
|
|
from ml.agents.base import AgentInput
|
|
|
|
|
|
_NOW = datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc) # Thursday 14:00
|
|
|
|
|
|
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, hour: int) -> FeedbackEvent:
|
|
ts = f"2026-05-01T{hour:02d}:00:00+00:00"
|
|
return FeedbackEvent(action=action, dwell_ms=60_000 if action == "done" else 500, created_at=ts)
|
|
|
|
|
|
class TestRunInference:
|
|
def test_cold_start_when_below_min_history(self):
|
|
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5) # only 5 < 10
|
|
result = run_inference(TOD_MANIFEST, history)
|
|
assert result["preferred_hour"] is None # cold_start_default
|
|
|
|
def test_infers_preferred_hour_as_mode(self):
|
|
# 7 events at 09:00, 3 at 17:00 → preferred_hour should be 9
|
|
events = [_event("done", 9)] * 7 + [_event("done", 17)] * 3
|
|
history = UserHistory(user_id="u1", events=events)
|
|
result = run_inference(TOD_MANIFEST, history)
|
|
assert result["preferred_hour"] == 9
|
|
|
|
def test_infers_preferred_hour_from_majority_hour(self):
|
|
events = [_event("done", 20)] * 6 + [_event("done", 8)] * 4
|
|
history = UserHistory(user_id="u1", events=events)
|
|
result = run_inference(TOD_MANIFEST, history)
|
|
assert result["preferred_hour"] == 20
|
|
|
|
def test_no_inferred_params_returns_empty(self):
|
|
from ml.agents.manifest import AgentManifest
|
|
bare = AgentManifest(
|
|
id="bare", version="1.0.0", description="", pref_schema={},
|
|
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
|
)
|
|
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 20)
|
|
result = run_inference(bare, history)
|
|
assert result == {}
|
|
|
|
def test_cold_start_fallback_on_infer_error(self):
|
|
"""infer() raising should fall back to cold_start_default, not crash."""
|
|
from ml.agents.manifest import InferredParam, AgentManifest
|
|
|
|
def _bad_infer(h):
|
|
raise RuntimeError("oops")
|
|
|
|
m = AgentManifest(
|
|
id="boom", version="1.0.0", description="", pref_schema={},
|
|
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
|
inferred_params=[InferredParam(key="x", ttl_sec=60, cold_start_default=42, min_history=1, infer=_bad_infer)],
|
|
)
|
|
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5)
|
|
result = run_inference(m, history)
|
|
assert result["x"] == 42
|
|
|
|
|
|
class TestTimeOfDayAgentWithInference:
|
|
agent = TimeOfDayAgent()
|
|
|
|
def test_uses_preferred_hour_from_agent_prefs(self):
|
|
inp = _inp(agent_prefs={"preferred_hour": 9}, now=datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc))
|
|
out = self.agent.compute(inp)
|
|
assert "peak productivity hour" in out.prompt_text.lower() or "peak" in out.prompt_text
|
|
|
|
def test_quiet_window_noon_suppressed(self):
|
|
inp = _inp(
|
|
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
|
now=datetime(2026, 5, 1, 23, 0, 0, tzinfo=timezone.utc),
|
|
)
|
|
out = self.agent.compute(inp)
|
|
assert "quiet window" in out.prompt_text
|
|
|
|
def test_quiet_window_not_in_window(self):
|
|
inp = _inp(
|
|
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
|
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
|
)
|
|
out = self.agent.compute(inp)
|
|
assert "quiet window" not in out.prompt_text
|
|
|
|
def test_agent_prefs_override_profile(self):
|
|
# agent_prefs.preferred_hour wins over profile.preferred_hour
|
|
inp = _inp(
|
|
profile={"preferred_hour": 8},
|
|
agent_prefs={"preferred_hour": 14},
|
|
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
|
)
|
|
out = self.agent.compute(inp)
|
|
assert "peak productivity hour (14:00)" in out.prompt_text
|
|
|
|
def test_no_prefs_falls_back_to_profile(self):
|
|
inp = _inp(profile={"preferred_hour": 10}, now=datetime(2026, 5, 1, 10, 0, 0, tzinfo=timezone.utc))
|
|
out = self.agent.compute(inp)
|
|
assert "peak" in out.prompt_text
|
|
|
|
def test_version_bumped(self):
|
|
assert MANIFEST.version == "1.2.0"
|
|
|
|
def test_manifest_has_preferred_hour_param(self):
|
|
keys = {p.key for p in MANIFEST.inferred_params}
|
|
assert "preferred_hour" in keys
|