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
- Reads the last 7 days of Garmin sleep data from
data/garmin/YYYY-MM-DD.json - Applies any self-report correction from
data/sleep/overrides.jsonl - Calculates the rolling average of effective
sleep.duration_hours - Delegates the threshold decision to the declarative gate framework (
scripts/executive/gates.py:is_action_blocked("training.<component>")); the hardcodedSLEEP_THRESHOLD = 6.5insleep_gate.pyis now only a fallback if the framework call raises - Returns a
SleepGateResultwith 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 insleep_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, andmax_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):
trendandrecent_bad_nighttend to carry the most weight β multi-night declining trend and last night's poor sleep are the strongest predictors of tonight's outcome.hrvtends to sit near the floor (limited data, low discrimination).gate_proximitycan hit the0.3floor when it fires constantly during gate periods and stops discriminating.stressandarchitecturemove 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
-
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).
-
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.
-
HRV momentum: Multi-day HRV trajectory (linear slope over 3-5 days) rather than point-in-time. Directions:
recovering,stable_up,stable_down,declining. -
Stress momentum: Same trajectory analysis for stress levels. Directions:
falling,stable,slightly_rising,rising. -
Travel detection: Scans
data/scratch/daily.mdanddata/scratch/weekly.mdfor travel keywords. Applies historical penalty (-1.5h average effect). -
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
-
Backfill outcomes: For each risk assessment in
risk-log.jsonlthat 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 inoutcomes.jsonl. -
Analyze accuracy: Over a 14-day rolling window, calculate per-scorer stats:
- Hit rate: When this scorer fired, how often was sleep actually poor/fair?
- Miss rate: When this scorer didn't fire, how often was sleep poor anyway?
- Lift: Hit rate minus base rate (how much better than random)
-
Discrimination: Hit rate minus miss rate (ability to differentiate good/bad nights)
-
Adjust weights: Using multiplicative updates with a 0.5 learning rate:
- Positive discrimination --> upweight (scorer correctly differentiates)
- Negative discrimination --> downweight (scorer is noise)
- Weights bounded to [0.3, 3.0] and normalized to sum to 6.0
- Minimum 5 observations required before any adjustment
- 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:
- Loads 10 days of preceding data (simulating what was available at assessment time)
- Runs risk scoring with default weights (no learned weights for historical)
- 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
weightsshown are illustrative round numbers, not live readings. Eachhistoryentry 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