Repository navigation
Expand file tree
/
Copy pathsession_selector.py
More file actions
3349 lines (3051 loc) · 165 KB
/
Copy pathsession_selector.py
File metadata and controls
3349 lines (3051 loc) · 165 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Smart Session Selector — stimulus-based daily workout picker
Goal: grow fitness (CTL) while keeping form (TSB) in a productive zone.
No fixed rotation. Pure stimulus-based scoring.
Evidence base (Perplexity research 2026-04-07):
- TSB < -30: high overreaching/injury risk (research consensus)
- TSB -5 to -20: optimal beginner training zone
- Intensity model: at <6h/week, POLARIZED outperforms pyramidal (2026 research)
Target: 80% Z1, 5% Z2, 15% Z3 (80/5/15 split)
Z2 threshold is least efficient zone for low-volume runners
Prioritize Z1 volume + Z3 quality over Z2 threshold grinds
- Intervals need 2+ speed-work runs (avg HR >153 OR max >=168 w/ avg >145, 15-75min) in past 21 days (neuromuscular prep proxy; Strava summary can't detect in-run strides pickups)
- HRV red day (suppressed + falling trend) = no hard session
- Gym: heavy (3-6 reps 80-90% 1RM) > plyometrics for running economy
- Deload: every 3-5 weeks OR when TSB sustained < -30
Scoring logic:
score = base_score * overdue_factor * tsb_modifier
winner = highest valid score that clears all gates
Gates:
- readiness < 55 → only rest
- TSB below session minimum → blocked
- back-to-back hard days → hard blocked
- day after very_high load → all runs blocked
- > 2 hard sessions this week → hard blocked
- HRV falling trend + readiness < 70 → hard sessions blocked
- deload mode (TSB < -30) → hard sessions blocked, easy/gym only
- same-category gym < 3 days ago → blocked (connective tissue recovery)
- weekly gym cap: lower 2x, upper 1x → blocked when hit
"""
import json
import os
import subprocess
import sys
import statistics
from datetime import datetime, date, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import check_inputs
import rhr_baseline # single source of truth, shared with analytics.sh
import hrmax_baseline # single source of truth for max HR (12mo rolling, guarded)
def dump_json(obj) -> str:
"""Stable public JSON: sorted keys, 2-space indent, trailing newline."""
return json.dumps(obj, indent=2, sort_keys=True) + "\n"
def atomic_write_text(path: Path, content: str) -> None:
"""Atomic write via tmp + rename so concurrent readers never see partial writes."""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
REPO_ROOT = Path(__file__).resolve().parent
DATA_DIR = REPO_ROOT / "data"
MUSCLE_BASELINE = 50.0
Z2_HR_CEILING = 149
def athlete_config_path():
override = os.environ.get("SESSION_SELECTOR_ATHLETE")
if override:
return Path(override)
live = DATA_DIR / "athlete.json"
if live.exists():
return live
return REPO_ROOT / "athlete.example.json"
def apply_athlete_config(cfg=None):
"""Load athlete knobs from JSON. Example file is public; live copy stays local."""
global VOLUME_FLOOR_MIN, GOAL_PRIORITY, GOAL_PROFILE
global VOL_RAMP_CAP, VOL_RAMP_2WK_CAP, HRMAX, RHR_DEFAULT
global MUSCLE_BASELINE, Z2_HR_CEILING
if cfg is None:
cfg = json.loads(athlete_config_path().read_text())
priority = cfg.get("goal_priority", "speed")
if priority not in GOAL_PROFILES:
priority = "speed"
GOAL_PRIORITY = priority
GOAL_PROFILE = GOAL_PROFILES[GOAL_PRIORITY]
VOLUME_FLOOR_MIN = int(cfg.get("volume_floor_min", 150))
VOL_RAMP_CAP = float(cfg.get("vol_ramp_cap", 1.12))
VOL_RAMP_2WK_CAP = float(cfg.get("vol_ramp_2wk_cap", 1.30))
MUSCLE_BASELINE = float(cfg.get("muscle_baseline_kg", 50.0))
Z2_HR_CEILING = int(cfg.get("z2_hr_ceiling", 149))
hrmax_baseline.FALLBACK_HRMAX = int(cfg.get("fallback_hrmax", 190))
rhr_baseline.FALLBACK_RHR = int(cfg.get("fallback_rhr", 60))
HRMAX = hrmax_baseline.hrmax_baseline()
RHR_DEFAULT = rhr_baseline.rhr_baseline()
return cfg
def apply_runtime_env():
"""Honor SESSION_SELECTOR_DATA_DIR for tests and replay. Call at start of main()."""
global DATA_DIR
override = os.environ.get("SESSION_SELECTOR_DATA_DIR")
if override:
DATA_DIR = Path(override)
apply_athlete_config()
def selector_today():
"""Honor SESSION_SELECTOR_TODAY=YYYY-MM-DD so a week can be replayed."""
raw = os.environ.get("SESSION_SELECTOR_TODAY")
if raw:
return date.fromisoformat(raw)
return date.today()
def load_recent_activities():
"""Load recent activities without calling a private Strava helper.
Order:
1. SESSION_SELECTOR_RECENT_CMD — shell command that prints a JSON array
2. data/recent_activities.json
"""
cmd = os.environ.get("SESSION_SELECTOR_RECENT_CMD")
if cmd:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
try:
return json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else []
except json.JSONDecodeError:
return []
path = DATA_DIR / "recent_activities.json"
if path.exists():
try:
data = json.loads(path.read_text())
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
return []
VOLUME_FLOOR_MIN = 150 # aerobic minutes/week for VAT mobilization
# ── Goal priority: the ONE explicit place the concurrent-goal trade-off lives ──
# Athletes often juggle overlapping goals (fat loss, race speed, muscle retention). Previously the
# ACTIVE trade-off between them was implied only by which scattered magic constant
# happened to be larger (easy-aerobic +10 here, a 220 quality floor there). That made
# "what is the engine optimising for THIS block?" unanswerable without reading the
# whole file. This declares it once. The bonuses/floors below read GOAL_PROFILE
# instead of bare numbers, so flipping GOAL_PRIORITY re-weights the engine in one edit.
# Defaults reproduce prior behaviour (fat_loss): easy_aerobic_bonus 10 == the old +10.
GOAL_PRIORITY = "speed" # "fat_loss" | "speed" | "balanced"
GOAL_PROFILES = {
# easy_aerobic_bonus : pts added to easy_z2 when readiness/load allow (fat-ox volume)
"fat_loss": {"easy_aerobic_bonus": 10},
"speed": {"easy_aerobic_bonus": 5},
"balanced": {"easy_aerobic_bonus": 8},
}
GOAL_PROFILE = GOAL_PROFILES[GOAL_PRIORITY]
# ── Raw weekly-volume ramp caps (research lever #1, 2026-06-03) ──────────
# Distance jumps >30% over 2 weeks raised distance-related injury risk (HR≈1.6,
# PMID 25155475); BSI guidance endorses ≤10%/wk. Sit at the conservative end of the band when ground-reaction force per step is high.
VOL_RAMP_CAP = 1.12 # ≤12% week-over-week vs trailing 4-week average
VOL_RAMP_2WK_CAP = 1.30 # ≤30% over any 2-week window
# paces.json is rewritten daily (daily-prefill 8am + pre-briefing) from duckdb
# VDOT + actual Z2. Tolerate a 0-1d lag (selector may run before the 8am refresh)
# and one missed day; flag as a data-integrity issue at 3+ days old, which means
# the refresh pipeline is down and pace-led targets are no longer tracking data.
PACES_STALE_DAYS = 2 # stale when (today - updated) > this
# Karvonen Z2 HR targeting. Fallbacks come from athlete.example.json unless DuckDB is set.
HRMAX = hrmax_baseline.hrmax_baseline()
RHR_DEFAULT = rhr_baseline.rhr_baseline()
def karvonen_z2_target(tsb, rhr=None):
"""Return (target_bpm, ceiling_bpm, label) for Z2 sessions."""
if rhr is None:
rhr = RHR_DEFAULT
hrr = HRMAX - rhr
ceil = min(round(rhr + 0.70 * hrr), Z2_HR_CEILING)
if tsb < -30:
target = round(rhr + 0.61 * hrr) # deload: ~138
return target, ceil, f"target HR {target} bpm (deload)"
target = round(rhr + 0.64 * hrr) # normal: ~142
return target, ceil, f"target HR {target} bpm"
# ── TSB thresholds (research-calibrated 2026-04-07) ──────────
# Source: coaching literature + Perplexity research synthesis
# Beginner target zone: -5 to -20. Below -30 = high injury/overreach risk.
TSB_PEAK = 5 # peak freshness — push hard
TSB_GOOD = -10 # optimal training zone
TSB_MODERATE = -20 # getting tired — reduce intensity
TSB_FATIGUED = -30 # research danger threshold — easy/gym only
TSB_CRITICAL = -40 # overreaching territory — recovery mode
READY_GREEN = 85
READY_YELLOW = 70
READY_RED = 55
# ── HRV thresholds (RMSSD-based, research-calibrated 2026-04-12) ──
# Source: Perplexity research - HRV4Training methodology, Kubios validation
# Normal range = baseline mean +/- 0.5 SD over 14-30 day window
# Below normal range = downgrade hard to easy
# 24-48h dip after hard session is expected, don't react
HRV_BASELINE_DAYS = 30 # days for baseline calculation
HRV_ROLLING_DAYS = 7 # rolling average window
HRV_SD_BAND = 0.5 # +/- standard deviations for normal range
# ── Session catalogue ──────────────────────────────────────
# base_score : urgency weight for 10K goal improvement
# type : "hard" | "easy" | "gym" | "rest"
# min_tsb : minimum TSB to unlock this session
# min_readiness : minimum readiness to unlock
# ideal_freq : target days between sessions of this type
CATALOGUE = {
# Phase 1 speed work: strides must come before intervals
# 2-3 weeks of strides → then intervals unlock
"strides": {
"base_score": 72, "type": "moderate",
"min_tsb": -25, "min_readiness": 60, "ideal_freq": 4,
},
# VO2max session (unlocked after strides established). Protocol ALTERNATES in
# build_session: 5×3min @ ~vVO2max is the backbone, 4×4min Helgerud rotates in
# ~every 3rd week (research 2026-06-03: long 3-4min reps beat 400/800m repeats
# and 30/30s for VO2max gain + time≥90% VO2max, at lower injury/mental cost).
# One key (not 400/800 split) so the two protocols don't starve each other.
"intervals_vo2": {
"base_score": 90, "type": "hard",
"min_tsb": -15, "min_readiness": 75, "ideal_freq": 7,
},
# Threshold is the FALLBACK quality session, not a co-flagship (training-rules
# L8 2026-05-26d: weekly hard = VO2 intervals; tempo minimized during ADS,
# surfaced only when intervals are gated by readiness<75 / not strides_ready).
# base_score sits below long_run(75) so it can't out-compete the flagship slot
# on a normal day, but above easy(55) so it wins when intervals are blocked.
"threshold": {
"base_score": 62, "type": "hard",
"min_tsb": -25, "min_readiness": 65, "ideal_freq": 7,
},
"long_run": {
"base_score": 75, "type": "easy",
"min_tsb": -35, "min_readiness": 60, "ideal_freq": 7,
},
"easy_z2": {
"base_score": 55, "type": "easy",
"min_tsb": -999, "min_readiness": 55, "ideal_freq": 2,
},
"full_body_gym": {
"base_score": 44, "type": "gym",
"min_tsb": -999, "min_readiness": 0, "ideal_freq": 4,
},
"rest": {
"base_score": 0, "type": "rest",
"min_tsb": -999, "min_readiness": 0, "ideal_freq": 7,
},
}
def _nested(data, path, default=None):
cur = data
for key in path:
if not isinstance(cur, dict):
return default
cur = cur.get(key)
if cur is None:
return default
return cur
def _num(value, default=0.0):
try:
if value is None:
return default
return float(value)
except (TypeError, ValueError):
return default
_SLEEP_ORDER = {"green": 0, "yellow": 1, "red": 2}
def sleep_band(duration_h, score):
"""Worse-of sleep duration band vs Oura sleep-score band (doc §5).
Too little sleep is a direct problem regardless of score, so short duration
downgrades on its own; a poor score on adequate duration (fragmentation) also
downgrades. Return whichever signal is worse so neither is masked by the other.
"""
d = "green"
if duration_h is not None:
if duration_h < 6.0:
d = "red"
elif duration_h < 7.0:
d = "yellow"
s = "green"
if score:
if score < 65:
s = "red"
elif score < 80:
s = "yellow"
return d if _SLEEP_ORDER[d] >= _SLEEP_ORDER[s] else s
def update_vo2max_history(recovery_signals):
"""Upsert the latest Oura VO2max reading (keyed by its own measurement day)
into a small rolling history. Oura computes VO2max infrequently, so a *trend*
only becomes available once several readings accumulate — this banks them.
Returns the sorted [(day, vo2max), ...] history."""
path = DATA_DIR / "vo2max_history.json"
try:
hist = json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
hist = {}
day = _nested(recovery_signals, ["oura", "vo2_max", "day"])
val = _num(_nested(recovery_signals, ["oura", "vo2_max", "vo2_max"]), 0)
if day and val > 0:
hist[str(day)] = round(val, 1)
hist = dict(sorted(hist.items())[-365:])
try:
atomic_write_text(path, json.dumps(hist, indent=2))
except OSError:
pass
return sorted(hist.items())
def vo2max_status(history):
"""Trend from VO2max history. Needs >=2 readings spanning >=21 days, else
'insufficient' (mirrors the HRV cold-start fallback — no fake trend from a
single point). Returns {status, latest, delta}."""
if not history:
return {"status": "insufficient", "latest": None, "delta": None}
latest = history[-1][1]
if len(history) < 2:
return {"status": "insufficient", "latest": latest, "delta": None}
try:
first_day = datetime.strptime(history[0][0], "%Y-%m-%d").date()
last_day = datetime.strptime(history[-1][0], "%Y-%m-%d").date()
except (ValueError, TypeError, IndexError):
return {"status": "insufficient", "latest": latest, "delta": None}
if (last_day - first_day).days < 21:
return {"status": "insufficient", "latest": latest, "delta": None}
delta = round(latest - history[0][1], 1)
# +/-2.0 ml/kg/min band: Oura's VO2max estimate has test-retest noise wider
# than 1.0, so a 1-point move over 3 weeks is within error and would flip the
# label on noise. Require a 2-point move to call a real trend.
status = "improving" if delta >= 2.0 else ("declining" if delta <= -2.0 else "stable")
return {"status": status, "latest": latest, "delta": delta}
# ── Eight Sleep history + RR-based illness early-warning ───────────────────
# WHY: sleeping respiratory rate is the single best wearable illness pre-symptom
# signal — it rises ~1-2 breaths/min 1-2 nights BEFORE symptoms (Oura/8sleep
# COVID-era validation). The Pod measures it independently of the wrist (Oura),
# so it's a true second sensor. But an absolute RR is meaningless without a
# PERSONAL baseline (resting sleeping RR varies 12-20 between people), so we bank
# a rolling history and threshold against the individual median — never a flat cut.
EIGHT_SLEEP_RR_HISTORY_DAYS = 30 # baseline window
EIGHT_SLEEP_RR_MIN_NIGHTS = 7 # cold-start floor before any RR call fires
EIGHT_SLEEP_RR_ABS_DELTA = 1.5 # breaths/min over baseline = elevated (lit: 1-2)
EIGHT_SLEEP_RR_PCT_DELTA = 0.08 # AND >=8% over baseline (noise guard)
def update_eight_sleep_history(recovery_signals):
"""Bank last night's Eight Sleep RR + HRV (keyed by session date) into a
rolling history so a personal baseline can form. Returns sorted history list
[(day, {"rr":x,"hrv":y}), ...]."""
path = DATA_DIR / "eight_sleep_history.json"
try:
hist = json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
hist = {}
es = _nested(recovery_signals, ["eight_sleep"], {}) or {}
day = es.get("date")
rr = _num(es.get("respiratory_rate"), 0)
hrv = _num(es.get("hrv"), 0)
if day and (rr > 0 or hrv > 0):
hist[str(day)] = {"rr": round(rr, 1) if rr else None,
"hrv": round(hrv, 1) if hrv else None}
hist = dict(sorted(hist.items())[-365:])
try:
atomic_write_text(path, json.dumps(hist, indent=2))
except OSError:
pass
return sorted(hist.items())
def eight_sleep_rr_status(history):
"""Personal-baseline RR illness watch. Compares the latest night's sleeping
respiratory rate to the trailing median (excluding the latest). Fires
'elevated' only when BOTH an absolute (>=1.5 bpm) AND relative (>=8%) rise
clear — conservative, to keep a zero-noise illness signal. Returns
{status, latest, baseline, delta}."""
rr_series = [(d, v.get("rr")) for d, v in history if v.get("rr")]
if len(rr_series) < EIGHT_SLEEP_RR_MIN_NIGHTS:
return {"status": "insufficient", "latest": None, "baseline": None, "delta": None}
latest = rr_series[-1][1]
baseline_pool = [v for _d, v in rr_series[:-1][-EIGHT_SLEEP_RR_HISTORY_DAYS:]]
if not baseline_pool:
return {"status": "insufficient", "latest": latest, "baseline": None, "delta": None}
baseline = statistics.median(baseline_pool)
delta = round(latest - baseline, 1)
elevated = (delta >= EIGHT_SLEEP_RR_ABS_DELTA
and baseline > 0 and (delta / baseline) >= EIGHT_SLEEP_RR_PCT_DELTA)
return {"status": "elevated" if elevated else "normal",
"latest": latest, "baseline": round(baseline, 1), "delta": delta}
def compute_sleep_debt(oura_data, today=None):
"""Acute multi-night sleep deprivation — a recovery HARD-STOP distinct from the
chronic 14d-avg modifier (which is already in base readiness). Severe acute sleep
loss blunts adaptation and raises injury risk, so it blocks hard work outright.
Conservative thresholds so it won't nag on a single okay-ish night.
Indexed by CALENDAR night (the `day` field), not by list position: a night Oura
failed to sync is treated as UNKNOWN (a gap), never silently bridging two
non-adjacent bad nights into a false "2 consecutive" pair, and never dropped so a
bad night vanishes from the count. Stale data (newest night >1 day old) does not
assert an acute debt. Returns (is_debt, reason)."""
nightly = oura_data.get("nightly", []) or []
by_date = {}
for n in nightly:
if not isinstance(n, dict):
continue
h = _num(n.get("sleep_h"))
d = n.get("day") or n.get("date") or n.get("summary_date")
if h and d:
try:
by_date[datetime.strptime(str(d)[:10], "%Y-%m-%d").date()] = h
except ValueError:
continue
if not by_date:
return False, ""
last_date = max(by_date)
# Don't fire on stale data — if the newest night is >1 day old, the acute signal
# is unknowable (sync gap / device off), so stay silent rather than assert debt.
if today is not None and (today - last_date).days > 1:
return False, ""
# Build the last 4 CALENDAR nights ending at the newest synced night; None = gap.
nights = [(last_date - timedelta(days=i), by_date.get(last_date - timedelta(days=i)))
for i in range(3, -1, -1)]
last_h = by_date[last_date]
if last_h < 5.0:
return True, f"last night {last_h:.1f}h (<5h)"
# 2 consecutive ACTUAL (present + calendar-adjacent) nights <6h
for (da, ha), (db, hb) in zip(nights, nights[1:]):
if ha is not None and hb is not None and (db - da).days == 1 and ha < 6.0 and hb < 6.0:
return True, f"2 consecutive nights <6h ({ha:.1f}, {hb:.1f})"
# 3+ of the last 4 calendar nights <6h (present nights only)
if sum(1 for _d, h in nights if h is not None and h < 6.0) >= 3:
return True, "3+ of last 4 nights <6h"
return False, ""
def _category_of(key):
"""Map a session key to its weekly category (for adherence comparison)."""
if key in ("intervals_vo2", "threshold"):
return "quality"
if key in ("easy_z2", "strides"):
return "easy"
if key == "long_run":
return "long"
if key == "full_body_gym":
return "gym"
if key == "rest":
return "rest"
return key
def log_decision(today, winner, label, sess_type, confidence, metrics):
"""Append/replace today's recommendation in a rolling decision log (keyed by
date, ~180 days kept). This is the accumulating record that lets future
personal calibration compare what the selector recommended vs what actually
happened — without it, the system can never learn from its own track record."""
path = DATA_DIR / "decision_log.json"
try:
log = json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
log = {}
log[today.isoformat()] = {
"recommended": winner,
"category": _category_of(winner),
"label": label,
"type": sess_type,
"confidence": confidence,
**metrics,
}
log = dict(sorted(log.items())[-180:])
try:
atomic_write_text(path, json.dumps(log, indent=2))
except OSError:
pass
return log
def compute_adherence(log, recent_activities, today, last_analysis=None):
"""Compare past recommendations vs what actually happened (Strava). Returns
adherence rate over 14/30d windows + recent mismatches, so persistent overrides
surface as a signal that the rotation/gates may be mis-tuned."""
rank = {"quality": 4, "long": 3, "gym": 2, "easy": 1}
actual_by_date = {}
for a in recent_activities:
d = a.get("date")
cat = _category_of(classify(a)) if (d and classify(a)) else None
if cat is None:
continue
if d not in actual_by_date or rank.get(cat, 0) > rank.get(actual_by_date[d], 0):
actual_by_date[d] = cat
def window(days):
hit = tot = 0
misses = []
for d, rec in log.items():
try:
dd = datetime.strptime(d, "%Y-%m-%d").date()
except (ValueError, TypeError):
continue
if dd >= today or (today - dd).days > days:
continue
rec_cat = rec.get("category")
act_cat = actual_by_date.get(d, "rest") # no activity that day = rest taken
tot += 1
if rec_cat == act_cat:
hit += 1
else:
misses.append({"date": d, "recommended": rec_cat, "actual": act_cat})
return {"window_days": days, "n": tot, "adhered": hit,
"rate": round(hit / tot, 2) if tot else None, "misses": misses[-5:]}
out = {"last_14d": window(14), "last_30d": window(30)}
if last_analysis and last_analysis.get("verdict"):
out["last_run_verdict"] = {
"date": last_analysis.get("date"),
"verdict": last_analysis.get("verdict"),
"classified_zone": last_analysis.get("classified_zone"),
"pace_diff_sec": last_analysis.get("pace_diff_sec"),
}
return out
def compute_decision_models(ctx, readiness, tsb, ctl, atl, acwr, hrv_status,
avg_sleep, rhr_trend, injury, week_hard,
week_gym_count, days_since_gym, zones,
week_km, weekly_km_target, sleep_score=None,
vo2max=None, sleep_debt=(False, ""), hrv_trend=""):
"""Layered state model used by the workout scorer.
This keeps the daily selector from overreacting to one noisy metric while
still enforcing hard stops when multiple risk signals line up.
"""
temp_dev = _num(_nested(ctx, ["readiness", "latest", "temperature_deviation"]), 0.0)
resp_rate_high = bool(_nested(ctx, ["anomalies", "respiratory_rate_high"], False))
recovery_signals = ctx.get("recovery_signals", {}) if isinstance(ctx, dict) else {}
recovery_alerts = recovery_signals.get("alerts", []) if isinstance(recovery_signals, dict) else []
spo2_avg = _num(_nested(recovery_signals, ["oura", "spo2", "average"]), 0)
bdi = _num(_nested(recovery_signals, ["oura", "spo2", "breathing_disturbance_index"]), 0)
stress_high = _num(_nested(recovery_signals, ["oura", "stress", "stress_high_min"]), 0)
resilience_level = str(_nested(recovery_signals, ["oura", "resilience", "level"], "") or "").lower()
# Eight Sleep sleeping-RR illness early-warning (personal baseline; see
# eight_sleep_rr_status). Independent of the Oura wrist sensor → true second
# opinion. Elevated alone = caution (-6); elevated + corroborating temp/SpO2 = illness.
es_rr_status = str(_nested(recovery_signals, ["eight_sleep", "rr_status", "status"], "") or "")
es_rr_elevated = es_rr_status == "elevated"
es_rr_delta = _nested(recovery_signals, ["eight_sleep", "rr_status", "delta"])
renpho_water = _num(_nested(recovery_signals, ["renpho", "water"]), 0)
renpho_weight = _num(_nested(recovery_signals, ["renpho", "weight"]), 0)
weight_delta_1d = _num(_nested(recovery_signals, ["renpho", "weight_delta_1d"]), 0)
# Overnight weight drop matters as % of bodyweight, not an absolute kg figure
# (doc §7). A ~1% / ~1kg drop is a caution; only a large drop (>=2% bodyweight)
# WITH a corroborating dehydration sign — or an extreme >=2.5% drop — is a hard
# stop. This stops a single rehydratable overnight drop from killing quality work.
low_water = bool(renpho_water and renpho_water < 55)
weight_drop_pct = (abs(weight_delta_1d) / renpho_weight) if (renpho_weight and weight_delta_1d < 0) else 0.0
dehydration_signs = low_water or (rhr_trend == "RISING") or (avg_sleep is not None and avg_sleep < 6.5)
hydration_block = (weight_drop_pct >= 0.02 and dehydration_signs) or weight_drop_pct >= 0.025
hydration_caution = (not hydration_block) and (weight_drop_pct >= 0.012 or low_water)
hydration_risk = hydration_block # name kept for downstream hard-gate consumers
# Temperature: tier it (doc §temp) instead of a flat 0.3 full-running block.
# Small blips are noise; >=0.6 (or respiratory/SpO2 issues) is treated as illness.
temp_illness = temp_dev >= 0.6
temp_caution = 0.3 <= temp_dev < 0.6
# Elevated 8sleep RR is a hard illness signal ONLY when corroborated by another
# axis (temp blip or low SpO2); on its own it's a watch (handled below as caution).
es_rr_corroborated = es_rr_elevated and (temp_caution or temp_illness or (spo2_avg and spo2_avg < 95))
illness_flag = (temp_illness or resp_rate_high or (spo2_avg and spo2_avg < 95)
or bdi >= 15 or es_rr_corroborated)
readiness_score = readiness
readiness_reasons = [f"base readiness {readiness}"]
if hrv_status.get("status") == "suppressed":
readiness_score -= 10
readiness_reasons.append("HRV suppressed")
elif hrv_status.get("status") == "elevated":
readiness_score += 3
readiness_reasons.append("HRV elevated/robust")
if rhr_trend == "RISING":
readiness_score -= 7
readiness_reasons.append("RHR rising")
# Sleep is already a ~30% component of the base `readiness` input (and is baked
# into Oura's own readiness contribution), so it is NOT re-deducted here — doing
# so triple-counted sleep across the recovery axis (doc §4). Sleep still modulates
# *intensity* (how hard today) via the worse-of band in score_session, which is
# the doc-sanctioned use of a recovery metric. Surface the band as context only.
s_band = sleep_band(avg_sleep, sleep_score)
if s_band != "green":
# avg_sleep can be None (Oura key present with null value) — guard the :.1f
# format the same way the dehydration check above guards `avg_sleep is not None`.
sleep_str = f"{avg_sleep:.1f}h" if avg_sleep is not None else "n/a"
readiness_reasons.append(
f"sleep {sleep_str} / score {sleep_score if sleep_score else 'n/a'} "
f"({s_band}) — modulates intensity, not re-scored into readiness")
sleep_debt_flag, sleep_debt_reason = sleep_debt
if sleep_debt_flag:
readiness_score -= 8
readiness_reasons.append(f"acute sleep debt: {sleep_debt_reason}")
if illness_flag:
readiness_score = min(readiness_score, 55)
msg = "possible illness signal"
if es_rr_corroborated:
msg += f" (8sleep RR +{es_rr_delta}bpm + temp/SpO2 corroboration)"
readiness_reasons.append(msg)
elif temp_caution:
readiness_score -= 6
readiness_reasons.append(f"temp deviation {temp_dev:.2f}°C (illness watch)")
elif es_rr_elevated:
# Elevated sleeping RR alone (uncorroborated) — soft watch, not a block.
readiness_score -= 6
readiness_reasons.append(
f"8sleep sleeping RR +{es_rr_delta}bpm over baseline (illness watch)")
if stress_high >= 120:
readiness_score -= 6
readiness_reasons.append(f"Oura high stress {stress_high:.0f}min")
if resilience_level in ("limited", "low"):
readiness_score -= 6
readiness_reasons.append(f"Oura resilience {resilience_level}")
if low_water:
readiness_score -= 4
readiness_reasons.append(f"Renpho water {renpho_water:.1f}%")
if hydration_block:
readiness_score -= 8
readiness_reasons.append(
f"overnight -{abs(weight_delta_1d):.1f}kg ({weight_drop_pct*100:.1f}%) + signs — dehydration risk")
elif hydration_caution:
readiness_score -= 3
readiness_reasons.append(
f"overnight -{abs(weight_delta_1d):.1f}kg ({weight_drop_pct*100:.1f}%) — hydrate, not a stop")
readiness_score = max(0, min(100, round(readiness_score)))
if readiness_score >= 75:
readiness_color = "Green"
elif readiness_score >= 60:
readiness_color = "Yellow"
elif readiness_score >= 45:
readiness_color = "Orange"
else:
readiness_color = "Red"
# NOTE: readiness color is intentionally NOT downgraded for TSB/ACWR here.
# Load (TSB/ACWR/CTL-ramp) is owned by the load domain below; recovery owns
# readiness/HRV/sleep/RHR. Folding load into readiness too double-counted the
# same fatigue (doc §4). Acute-load safety is enforced by explicit load gates
# (Overreached + ACWR>1.5 interval/long block) in apply_layered_decision.
if tsb >= 0:
load_status, load_score = "Fresh", 80
elif tsb >= -10:
load_status, load_score = "Productive", 70
elif tsb >= -20:
load_status, load_score = "Caution", 55
else:
load_status, load_score = "Overreached", 35
load_reasons = [f"TSB {tsb:.1f}", f"CTL {ctl:.1f}", f"ATL {atl:.1f}"]
if acwr:
load_reasons.append(f"ACWR {acwr:.2f}")
if acwr > 1.8:
load_status, load_score = "Overreached", min(load_score, 25)
load_reasons.append("ACWR very high")
elif acwr > 1.5:
load_status, load_score = ("Caution" if load_status != "Overreached" else load_status), min(load_score, 45)
load_reasons.append("ACWR high")
elif acwr > 1.3:
load_score -= 5
if load_status == "Fresh":
load_status = "Productive"
load_reasons.append("ACWR elevated")
elif acwr < 0.8:
# Undertraining: ACWR <0.8 carries elevated risk specifically WHEN
# returning to higher load (research [2][6]). Not a block — flags headroom
# to build; the ramp back stays gradual via the volume-ramp guard (≤12%/wk).
load_reasons.append("ACWR <0.8 — undertrained; headroom to build, ramp gradually")
if week_hard >= 3:
load_status, load_score = "Caution", min(load_score, 45)
load_reasons.append(f"{week_hard} hard runs this week")
# Oura stress/resilience and hydration are recovery signals — they adjust the
# readiness (recovery) domain only, not load. Keeping them out of load avoids
# double-counting the same fatigue across two gates (doc §4).
cutting = _nested(ctx, ["body_comp_goal", "cutting_safety"], {})
cutting_status = (cutting or {}).get("status") or "OK"
bf = _num(_nested(ctx, ["body_comp", "latest", "body_fat"]), 0)
muscle_alert = None
body_status = "OnTarget"
body_score = 70
body_reasons = [f"cutting safety {cutting_status}"]
if cutting_status not in ("OK", "ok", "OnTarget", None):
body_status, body_score = "LEA_Risk", 45
body_reasons.append("cutting safety not OK")
# LEA / under-fuelling cluster. A triple-AND (low readiness AND HRV suppressed
# AND RHR rising) has low recall in the one domain training-rules L183 calls
# non-negotiable ("in a deficit + HRV amber/red → ALWAYS downgrade"). Fire on
# 2-of-3 stress signals; during an active cut, ANY single HRV-stress signal is
# enough. HRV-stress carries a cold-start fallback so the gate isn't silently
# absent when <14 days of banked readings make status "insufficient_data".
hrv_stress = (hrv_status.get("status") == "suppressed") or (
hrv_status.get("status") == "insufficient_data" and hrv_trend == "FALLING")
stress_signals = sum((
readiness_color in ("Orange", "Red"),
hrv_stress,
rhr_trend == "RISING",
))
cut_active = bool(cutting) # cutting_safety is tracked → in a cut/deficit phase
if stress_signals >= 2 or (cut_active and hrv_stress):
body_status, body_score = "LEA_Risk", min(body_score, 45)
tag = " (cut-sensitive)" if (cut_active and hrv_stress and stress_signals < 2) else ""
body_reasons.append(f"LEA cluster: {stress_signals}/3 stress signals{tag}")
if bf and bf > 17 and body_status != "LEA_Risk":
body_reasons.append(f"BF {bf:.1f}%: favor easy aerobic volume")
if week_gym_count < 2:
body_reasons.append(f"gym {week_gym_count}/2 this week")
if days_since_gym >= 7:
muscle_alert = f"gym overdue {days_since_gym}d"
z2_progress = _nested(ctx, ["z2_pace_progress", "monthly_improvement_sec"])
decoupling = _num(_nested(ctx, ["aerobic_efficiency", "latest", "aerobic_decoupling"]), 0)
perf_status = "Stable"
perf_score = 65
perf_reasons = []
if z2_progress is not None:
z2_delta = _num(z2_progress)
if z2_delta >= 5:
perf_status, perf_score = "Improving", 75
perf_reasons.append(f"Z2 pace improving {z2_delta:.0f}s/month")
elif z2_delta <= -10:
perf_status, perf_score = "Regressing", 50
perf_reasons.append(f"Z2 pace regressed {abs(z2_delta):.0f}s/month")
else:
perf_reasons.append("Z2 pace stable")
if decoupling:
perf_reasons.append(f"latest decoupling {decoupling:.1f}%")
if decoupling > 8:
perf_status, perf_score = "Fatigued", min(perf_score, 45)
elif decoupling > 5:
perf_score -= 5
if vo2max and vo2max.get("latest"):
if vo2max["status"] == "improving":
if perf_status != "Fatigued":
perf_status = "Improving"
perf_score = max(perf_score, 75)
perf_reasons.append(f"VO2max {vo2max['latest']} ↑{vo2max['delta']}")
elif vo2max["status"] == "declining":
perf_score -= 5
perf_reasons.append(f"VO2max {vo2max['latest']} ↓{abs(vo2max['delta'])}")
elif vo2max["status"] == "stable":
perf_reasons.append(f"VO2max {vo2max['latest']} (stable)")
else:
perf_reasons.append(f"VO2max {vo2max['latest']} (baseline; trend pending)")
if not perf_reasons:
perf_reasons.append("not enough performance trend data")
weekly_progress = week_km / weekly_km_target if weekly_km_target else 0
return {
"readiness": {
"score": readiness_score,
"color": readiness_color,
"illness_flag": illness_flag,
"reasons": readiness_reasons,
"temperature_deviation": temp_dev,
"temp_caution": temp_caution,
"sleep_debt": sleep_debt_flag,
"sleep_debt_reason": sleep_debt_reason,
"recovery_alerts": recovery_alerts,
"hydration_risk": hydration_risk,
"hydration_caution": hydration_caution,
"weight_drop_pct": round(weight_drop_pct, 4),
},
"load": {
"status": load_status,
"score": max(0, min(100, round(load_score))),
"acwr": round(acwr, 2) if acwr else None,
"reasons": load_reasons,
},
"body_comp": {
"status": body_status,
"score": body_score,
"muscle_alert": muscle_alert,
"reasons": body_reasons,
},
"performance": {
"status": perf_status,
"score": max(0, min(100, round(perf_score))),
"reasons": perf_reasons,
"latest_decoupling": round(decoupling, 2) if decoupling else None,
"vo2max": vo2max or {"status": "insufficient", "latest": None, "delta": None},
},
"weekly_structure": {
"week_km": round(week_km, 1),
"week_target": weekly_km_target,
"progress": round(weekly_progress, 2),
"week_gym_count": week_gym_count,
"mid_pct": zones.get("mid_pct", 0),
"high_pct": zones.get("high_pct", 0),
},
}
def apply_layered_decision(scores, blocked, models, catalogue, ds, yest_type,
week_hard, week_gym_count, today_dow, injury=0):
"""Apply cross-domain hard blocks and additive score adjustments.
The older selector used mostly multiplicative scoring. This pass is more
explicit: safety blocks first, then goal/structure boosts.
"""
adjustments = {k: [] for k in scores}
readiness_color = models["readiness"]["color"]
illness = models["readiness"]["illness_flag"]
temp_caution = models["readiness"].get("temp_caution", False)
sleep_debt = models["readiness"].get("sleep_debt", False)
hydration_risk = models["readiness"].get("hydration_risk", False)
load_status = models["load"]["status"]
acwr = models["load"]["acwr"] or 0
body_status = models["body_comp"]["status"]
perf_status = models["performance"]["status"]
decoupling = models["performance"].get("latest_decoupling") or 0
for key, info in catalogue.items():
if scores.get(key, -1) < 0:
continue
is_run = info["type"] in ("hard", "easy", "moderate")
is_hard_run = info["type"] == "hard"
is_long = key == "long_run"
is_gym = key == "full_body_gym"
# Niggle traffic-light (2026-06-03). injury: 0=green, 1=amber, 2=red.
# RED = stop running (gym/cross-train/rest only). AMBER = "treat it like
# red for that structure" (research §6) — drop quality + long, keep easy
# Z2 + pain-free strides; volume is halved upstream.
if injury >= 2 and is_run:
scores[key] = -1
blocked[key] = "niggle RED — stop running; gym/cross-train/rest only"
continue
if injury == 1 and (is_hard_run or is_long or key == "strides"):
scores[key] = -1
blocked[key] = "niggle AMBER — quality/long/strides blocked; easy Z2 only (no speed work on a niggle)"
continue
if illness and key not in ("rest", "full_body_gym"):
scores[key] = -1
blocked[key] = "illness signal — running intensity/volume blocked"
continue
if hydration_risk and (is_hard_run or is_long):
scores[key] = -1
blocked[key] = "hydration/weight-drop risk — hard/long run blocked"
continue
if sleep_debt and is_hard_run:
scores[key] = -1
blocked[key] = "acute sleep debt — hard run blocked (adaptation/injury risk)"
continue
if readiness_color == "Red" and key != "rest":
scores[key] = -1
blocked[key] = "red readiness — rest only"
continue
if readiness_color == "Orange" and is_hard_run:
scores[key] = -1
blocked[key] = "orange readiness — hard running blocked"
continue
if yest_type in ("hard", "long_run") and is_hard_run:
scores[key] = -1
blocked[key] = "no hard run after hard/long run"
continue
if load_status == "Overreached" and (is_hard_run or is_long):
scores[key] = -1
blocked[key] = "overreached load — hard/long run blocked"
continue
if acwr > 1.8 and (is_hard_run or is_long):
scores[key] = -1
blocked[key] = "ACWR > 1.8 — hard/long run blocked"
continue
if acwr > 1.5 and (key == "intervals_vo2" or is_long):
scores[key] = -1
blocked[key] = "ACWR > 1.5 — intervals/long run blocked (acute load spike)"
continue
if body_status == "LEA_Risk" and is_hard_run:
scores[key] = -1
blocked[key] = "LEA risk — hard running blocked"
continue
if readiness_color in ("Green", "Yellow") and load_status in ("Fresh", "Productive") and key == "easy_z2":
_eab = GOAL_PROFILE["easy_aerobic_bonus"]
scores[key] += _eab
adjustments[key].append(f"+{_eab} easy aerobic work fits readiness/load ({GOAL_PRIORITY})")
if key == "long_run" and ds.get("long_run", 999) >= 7 and decoupling < 5 and load_status != "Caution":
scores[key] += 20
adjustments[key].append("+20 long run due and decoupling good")
elif key == "long_run" and decoupling > 7:
scores[key] -= 15
adjustments[key].append("-15 recent decoupling high; cap long run")
if is_hard_run and perf_status == "Improving" and week_hard <= 1:
scores[key] += 10
adjustments[key].append("+10 performance improving and hard-run budget available")
if is_hard_run and perf_status in ("Fatigued", "Regressing"):
scores[key] -= 15
adjustments[key].append("-15 performance fatigue/regression")
if is_hard_run and temp_caution:
scores[key] -= 15
adjustments[key].append("-15 mild temp elevation (illness watch)")
if is_gym and week_gym_count < 2:
urgency = 20 if today_dow in ("Friday", "Saturday", "Sunday") else 12
scores[key] += urgency
adjustments[key].append(f"+{urgency} strength minimum not met")
if is_gym and body_status == "LEA_Risk":
scores[key] -= 10
adjustments[key].append("-10 LEA risk: keep strength lighter")
if key == "rest" and (readiness_color in ("Orange", "Red") or load_status == "Overreached" or body_status == "LEA_Risk" or sleep_debt):
scores[key] += 40
adjustments[key].append("+40 recovery risk cluster")
if sleep_debt and key in ("easy_z2", "rest"):
scores[key] += 15
adjustments[key].append("+15 acute sleep debt — favor recovery")
return scores, blocked, {k: v for k, v in adjustments.items() if v}
def recent_from_briefing(ctx):
"""Fallback recent activity list from briefing_context.json.
The primary Strava shell command can produce empty stdout during transient
auth/network failures. The briefing context is refreshed earlier in the
morning and has enough fields for recency, weekly volume, and hard-day gates.
"""
runs = _nested(ctx, ["recent_runs", "runs"], []) or []
out = []
for r in runs:
_dur = _num(r.get("duration_min"), 0)
out.append({
"name": r.get("name") or "Run",
"type": r.get("type") or "Run",
"date": r.get("day") or r.get("date"),
"start": r.get("start") or r.get("start_date_local"),
"distance_km": _num(r.get("distance_km"), 0),
"duration_min": _dur,
# Pass through big-event fields if the briefing carries them; else fall
# back to moving time for elapsed so the duration path still fires.
"elapsed_min": _num(r.get("elapsed_min"), _dur),
"suffer_score": r.get("suffer_score"),
"avg_hr": r.get("average_heartrate") or r.get("avg_hr"),
"max_hr": r.get("max_heartrate") or r.get("max_hr"),
})
return [a for a in out if a.get("date")]
def build_final_decision(winner, scores, blocked, models, score_adjustments,
readiness_raw, tsb, ctl, week_km, runs_done,
week_gym_count, muscle_alert, days_since_gym):
valid = sorted([(k, v) for k, v in scores.items() if v >= 0], key=lambda x: -x[1])
top3 = valid[:3]
top3_str = ", ".join(f"{k}({v})" for k, v in top3)
runner_up = valid[1] if len(valid) > 1 else None
gap = round(scores[winner] - runner_up[1], 1) if runner_up else 999
confidence = 95 if not runner_up else max(55, min(95, round(60 + gap / max(scores[winner], 1) * 80)))
hard_blocked = [
f"{k}: {v}" for k, v in blocked.items()
if k in CATALOGUE and CATALOGUE[k]["type"] == "hard"
]
why = [
f"{models['readiness']['color']} readiness {models['readiness']['score']} ({readiness_raw} raw)",
f"{models['load']['status']} load: {', '.join(models['load']['reasons'])}",
f"{models['body_comp']['status']} body-comp: {', '.join(models['body_comp']['reasons'])}",
f"{models['performance']['status']} performance: {', '.join(models['performance']['reasons'])}",
]
why.extend(score_adjustments.get(winner, []))
if runner_up:
why.append(f"beat next option {runner_up[0]} by {gap} points")
# Fueling targets: protein 1.6-1.8 g/kg; carbs periodized to session load;
# maintenance on quality/long/lift days; modest deficit only on true easy days;
# energy availability about 40 kcal/kg FFM.
nutrition_flags = ["protein 1.6-1.8 g/kg across 4-5 meals (muscle retention)"]
if winner in ("threshold", "intervals_vo2", "long_run"):
nutrition_flags.append("QUALITY/LONG day: eat at maintenance — carbs 5-7g/kg, "
"1-4g/kg pre, 30-60g/h if >75min, 20-40g protein + carbs after")
elif winner == "full_body_gym":
nutrition_flags.append("LIFT day: protein priority, carbs to fuel the lift; do not under-eat")
elif winner == "easy_z2":
nutrition_flags.append("EASY day: modest deficit OK (300-500kcal), carbs ~3-5g/kg; don't stack deficit days")
if models["body_comp"]["status"] == "LEA_Risk":
nutrition_flags.append("LEA risk: calories to maintenance for several days (EA <30 erodes muscle + adaptation)")