diff --git a/db/migrations/074_sleep_architecture.sql b/db/migrations/074_sleep_architecture.sql new file mode 100644 index 0000000..03ec18c --- /dev/null +++ b/db/migrations/074_sleep_architecture.sql @@ -0,0 +1,78 @@ +-- Migration 074: sleep architecture — Phase 1 schema +-- +-- Operationalizes Avenue 1 from research/autonomous-research-avenues-2026-05-20.md: +-- "Sleep architecture as a first-class state machine." Biology +-- partitions sleep into NREM 1/2/3 + REM, each with qualitatively +-- different memory operations. brainctl's dream_cycle + DMN treat +-- sleep as one undifferentiated state. +-- +-- Phase 1 ships: +-- sleep_cycle_state — current cycle + stage + entry time +-- sleep_cycle_transitions — log of stage transitions with cause +-- sleep_stage_catalog — per-stage description + permitted operations +-- +-- The catalog encodes what each stage *can* do (NREM2 = spindles + +-- declarative consolidation; NREM3/SWS = sharp-wave ripples + replay; +-- REM = procedural / emotional consolidation + bisociation). +-- +-- Phase 1 is inspection + manual writes. Phase 2 auto-progresses +-- through ultradian cycles when ARAS sleep_wake_mode = nrem/rem_sleep. +-- Phase 3 stage-gates consolidation operations. +-- +-- Rollback: +-- DROP TABLE IF EXISTS sleep_cycle_transitions; +-- DROP TABLE IF EXISTS sleep_cycle_state; +-- DROP TABLE IF EXISTS sleep_stage_catalog; +-- DELETE FROM schema_version WHERE version = 74; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS sleep_stage_catalog ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stage TEXT NOT NULL UNIQUE CHECK(stage IN ('nrem1', 'nrem2', 'nrem3_sws', 'rem', 'awake')), + typical_duration_seconds INTEGER NOT NULL DEFAULT 600, + description TEXT, + permitted_operations TEXT, -- comma-separated tags (e.g. 'spindle_consolidation,replay,bisociation') + arousal_floor REAL NOT NULL DEFAULT 0.0, + arousal_ceiling REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); + +CREATE TABLE IF NOT EXISTS sleep_cycle_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_stage TEXT NOT NULL DEFAULT 'awake' CHECK(current_stage IN ('nrem1', 'nrem2', 'nrem3_sws', 'rem', 'awake')), + cycle_number INTEGER NOT NULL DEFAULT 0, -- ultradian cycle count (each ~90 min in biology) + stage_entered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + cycle_started_at TEXT, + total_sleep_seconds INTEGER NOT NULL DEFAULT 0, + total_rem_seconds INTEGER NOT NULL DEFAULT 0, + total_sws_seconds INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO sleep_cycle_state (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS sleep_cycle_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transitioned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + from_stage TEXT NOT NULL, + to_stage TEXT NOT NULL, + cycle_number INTEGER NOT NULL, + duration_in_from_stage_seconds INTEGER, + reason TEXT, + triggered_by TEXT -- 'manual' | 'aras_signal' | 'duration_elapsed' | 'consolidation_complete' +); +CREATE INDEX IF NOT EXISTS idx_sct_recent ON sleep_cycle_transitions(transitioned_at); +CREATE INDEX IF NOT EXISTS idx_sct_to_stage ON sleep_cycle_transitions(to_stage, transitioned_at); +CREATE INDEX IF NOT EXISTS idx_sct_cycle ON sleep_cycle_transitions(cycle_number); + +INSERT OR IGNORE INTO sleep_stage_catalog (stage, typical_duration_seconds, description, permitted_operations, arousal_floor, arousal_ceiling) VALUES + ('awake', 0, 'normal operating state — full retrieval and writes enabled', 'all', 0.30, 1.0), + ('nrem1', 300, 'sleep onset — light, easily aroused; no canonical memory op', 'idle_decay', 0.15, 0.40), + ('nrem2', 1500, 'spindle stage — sleep spindles + slow oscillations; declarative consolidation', 'spindle_consolidation,semantic_promotion', 0.10, 0.30), + ('nrem3_sws', 1200, 'slow-wave sleep — sharp-wave ripples; hippocampus→neocortex replay', 'swr_replay,episodic_to_semantic,memory_promote', 0.05, 0.20), + ('rem', 900, 'REM — procedural + emotional consolidation; bisociative recombination; dreaming', 'procedural_consolidation,bisociation,dmn_simulate,reconsolidate', 0.20, 0.50); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (74, 'sleep architecture Phase 1: 3 tables (catalog + state + transitions) with 5 seeded stages', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..765027a 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -70,6 +70,7 @@ mcp_tools_reconcile, mcp_tools_reflexion, mcp_tools_scheduler, + mcp_tools_sleep_architecture, mcp_tools_telemetry, mcp_tools_temporal, mcp_tools_temporal_abstraction, @@ -113,6 +114,7 @@ mcp_tools_reconcile, mcp_tools_reflexion, mcp_tools_scheduler, + mcp_tools_sleep_architecture, mcp_tools_telemetry, mcp_tools_temporal, mcp_tools_temporal_abstraction, diff --git a/src/agentmemory/mcp_tools_sleep_architecture.py b/src/agentmemory/mcp_tools_sleep_architecture.py new file mode 100644 index 0000000..9d76a64 --- /dev/null +++ b/src/agentmemory/mcp_tools_sleep_architecture.py @@ -0,0 +1,337 @@ +"""brainctl MCP tools — sleep architecture state machine. + +Phase 1 per research/autonomous-research-avenues-2026-05-20.md Avenue 1. +Codifies the 5 sleep stages (awake / NREM1 / NREM2 / NREM3-SWS / REM) +as a first-class state machine. Phase 1 = inspection + manual stage +transitions; Phase 2 will auto-progress through ultradian cycles when +ARAS sleep_wake_mode flips; Phase 3 will stage-gate consolidation ops. +""" +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from mcp.types import Tool + +from agentmemory.lib.mcp_helpers import open_db +from agentmemory.paths import get_db_path + +DB_PATH: Path = get_db_path() + +VALID_STAGES = {"awake", "nrem1", "nrem2", "nrem3_sws", "rem"} + +# Canonical NREM1 → NREM2 → NREM3 → NREM2 → REM ultradian cycle. +# Returning to awake from any stage is always permitted. +_CANONICAL_NEXT_STAGE = { + "awake": "nrem1", + "nrem1": "nrem2", + "nrem2": "nrem3_sws", + "nrem3_sws": "rem", + "rem": "nrem2", # back into NREM2 starts the next ultradian cycle +} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _rows(rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(r) for r in rows] + + +def _require_schema(conn: sqlite3.Connection) -> str | None: + for t in ("sleep_stage_catalog", "sleep_cycle_state", "sleep_cycle_transitions"): + if not conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,) + ).fetchone(): + return (f"sleep architecture schema missing: {t} not found. " + "Run `brainctl migrate` (migration 074).") + return None + + +def tool_sleep_status(**_kw: Any) -> dict[str, Any]: + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM sleep_cycle_state WHERE id = 1").fetchone() + catalog = _rows(conn.execute( + "SELECT * FROM sleep_stage_catalog ORDER BY id" + ).fetchall()) + last_transitions = _rows(conn.execute( + "SELECT * FROM sleep_cycle_transitions ORDER BY id DESC LIMIT 5" + ).fetchall()) + # Elapsed in current stage + elapsed_row = conn.execute( + "SELECT (julianday('now') * 86400 - julianday(?) * 86400) AS elapsed_s", + (state["stage_entered_at"],), + ).fetchone() + elapsed = float(elapsed_row[0]) if elapsed_row and elapsed_row[0] is not None else 0.0 + current_stage_meta = next( + (c for c in catalog if c["stage"] == state["current_stage"]), None, + ) + return { + "ok": True, + "state": dict(state) if state else None, + "stage_catalog": catalog, + "last_5_transitions": last_transitions, + "current_stage_elapsed_seconds": elapsed, + "current_stage_meta": current_stage_meta, + } + + +def tool_sleep_transition( + to_stage: str, + reason: str | None = None, + triggered_by: str = "manual", + agent_id: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Move to a specific sleep stage. Records the transition + updates + cycle bookkeeping. Setting to_stage='awake' from any stage is + always permitted. From 'rem' to 'nrem2' increments cycle_number.""" + if to_stage not in VALID_STAGES: + return {"error": f"invalid to_stage {to_stage!r}; expected one of {sorted(VALID_STAGES)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM sleep_cycle_state WHERE id = 1").fetchone() + if not state: + return {"error": "sleep_cycle_state seed row missing"} + from_stage = state["current_stage"] + if from_stage == to_stage: + return {"ok": True, "no_op": True, "current_stage": to_stage} + # Compute duration in from_stage + elapsed_row = conn.execute( + "SELECT (julianday('now') * 86400 - julianday(?) * 86400) AS elapsed_s", + (state["stage_entered_at"],), + ).fetchone() + dur = int(elapsed_row[0]) if elapsed_row and elapsed_row[0] is not None else 0 + cycle_number = int(state["cycle_number"]) + cycle_started_at = state["cycle_started_at"] + # Cycle bookkeeping + if from_stage == "awake" and to_stage == "nrem1": + # Starting a new sleep period + cycle_number = max(1, cycle_number + 1) if state["cycle_started_at"] is None else cycle_number + 1 + cycle_started_at = "datetime('now')" + elif from_stage == "rem" and to_stage == "nrem2": + # Completing an ultradian cycle, starting the next + cycle_number += 1 + # Insert transition row + cur = conn.execute( + """ + INSERT INTO sleep_cycle_transitions + (agent_id, from_stage, to_stage, cycle_number, + duration_in_from_stage_seconds, reason, triggered_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (agent_id, from_stage, to_stage, cycle_number, dur, reason, triggered_by), + ) + transition_id = cur.lastrowid + # Bookkeeping: total_*_seconds + total_sleep_inc = dur if from_stage != "awake" else 0 + total_rem_inc = dur if from_stage == "rem" else 0 + total_sws_inc = dur if from_stage == "nrem3_sws" else 0 + if cycle_started_at == "datetime('now')": + conn.execute( + """ + UPDATE sleep_cycle_state SET + current_stage = ?, cycle_number = ?, + stage_entered_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + cycle_started_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + total_sleep_seconds = total_sleep_seconds + ?, + total_rem_seconds = total_rem_seconds + ?, + total_sws_seconds = total_sws_seconds + ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (to_stage, cycle_number, total_sleep_inc, total_rem_inc, total_sws_inc), + ) + else: + conn.execute( + """ + UPDATE sleep_cycle_state SET + current_stage = ?, cycle_number = ?, + stage_entered_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + total_sleep_seconds = total_sleep_seconds + ?, + total_rem_seconds = total_rem_seconds + ?, + total_sws_seconds = total_sws_seconds + ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (to_stage, cycle_number, total_sleep_inc, total_rem_inc, total_sws_inc), + ) + conn.commit() + return { + "ok": True, "transition_id": transition_id, + "from_stage": from_stage, "to_stage": to_stage, + "cycle_number": cycle_number, + "duration_in_from_stage_seconds": dur, + } + + +def tool_sleep_advance(reason: str | None = None, agent_id: str | None = None, + **_kw: Any) -> dict[str, Any]: + """Advance one step along the canonical ultradian cycle. + awake → nrem1 → nrem2 → nrem3_sws → rem → nrem2 → ...""" + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM sleep_cycle_state WHERE id = 1").fetchone() + if not state: + return {"error": "sleep_cycle_state seed row missing"} + next_stage = _CANONICAL_NEXT_STAGE.get(state["current_stage"]) + if not next_stage: + return {"error": f"no canonical next stage from {state['current_stage']!r}"} + return tool_sleep_transition( + to_stage=next_stage, + reason=reason or "canonical_advance", + triggered_by="manual", + agent_id=agent_id, + ) + + +def tool_sleep_operation_permitted(operation: str, **_kw: Any) -> dict[str, Any]: + """Check whether `operation` is permitted in the current sleep stage. + + Looks up the current stage's permitted_operations CSV and matches + case-insensitively. Returns {permitted: bool, current_stage, + permitted_operations}. + """ + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT current_stage FROM sleep_cycle_state WHERE id = 1").fetchone() + if not state: + return {"error": "sleep_cycle_state seed row missing"} + catalog_row = conn.execute( + "SELECT permitted_operations FROM sleep_stage_catalog WHERE stage = ?", + (state["current_stage"],), + ).fetchone() + ops_csv = (catalog_row["permitted_operations"] or "").lower() if catalog_row else "" + ops = {o.strip() for o in ops_csv.split(",") if o.strip()} + permitted = "all" in ops or operation.lower() in ops + return { + "ok": True, "operation": operation, "current_stage": state["current_stage"], + "permitted": permitted, "permitted_operations": sorted(ops), + } + + +def tool_sleep_history(limit: int = 50, since: str | None = None, + to_stage: str | None = None, **_kw: Any) -> dict[str, Any]: + limit = max(1, min(int(limit), 500)) + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + clauses, params = [], [] + if since: + clauses.append("transitioned_at >= ?"); params.append(since) + if to_stage: + if to_stage not in VALID_STAGES: + return {"error": f"invalid to_stage {to_stage!r}"} + clauses.append("to_stage = ?"); params.append(to_stage) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + rows = conn.execute( + f"SELECT * FROM sleep_cycle_transitions {where} ORDER BY id DESC LIMIT ?", + (*params, limit), + ).fetchall() + return {"ok": True, "transitions": _rows(rows)} + + +TOOLS: list[Tool] = [ + Tool( + name="sleep_status", + description=( + "Sleep architecture inspection. Current stage + cycle_number + elapsed in " + "stage + last 5 transitions + full catalog of permitted operations per stage." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="sleep_transition", + description=( + "Move to a specific sleep stage. to_stage ∈ {awake, nrem1, nrem2, nrem3_sws, " + "rem}. Updates cycle bookkeeping (entering nrem1 from awake = new cycle; " + "rem → nrem2 = next ultradian cycle)." + ), + inputSchema={ + "type": "object", + "properties": { + "to_stage": {"type": "string", "enum": sorted(VALID_STAGES)}, + "reason": {"type": "string"}, + "triggered_by": {"type": "string", "default": "manual"}, + "agent_id": {"type": "string"}, + }, + "required": ["to_stage"], + }, + ), + Tool( + name="sleep_advance", + description=( + "Advance one step along the canonical ultradian cycle: " + "awake → nrem1 → nrem2 → nrem3_sws → rem → nrem2 → ... Convenience wrapper " + "around sleep_transition." + ), + inputSchema={ + "type": "object", + "properties": { + "reason": {"type": "string"}, + "agent_id": {"type": "string"}, + }, + }, + ), + Tool( + name="sleep_operation_permitted", + description=( + "Check whether a named operation is permitted in the current sleep stage. " + "Looks up the catalog's permitted_operations CSV. 'all' permits everything. " + "Used by consolidation/dream/replay code to stage-gate their work." + ), + inputSchema={ + "type": "object", + "properties": {"operation": {"type": "string"}}, + "required": ["operation"], + }, + ), + Tool( + name="sleep_history", + description="Paginated stage-transition history. Filters: since, to_stage. limit clamped to [1, 500].", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 50}, + "since": {"type": "string"}, + "to_stage": {"type": "string", "enum": sorted(VALID_STAGES)}, + }, + }, + ), +] + + +_SLEEP_TOOLS = { + "sleep_status": tool_sleep_status, + "sleep_transition": tool_sleep_transition, + "sleep_advance": tool_sleep_advance, + "sleep_operation_permitted": tool_sleep_operation_permitted, + "sleep_history": tool_sleep_history, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _SLEEP_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_sleep_architecture.py b/tests/test_mcp_tools_sleep_architecture.py new file mode 100644 index 0000000..c9f5942 --- /dev/null +++ b/tests/test_mcp_tools_sleep_architecture.py @@ -0,0 +1,136 @@ +"""Tests for mcp_tools_sleep_architecture — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_074 = REPO_ROOT / "db" / "migrations" / "074_sleep_architecture.sql" + + +def _bootstrap(conn): + conn.executescript( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT);" + ) + + +def _apply(db_path): + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap(conn) + conn.executescript(MIGRATION_074.read_text()) + conn.commit() + finally: + conn.close() + + +def _make_db(tmp_path, monkeypatch): + db = tmp_path / "brain.db" + _apply(db) + from agentmemory import mcp_tools_sleep_architecture as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_seeds_5_stages(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + stages = [r[0] for r in conn.execute( + "SELECT stage FROM sleep_stage_catalog ORDER BY id" + ).fetchall()] + assert stages == ["awake", "nrem1", "nrem2", "nrem3_sws", "rem"] + state = conn.execute( + "SELECT current_stage, cycle_number FROM sleep_cycle_state" + ).fetchone() + assert state == ("awake", 0) + finally: + conn.close() + + +def test_status_returns_state_and_catalog(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_sleep_status() + assert out["ok"] is True + assert out["state"]["current_stage"] == "awake" + assert len(out["stage_catalog"]) == 5 + assert out["current_stage_meta"]["stage"] == "awake" + + +def test_transition_moves_stage_and_records(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_sleep_transition(to_stage="nrem1", reason="user opened the eyelids") + assert out["ok"] is True + assert out["from_stage"] == "awake" + assert out["to_stage"] == "nrem1" + assert out["cycle_number"] >= 1 # entering sleep starts cycle 1 + status = mod.tool_sleep_status() + assert status["state"]["current_stage"] == "nrem1" + + +def test_transition_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_sleep_transition(to_stage="not-a-stage") + assert "error" in out + + +def test_transition_noop_on_same_stage(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_sleep_transition(to_stage="awake") + assert out.get("no_op") is True + + +def test_advance_walks_the_canonical_cycle(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + expected = ["nrem1", "nrem2", "nrem3_sws", "rem", "nrem2"] + for i, expected_stage in enumerate(expected): + out = mod.tool_sleep_advance(reason=f"step-{i}") + assert out["ok"] is True + assert out["to_stage"] == expected_stage + + +def test_advance_increments_cycle_at_rem_to_nrem2(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Walk through: awake → nrem1 (cycle 1) → nrem2 → nrem3_sws → rem → nrem2 (cycle 2) + mod.tool_sleep_advance() # nrem1 + cycle_after_nrem1 = mod.tool_sleep_status()["state"]["cycle_number"] + mod.tool_sleep_advance() # nrem2 + mod.tool_sleep_advance() # nrem3_sws + mod.tool_sleep_advance() # rem + mod.tool_sleep_advance() # nrem2 again (cycle++) + cycle_after_rem_to_nrem2 = mod.tool_sleep_status()["state"]["cycle_number"] + assert cycle_after_rem_to_nrem2 == cycle_after_nrem1 + 1 + + +def test_operation_permitted_when_in_stage(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # awake permits 'all' + out = mod.tool_sleep_operation_permitted(operation="memory_search") + assert out["permitted"] is True + # transition to nrem3_sws which permits swr_replay but not bisociation + mod.tool_sleep_transition(to_stage="nrem3_sws") + sws_replay = mod.tool_sleep_operation_permitted(operation="swr_replay") + assert sws_replay["permitted"] is True + sws_bisoc = mod.tool_sleep_operation_permitted(operation="bisociation") + assert sws_bisoc["permitted"] is False + + +def test_operation_permitted_in_rem(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_sleep_transition(to_stage="rem") + rem_bisoc = mod.tool_sleep_operation_permitted(operation="bisociation") + assert rem_bisoc["permitted"] is True + rem_replay = mod.tool_sleep_operation_permitted(operation="swr_replay") + assert rem_replay["permitted"] is False # SWS-specific op + + +def test_history_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_sleep_transition(to_stage="nrem1") + mod.tool_sleep_transition(to_stage="nrem2") + mod.tool_sleep_transition(to_stage="rem") + all_h = mod.tool_sleep_history(limit=10) + assert len(all_h["transitions"]) == 3 + rem_only = mod.tool_sleep_history(limit=10, to_stage="rem") + assert len(rem_only["transitions"]) == 1