Skip to content

Sleep Guardian

The Sleep Guardian is a predictive sleep risk system that protects training capacity by scoring nightly risk, forecasting multi-day outcomes, and learning from prediction accuracy. It builds on the sleep gate -- a hard threshold that blocks high-intensity training when sleep is insufficient -- and layers prediction, proactive alerting, and self-tuning on top.

System Architecture

                         Garmin Sync (daily)
                               |
                               v
                    +---------------------+
                    | data/garmin/*.json   |
                    | (sleep, hrv, stress) |
                    +---------------------+
                         |           |
              +----------+           +----------+
              |                                 |
              v                                 v
    +------------------+              +------------------+
    |   Sleep Gate     |              |   Risk Scoring   |
    |  (hard block)    |              |  (6 scorers)     |
    +------------------+              +------------------+
              |                           |          |
              v                           v          v
    Training suspended            7:30pm Alert   Risk Log
    if avg < 6.5h                (Telegram)    (risk-log.jsonl)
                                                     |
                                      +--------------+
                                      |              |
                                      v              v
                              +------------+  +-------------+
                              | Predictor  |  |  Backfill   |
                              | (2-3 day   |  |  (outcomes  |
                              |  forecast) |  |   .jsonl)   |
                              +------------+  +-------------+
                                                     |
                                                     v
                                             +-------------+
                                             | Learning     |
                                             | Loop (nightly|
                                             | weight adj.) |
                                             +-------------+
                                                     |
                                                     v
                                             +-------------+
                                             | guardian_    |
                                             | weights.json |
                                             +-------------+
                                                     |
                                        (weights feed back
                                         into risk scoring)

Sleep Gate

Location: scripts/scheduling/sleep_gate.py

The sleep gate is the foundation. Per Board Resolution RES-mtg-c9207983-agenda-1, high-intensity training is suspended when the 7-day sleep average falls below 6.5 hours.

How It Works

  1. Reads the last 7 days of Garmin sleep data from data/garmin/YYYY-MM-DD.json
  2. Applies any self-report correction from data/sleep/overrides.jsonl
  3. Calculates the rolling average of effective sleep.duration_hours
  4. Delegates the threshold decision to the declarative gate framework (scripts/executive/gates.py:is_action_blocked("training.<component>")); the hardcoded SLEEP_THRESHOLD = 6.5 in sleep_gate.py is now only a fallback if the framework call raises
  5. Returns a SleepGateResult with blocking status and message

Note: The sleep gate is one instance of a generalized declarative gate system. See Declarative Gate Framework below β€” the canonical threshold lives in data/executive/equilibrium-manifest.json, not in sleep_gate.py.

Gate Check Points

The gate is checked: - At the start of /checkin sessions (the checkin skill calls check_sleep_gate("max_effort_interval") and reports the status at the top) - When bio-zack mentions training, intervals, or high-intensity workouts - When logging activities via /log

from scripts.scheduling.sleep_gate import check_sleep_gate
result = check_sleep_gate("max_effort_interval")
# result.is_blocked β†’ True if avg < 6.5h
# result.avg_sleep_hours β†’ current 7-day average
# result.days_with_data β†’ how many of the 7 days have data
# result.message β†’ human-readable status

Design Decisions

  • Blocks high-intensity components: The gate applies to max_effort_interval, bike_intervals, intervals, hiit, sprints, ftp, and max_effort. Zone 2 cardio, strength, and other components are not gated. The resolution specifically targets high-intensity work that requires recovery capacity.
  • No data = no block: If Garmin data is unavailable, the gate does not block (warns instead). Erring on the side of allowing training when data is missing.
  • Hard threshold, no gradient: The gate is binary at 6.5h. The gradient behavior lives in the risk scoring layer above.

Self-report overrides: the night outranks the instrument

A Garmin row is an instrument reading, not the night itself. If bio-Zack says the watch fell off or otherwise supplies a correction, Vita appends it to data/sleep/overrides.jsonl through scripts/scheduling/sleep_overrides.py. Raw Garmin files are never edited.

The ledger is append-only and last-wins per (date, field). An override with a duration replaces the recorded duration. An override with null marks the night invalid without a substitute; consumers exclude it and shrink the window instead of treating it as zero.

The same effective value is used by the hard gate, risk scorer, and Health Center sleep page. When a duration is corrected because the recording was broken, recording-derived stages, score, and bed/wake clocks are suppressed; separately measured HRV, stress, and resting heart rate remain available.

uv run python -m scripts.scheduling.sleep_overrides add 2026-07-04 7.5 \
  --garmin 5.05 --source self-report --evidence "watch fell off"

uv run python -m scripts.scheduling.sleep_overrides add 2026-07-04 invalid
uv run python -m scripts.scheduling.sleep_overrides list

Declarative Gate Framework

Location: scripts/executive/gates.py (engine), data/executive/equilibrium-manifest.json (definitions)

The sleep gate was generalized into a declarative gate system as part of Homeostatic Equilibrium v2. Gates are evaluated deterministically β€” no LLM calls, no network. The manifest is the single source of truth; the engine evaluates condition operators (<, <=, >, >=, ==) against metrics resolved from live data.

The canonical sleep-gate definition:

{
  "id": "sleep-hard-gate",
  "type": "hard",
  "label": "Sleep Recovery Gate",
  "source_dimension": "sleep",
  "condition": { "metric": "avg_7d", "operator": "<", "value": 6.5 },
  "blocks": ["training.max_effort_interval"],
  "message": "High-intensity training suspended β€” 7-day sleep avg below 6.5h",
  "authority": "RES-mtg-c9207983-agenda-1"
}

Gate types in the framework: hard (blocks actions), soft (advisory), scope (reduces capacity), tempo (reduces alert frequency), upgrade (blocks adding new items). The sleep gate is the only hard gate that blocks training.

from scripts.executive.gates import is_action_blocked
blocked, gate_result = is_action_blocked("training.max_effort_interval")
# blocked β†’ True if any active hard gate lists this action in its blocks[]
# gate_result β†’ GateResult(gate_id, gate_type, is_active, message, ...)

Gate state transitions (activated/deactivated) are appended to data/executive/gate_events.jsonl via log_gate_transitions(), so the gate's history is auditable independently of the risk log. The manifest is mtime-cached in the engine, so edits take effect on next read without restart.

Risk Scoring

Location: scripts/scheduling/sleep_risk.py

The risk scoring engine evaluates tonight's sleep risk across 6 factors, producing a score from 0-100 and a traffic-light level (green/yellow/red).

The 6 Scorers

Scorer What It Detects Max Points Triggers
gate_proximity How close to tripping the 6.5h gate 30 Gate active (30), critical margin <0.3h (25), thin margin <0.5h (15), moderate <1.0h (5)
trend Sleep duration trajectory over 3-5 nights 20 Declining trend (20), persistently low + flat (12)
hrv HRV relative to personal baseline 20 Crashed <75% of baseline (20), below baseline <85% (12), declining over 3 nights (10)
stress Garmin stress average for the day 15 High stress >=50 (15), elevated >=40 (8)
architecture REM and deep sleep quality 12 Both REM <1h and deep <1h (12), either one (8)
recent_bad_night Last night's duration 15 Very poor <5h (15), short <5.5h (8)

An optional travel scorer adds 20 points when travel context is provided (either manually or detected from scratch files).

Risk Levels

Level Score Range Label Alert Behavior
Green 0-30 Low risk No telegram alert
Yellow 31-60 Moderate risk Alert sent at 7:30pm
Red 61-100 High risk Alert sent at 7:30pm

Learned Weights

Raw scorer points are multiplied by learned weights from data/sleep/guardian_weights.json before summing. This means the system upweights scorers that correctly predict poor sleep and downweights those that cry wolf.

Weights drift nightly as the learning loop runs β€” read data/sleep/guardian_weights.json for live values rather than trusting any snapshot. Weights are bounded to [0.3, 3.0] and normalized to sum to 6.0. As a representative shape (these change over time):

  • trend and recent_bad_night tend to carry the most weight β€” multi-night declining trend and last night's poor sleep are the strongest predictors of tonight's outcome.
  • hrv tends to sit near the floor (limited data, low discrimination).
  • gate_proximity can hit the 0.3 floor when it fires constantly during gate periods and stops discriminating.
  • stress and architecture move in either direction as observations accumulate; the loop has reweighted both up and down across cycles.

The interpretation is whatever the loop has learned this week, not a fixed editorial judgment β€” inspect via /sleep-guardian-tune.

Assessment Flow

from scripts.scheduling.sleep_risk import assess_risk, format_risk_report, log_assessment

# Basic assessment
result = assess_risk()

# With travel context
result = assess_risk(travel_context="<destination> trip <dates>")

# result.score β†’ 0-100
# result.level β†’ "green", "yellow", "red"
# result.factors β†’ list of RiskFactor(name, severity, points, detail)
# result.recommendation β†’ actionable text
# result.avg_7day β†’ current 7-day average
# result.gate_margin β†’ hours above/below 6.5h threshold

# Log to risk-log.jsonl for outcome tracking
log_assessment(result)

Prediction System

Location: scripts/scheduling/sleep_predictor.py

The predictor forecasts sleep risk 2-3 days ahead using day-of-week patterns, rolling window simulation, HRV/stress momentum, and travel detection.

Forecasting Signals

  1. Day-of-week patterns: Learns average sleep duration per weekday from 30 days of history. Some nights are consistently worse (e.g., weekend nights with social events).

  2. Rolling window simulation: As each forecast day advances, the oldest real day exits the 7-day window and the predicted duration enters. This models how the gate average will evolve.

  3. HRV momentum: Multi-day HRV trajectory (linear slope over 3-5 days) rather than point-in-time. Directions: recovering, stable_up, stable_down, declining.

  4. Stress momentum: Same trajectory analysis for stress levels. Directions: falling, stable, slightly_rising, rising.

  5. Travel detection: Scans data/scratch/daily.md and data/scratch/weekly.md for travel keywords. Applies historical penalty (-1.5h average effect).

  6. Gate clearance prediction: Simulates up to 14 days forward to estimate when the gate will flip (clear if active, trip if clear). Accounts for which low-sleep days are rolling off the window.

Forecast Output

from scripts.scheduling.sleep_predictor import generate_forecast, format_forecast_report

forecast = generate_forecast(days_ahead=3)

# forecast.daily β†’ list of DailyForecast per night
#   .date, .day_name, .expected_hours, .expected_range (low, high)
#   .risk_level, .risk_score, .confidence ("high"/"medium"/"low")
#   .factors, .gate_avg_projected

# forecast.gate β†’ GateForecast
#   .currently_active, .current_avg, .margin
#   .days_to_change, .change_date, .confidence, .explanation

# forecast.trend_momentum β†’ "accelerating_up", "improving", "flat",
#                            "declining", "accelerating_down"
# forecast.headline β†’ one-line summary
# forecast.recommendations β†’ list of actionable strings

print(format_forecast_report(forecast))

Confidence Degradation

Forecast confidence decreases with distance: - Night 1: High (if 3+ data points for that weekday), otherwise Medium - Night 2: Medium - Night 3+: Low

Learning Loop

Location: scripts/scheduling/sleep_guardian_learn.py

The learning loop runs nightly after Garmin sync (triggered by the scheduler's rollup loop at 00:05 PT). It closes the feedback loop between predictions and actual outcomes.

Three-Step Process

  1. Backfill outcomes: For each risk assessment in risk-log.jsonl that lacks a matching outcome, check if Garmin data exists for the following night. If so, record the actual sleep hours, score, deep/REM hours, and HRV in outcomes.jsonl.

  2. Analyze accuracy: Over a 14-day rolling window, calculate per-scorer stats:

  3. Hit rate: When this scorer fired, how often was sleep actually poor/fair?
  4. Miss rate: When this scorer didn't fire, how often was sleep poor anyway?
  5. Lift: Hit rate minus base rate (how much better than random)
  6. Discrimination: Hit rate minus miss rate (ability to differentiate good/bad nights)

  7. Adjust weights: Using multiplicative updates with a 0.5 learning rate:

  8. Positive discrimination --> upweight (scorer correctly differentiates)
  9. Negative discrimination --> downweight (scorer is noise)
  10. Weights bounded to [0.3, 3.0] and normalized to sum to 6.0
  11. Minimum 5 observations required before any adjustment
  12. Low-confidence scorers (< 2 firings) are left unchanged

Outcome Classification

Actual sleep quality is classified as:

Quality Criteria
Poor Duration < 5.5h OR Garmin score < 55
Fair Duration < 6.5h OR Garmin score < 70
Good Duration >= 6.5h AND score >= 70

A prediction is "correct" if: - Yellow/red predicted AND actual was poor/fair, OR - Green predicted AND actual was good

Intervention Tracking

The system tracks known interventions to measure their effectiveness:

from scripts.scheduling.sleep_guardian_learn import record_intervention, analyze_interventions

# record_intervention is the live capture point wired into the /checkin
# flow (.claude/skills/checkin/SKILL.md) β€” when bio-zack reports what he did
# the night before, it attaches the list to that date's outcome entry.
record_intervention("YYYY-MM-DD", ["intervention_a", "intervention_b"])

# The analysis side (~line 465) consumes recorded interventions:
stats = analyze_interventions()
# For each known intervention, computes avg sleep quality on nights it was
# taken vs not taken (requires >=3 nights with intervention data). Lets the
# system surface which interventions actually correlate with better sleep.

Known interventions are a generic list of sleep-hygiene actions (e.g. supplements, screen/caffeine/alcohol cutoffs, earlier bedtime, wind-down routine, breathwork, cooler room) defined as KNOWN_INTERVENTIONS in sleep_guardian_learn.py.

Morning Retrospective

The learning module provides a morning retrospective, called live from the /checkin flow (.claude/skills/checkin/SKILL.md) right after the sleep-gate status line:

from scripts.scheduling.sleep_guardian_learn import get_morning_retrospective

retro = get_morning_retrospective()
# retro["predicted_level"], retro["actual_hours"], retro["prediction_correct"]
# retro["summary"] β†’ "Last night: predicted <level> (<score>/100) β†’ actual <Nh> (<quality>) [correct]"

Historical Backfill

Location: scripts/scheduling/sleep_guardian_backfill.py

One-time script to bootstrap the learning loop with retroactive assessments from existing Garmin data. For each historical date:

  1. Loads 10 days of preceding data (simulating what was available at assessment time)
  2. Runs risk scoring with default weights (no learned weights for historical)
  3. Records the assessment and matches it to the actual next-night outcome
# Dry run (preview)
uv run python scripts/scheduling/sleep_guardian_backfill.py --dry-run

# Execute backfill
uv run python scripts/scheduling/sleep_guardian_backfill.py

Skips dates that already exist in the risk log. Requires at least 7 days of Garmin history before the first assessment (needs lookback context).

User Configuration

Location: data/sleep/guardian_config.json

Sensitivity Presets

Three presets control risk thresholds:

Preset Green Max Yellow Max Use Case
Low 45 75 Fewer alerts, only high-severity signals
Medium (default) 30 60 Balanced -- standard thresholds
High 20 45 Catch subtle signals early, high-stakes periods
from scripts.scheduling.sleep_guardian_learn import set_sensitivity
set_sensitivity("high")   # more alerts during critical periods
set_sensitivity("medium") # back to defaults

Weight Overrides

Manual multipliers on top of learned weights:

from scripts.scheduling.sleep_guardian_learn import set_weight_override, clear_weight_overrides

set_weight_override("architecture", 0.5)  # halve (it's noisy)
set_weight_override("stress", 1.5)        # boost (strong signal for you)
clear_weight_overrides()                   # back to learned-only

Multiplier range: 0.1 to 5.0. Setting to 1.0 removes the override.

Disable Scorers

Turn off a scorer entirely:

from scripts.scheduling.sleep_guardian_learn import toggle_scorer
toggle_scorer("architecture", enabled=False)  # skip this scorer
toggle_scorer("architecture", enabled=True)   # re-enable

Proactive Alerts

7:30 PM Sleep Risk Assessment

Scheduled daily via scripts/proactive/triggers.py. The builder at scripts/proactive/builders.py:build_sleep_gate_warning() runs assess_risk(), logs the assessment, and sends a Telegram alert if risk is yellow or red. Green assessments are logged but produce no alert.

9:45 PM Wind-Down Reminder

Per the Board Resolution, a wind-down alert fires at 9:45 PM PT with a 10:30 PM bedtime target. Simple static message: wrap up screens, devices outside bedroom, dim lights.

Independent Monitoring (Sentinel)

Separate from the Guardian's own pipeline, the Sentinel runs lightweight detector scripts that watch sleep-gate state and data arrival:

Check What it does
scripts/sentinel/checks/sleep_gate_status.py Prints "blocked (X.Xh avg)" / "clear (X.Xh avg)" from check_sleep_gate; Sentinel hashes the output and fires on a status transition or significant average change
scripts/sentinel/checks/garmin_sleep_landed.py Detects when fresh Garmin sleep data (score or duration) has landed for today and enqueues a boot-sequence orient message (once per day)
scripts/sentinel/checks/sleep_cycle_check.py Unrelated to the gate β€” verifies the nightly /sleep self-improvement cycle has actually run recently

Scheduler Integration

The learning loop runs as part of the nightly rollup (00:05 PT) in api/src/scheduler.py:

# In VitaScheduler._rollup_loop()
learn_result = await asyncio.to_thread(self._run_guardian_learning)
# Logs: f"Guardian learning: {learn_result['new_outcomes_recorded']} outcomes, "
#       f"weights_adjusted={learn_result['weights_adjusted']}"
# (_run_guardian_learning delegates to
#  api/src/services/scheduler_maintenance.py:run_guardian_learning)

CLI Commands

Command Description
/sleep-risk Run risk assessment, display report, log result
/sleep-guardian-tune Show weights, accuracy, outcomes, trends, config

Direct Script Execution

# Risk assessment
uv run python scripts/scheduling/sleep_risk.py
uv run python scripts/scheduling/sleep_risk.py --travel "trip to <city>"
uv run python scripts/scheduling/sleep_risk.py --json --log

# Forecast
uv run python scripts/scheduling/sleep_predictor.py
uv run python scripts/scheduling/sleep_predictor.py --days 5 --json
uv run python scripts/scheduling/sleep_predictor.py --telegram
uv run python scripts/scheduling/sleep_predictor.py --save

# Learning loop
uv run python scripts/scheduling/sleep_guardian_learn.py --run
uv run python scripts/scheduling/sleep_guardian_learn.py --tune
uv run python scripts/scheduling/sleep_guardian_learn.py --trend --days 14
uv run python scripts/scheduling/sleep_guardian_learn.py --config
uv run python scripts/scheduling/sleep_guardian_learn.py --analyze
uv run python scripts/scheduling/sleep_guardian_learn.py --adjust --dry-run
uv run python scripts/scheduling/sleep_guardian_learn.py --sensitivity high

# Backfill
uv run python scripts/scheduling/sleep_guardian_backfill.py
uv run python scripts/scheduling/sleep_guardian_backfill.py --dry-run

# Gate check
uv run python -c "from scripts.scheduling.sleep_gate import check_sleep_gate; print(check_sleep_gate('max_effort_interval'))"

Data Structures

File Locations

File Purpose
scripts/scheduling/sleep_gate.py Hard gate check (delegates to gate framework; 6.5h fallback)
scripts/scheduling/sleep_overrides.py Append/read self-report corrections without mutating raw Garmin data
data/sleep/overrides.jsonl Append-only, last-wins correction ledger
scripts/executive/gates.py Declarative gate engine (is_action_blocked, transition logging)
data/executive/equilibrium-manifest.json Canonical gate definitions (incl. sleep-hard-gate)
data/executive/gate_events.jsonl Gate activation/deactivation audit log
scripts/scheduling/sleep_risk.py Risk scoring engine (6 scorers)
scripts/scheduling/sleep_predictor.py Multi-day forecasting
scripts/scheduling/sleep_guardian_learn.py Learning loop, weight tuning, config
scripts/scheduling/sleep_guardian_backfill.py Historical backfill script
scripts/proactive/triggers.py Schedules 7:30pm and 9:45pm alerts
scripts/proactive/builders.py Builds alert messages
data/sleep/risk-model.json Baseline model parameters and learned patterns
data/sleep/risk-log.jsonl Every risk assessment (input to learning loop)
data/sleep/outcomes.jsonl Predicted vs actual outcomes
data/sleep/guardian_weights.json Learned scorer weights + history
data/sleep/guardian_config.json User config (sensitivity, overrides, disabled scorers)
data/sleep/predictor_state.json Latest saved forecast state
data/garmin/YYYY-MM-DD.json Source sleep/HRV/stress data
.claude/commands/sleep-risk.md /sleep-risk command definition
.claude/commands/sleep-guardian-tune.md /sleep-guardian-tune command definition
.claude/skills/checkin/SKILL.md Live wiring of gate check, retrospective, and intervention capture
scripts/sentinel/checks/sleep_gate_status.py Sentinel detector for gate transitions
scripts/sentinel/checks/garmin_sleep_landed.py Sentinel detector for fresh sleep-data arrival
api/src/services/scheduler_maintenance.py run_guardian_learning (nightly rollup hook)

risk-model.json

Static model parameters including personal baselines, risk factor weights with observation counts and confidence levels, risk thresholds, and learned patterns (e.g. post-travel recovery penalties, weekend variance).

risk-log.jsonl

One entry per assessment. Fields: date, assessed_at, score, level, avg_7day, gate_margin, trend, factor_count, factors (list of factor names). Backfilled entries include backfilled: true.

outcomes.jsonl

Matches assessments to actual sleep. Fields: assessment_date, predicted_score, predicted_level, predicted_factors, actual_sleep_hours, actual_sleep_score, actual_deep_hours, actual_rem_hours, actual_hrv_ms, actual_quality (poor/fair/good), prediction_correct (bool). Optionally includes interventions list.

guardian_weights.json

Live weights drift nightly; the structure (not the values) is stable:

{
  "schema_version": 1,
  "updated_at": "<ISO timestamp of last adjustment>",
  "weights": {
    "gate_proximity": 0.3,
    "trend": 2.5,
    "hrv": 0.1,
    "stress": 0.7,
    "architecture": 0.3,
    "recent_bad_night": 2.0
  },
  "history": [
    {
      "date": "YYYY-MM-DD",
      "old": { "...": "..." },
      "new": { "...": "..." },
      "n_observations": 10,
      "overall_accuracy": 0.7
    }
  ],
  "stats": {
    "last_analysis": { "...": "..." },
    "last_adjusted": "YYYY-MM-DD"
  }
}

Note: The weights shown are illustrative round numbers, not live readings. Each history entry records the before/after weights for one nightly adjustment, the observation count over the rolling window, and the overall prediction accuracy at that time.

guardian_config.json

{
  "schema_version": 1,
  "sensitivity": "medium",
  "sensitivity_presets": {
    "low": { "risk_thresholds": { "green": { "max_score": 45 }, "yellow": { "max_score": 75 } } },
    "medium": { "risk_thresholds": { "green": { "max_score": 30 }, "yellow": { "max_score": 60 } } },
    "high": { "risk_thresholds": { "green": { "max_score": 20 }, "yellow": { "max_score": 45 } } }
  },
  "weight_overrides": {},
  "disabled_scorers": []
}

Development Phases

The Sleep Guardian was built incrementally:

Phase Feature Status
1 Sleep gate (hard 6.5h threshold) Shipped
2 Risk scoring engine (6 scorers, risk-log) Shipped
3 Learning loop (outcome backfill, weight tuning) Shipped
4 User configurability (sensitivity, overrides, disable scorers) Shipped
5 Predictive forecasting (multi-day, gate clearance, DOW patterns) Shipped

Proposal document: plans/proposals/2025-12-31-2030-predictive-sleep-guardian.md

Where to Go Next

  • Garmin β€” the source of nightly sleep/HRV/stress data the Guardian reads
  • Sentinel β€” independent detectors that watch gate state and data arrival
  • Checkin β€” the daily flow that reports gate status and captures interventions
  • Proactive β€” the queue/trigger machinery behind the 7:30pm and 9:45pm alerts
  • Executive β€” the Homeostatic Equilibrium engine that the declarative gate framework belongs to