Les garde-fous (arc_guardrails.py)¶
La progression d'entraînement est confiée au LLM (l'agent coach). Principale
critique adressée aux coachs IA : une progression trop agressive — des
blessures ont été rapportées avec des outils comparables. scripts/
arc_guardrails.py est un second avis purement calculé, déterministe et
testé, que le coach consulte avant d'écrire une semaine et avant de la pousser
au calendrier Garmin — câblage effectif dans agents/coach.md et
skills/garmin-workout-scheduling/SKILL.md
(#53, voir
« Câblage agent » ci-dessous), ce moteur restant lui-même indépendant de ce
câblage.
Les sept règles¶
| id | vérifie | sévérité par défaut |
|---|---|---|
r1_acwr_projected |
ACWR (charge aiguë/chronique) projeté, maximum sur la semaine proposée | warn |
r2_weekly_volume_jump |
hausse de la durée (+ distance en road) hebdomadaire planifiée |
warn |
r3_weekly_elevation_jump |
hausse du D+ hebdomadaire planifié (trail seulement) | warn |
r4_monotony_projected |
monotonie de Foster projetée sur la semaine proposée | warn |
r5_quality_after_red |
séance de qualité le jour même ou le lendemain d'un verdict santé rouge | block |
r6_long_run_share |
part de la plus longue sortie dans le volume hebdomadaire | warn |
r7_consecutive_quality |
deux séances de qualité le même jour ou sur deux jours consécutifs | warn |
Seuils et sévérités : [guardrails] de config/workspace.toml, voir
Configuration.
R1 (ACWR) est warn, pas block, par défaut
Voir Sources ci-dessous pour le détail des réserves
scientifiques. Remettez severity_r1_acwr_projected = "block" dans
[guardrails] si vous préférez la fermeté.
Ce qui n'est jamais bloqué¶
- Historique réel insuffisant — trois gardes distinctes :
- moins de 84 jours d'historique réel avant
week_start(MIN_HISTORY_DAYS_FOR_PROJECTION, 2× la fenêtre de condition de 42 jours) : R1 est sautée (reason_code: "insufficient_history"), jamais évaluée sur un démarrage à froid du modèle impulsion-réponse de Banister — sans cette garde, un nouvel utilisateur avec 1 à 8 semaines d'historique lirait un ACWR de 3,18 à 1,34 sur une semaine pourtant parfaitement stable ; - moins de 14 jours d'historique réel : R4 (monotonie) est sautée — un plancher bien plus court que celui de R1, la fenêtre de monotonie (moyenne/écart-type de charge brute sur 7 jours) n'ayant pas le biais de démarrage à froid d'une moyenne mobile exponentielle ;
- moins de 4 séances de la famille course dans la semaine proposée :
R6 est sautée (
reason_code: "too_few_sessions") — avec 3 séances ou moins, la plus longue dépasse presque toujours 35 % par pure construction arithmétique.
- moins de 84 jours d'historique réel avant
- Référence sans historique de la bonne famille (R2/R3 : la fenêtre de
référence n'a aucune activité de la famille course, ex. une semaine 100 %
vélo) : sautées avec
reason_code: "no_reference". - Séance prescrite en distance sans allure récente pour estimer sa durée
(R1/R2/R4) : sautées avec
reason_code: "missing_planned_duration"— voir Séances prescrites en distance ci-dessous. - Semaine de course (date de
planning/active_objective.mddans la semaine proposée) : R1, R2, R3, R4, R6 et R7 sont sautées (reason_code: "race_week") — l'objectif est d'éviter une progression trop agressive à l'entraînement, jamais de bloquer la course elle-même. R5 reste active (une alerte santé reste pertinente juste avant une course). - Semaine qui SUIT une course (récupération) : R1 ne compare pas l'ACWR
projeté au seuil brut, mais à un scénario « repos complet » calculé sur la
même semaine (mêmes séances mises à zéro) — elle ne bloque QUE si la semaine
proposée aggrave le ratio par rapport à ce repos complet. Sinon, une
violation
infoest quand même rendue (« ACWR déjà élevé [...] la proposition ne l'augmente pas ») pour que le coach voie le chiffre : une fatigue résiduelle de course, seule, ne bloque jamais une semaine de récupération, mais n'est pas non plus passée sous silence. - Bilan matinal désactivé (
[health].morning_check = "off") : R5 est sautée (reason_code: "health_check_disabled") — aucune donnée de santé n'est de toute façon récupérée dans ce mode. - Séances annulées, déplacées ou manquées (
status), ou de repos : exclues de toutes les règles. - Semaine de récupération (deload) : R2/R3 ne réagissent qu'à une hausse — une baisse de volume ne déclenche jamais rien.
- Sport hors périmètre d'une règle : R3 (D+) ne s'applique qu'en trail
(
reason_code: "not_applicable_sport").
Approximation de charge d'une séance planifiée¶
Une séance planifiée n'a pas de FC : impossible d'y calculer un vrai TRIMP
(arc_metrics.trimp_banister). R1/R4 projettent donc une charge à partir de
l'intensité prescrite (rest → 0 … race → 10 sur l'échelle RPE 0-10),
passée dans exactement la même formule que le repli session-RPE de
arc_metrics.session_load, pour rester sur la même échelle que la charge
réelle déjà indexée. C'est une approximation d'une approximation, jamais
présentée comme mesurée — voir arc_guardrails.ASSUMPTIONS["projected_load"].
Chaque jour de la semaine proposée est apparié séance par séance à une
activité réelle déjà indexée (même logique que arc_metrics.week_compliance)
: une séance déjà réalisée compte sa charge RÉELLE, une séance encore prévue
compte sa charge PROJETÉE — plusieurs séances le même jour sont sommées,
jamais l'une écrasant l'autre. Un jour déjà passé (avant --today) sans
activité réelle appariée compte 0, jamais la charge projetée : un jour
manqué sans donnée ne doit jamais recevoir le bénéfice d'une charge qui n'a
peut-être jamais eu lieu.
Séances prescrites en distance¶
Une séance planifiée seulement en distance (planned_distance_m, sans
planned_duration_s — ex. « Sortie longue 25 km / 900 m D+ ») ne compte
jamais une durée/charge de zéro : sa durée est estimée depuis l'allure
course récente de l'athlète (médiane sur 90 jours, famille course à pied) et
l'équivalence D+/plat déjà utilisée pour les prédictions
(arc_metrics.TRAIL_FLAT_M_PER_M_DPLUS). Les dates estimées sont exposées
dans context.distance_only_sessions_estimated, pour transparence. Sans
aucune allure récente disponible, R1/R2/R4 sont sautées plutôt que d'inventer
une estimation sans donnée — voir
arc_guardrails.ASSUMPTIONS["distance_only_estimate"].
Sources¶
Chaque règle cite sa source dans son champ source (littérature vérifiable
pour R1/R4/R7 — Gabbett 2016, Foster 1998, Seiler & Kjerland 2006 — ou
« convention du projet » explicite pour R2/R3/R6, faute de source unique et
consensuelle). Le détail complet, avec les citations complètes, est dans
scripts/arc_guardrails.py::ASSUMPTIONS.
Réserves scientifiques sur R1 (ACWR)
Les seuils de Gabbett (2016) viennent d'études en sports collectifs
(rugby, football australien), avec des moyennes glissantes simples
sur 7 jours (aigu) et 28 jours (chronique). Ce moteur utilise le modèle
impulsion-réponse de Banister (moyennes mobiles exponentielles
7 j/42 j), une définition mathématiquement différente, jamais validée par
les mêmes études — et évaluée au maximum de la semaine proposée
plutôt qu'en fin de semaine, pour limiter (sans l'éliminer complètement)
un biais de motif hebdomadaire (une longue sortie systématiquement le
dimanche gonflerait sinon l'ACWR du seul dernier jour). La preuve
elle-même est contestée dans la littérature de course à pied : voir
Impellizzeri F.M. et al. (2020), déjà cité dans
docs/marques.md (« ACWR : la zone 0,8-1,3 est un repère
indicatif [...] discuté dans la littérature »). C'est pourquoi R1 est
warn par défaut, pas block.
Pourquoi ces citations sont dans le code, pas dans resources/
resources/ est un dossier privé du workspace de chaque utilisateur
(gitignoré, créé par install.sh) : il n'existe pas dans ce dépôt public,
et une règle ne peut donc pas y citer un fichier vérifiable. Les sources
citées ici sont donc des références de littérature directement dans
arc_guardrails.ASSUMPTIONS (même convention que arc_metrics.ASSUMPTIONS
déjà dans ce projet), et les règles sans source unique et consensuelle
(R2/R3/R6) sont explicitement étiquetées « convention du projet ».
CLI¶
# Sur un fichier semaine déjà écrit
python3 scripts/arc_guardrails.py check --week planning/2026-09-21_semaine.md
# AVANT d'écrire le fichier (fichier temporaire ou flux stdin, JSON ou Markdown ```arc)
echo '{"week_start": "2026-09-21", "sessions": [...]}' \
| python3 scripts/arc_guardrails.py check --week -
python3 scripts/arc_guardrails.py check --week /tmp/proposed.json --workspace . --today 2026-09-20
La semaine proposée est validée avant tout calcul : contrat arc_contract
(champs requis, types — un planned_duration_s non numérique est rejeté
avant de pouvoir faire planter un calcul) et week_start sur un lundi
(le moteur suppose partout des semaines Lundi-Dimanche).
Sortie JSON sur stdout. Codes de sortie — un agent en headless doit pouvoir les distinguer sans ambiguïté :
| Code | Signification |
|---|---|
0 |
ok — aucune violation de sévérité block (des warn/info peuvent exister) |
1 |
au moins une violation de sévérité block |
2 |
erreur (fichier introuvable, semaine non conforme au contrat, date malformée…) — jamais confondu avec 1 |
{
"ok": true,
"violations": [
{"rule_id": "r1_acwr_projected", "severity": "warn",
"message": "ACWR projeté (maximum sur la semaine) : 1.42, au-delà du seuil 1.3.",
"message_en": "Projected ACWR (weekly maximum): 1.42, above the 1.3 threshold.",
"values": {"observed": 1.42, "threshold": 1.3},
"session_dates": [], "source": "..."}
],
"checked_rules": ["r1_acwr_projected", "r2_weekly_volume_jump", "..."],
"skipped_rules": [{"rule_id": "r3_weekly_elevation_jump",
"reason_code": "not_applicable_sport",
"reason": "R3 ne s'applique qu'en trail ([sport].primary)."}],
"context": {"week_start": "2026-09-21", "is_race_week": false,
"acwr_projected": 1.42, "monotony_projected": 1.6,
"distance_only_sessions_estimated": [], "...": "..."}
}
violations[].rule_id et le format ci-dessus sont repris tels quels par
#54 (bloc
decision, qui enregistre les rule_id déclenchés — voir « Câblage agent »
ci-dessous) et pensés pour
#57 (drapeau
composite de risque de blessure, qui combine context.acwr_projected/
context.monotony_projected avec d'autres signaux).
Câblage agent (#53)¶
Le moteur ci-dessus ne fait rien tant que personne ne l'appelle : coach
(agents/coach.md, section « GUARDRAILS MANDATE ») et le skill
garmin-workout-scheduling lancent check avant d'écrire un fichier semaine
et avant tout schedule_workouts/schedule_week, jamais après.
ok=false(exit 1, au moins unblock), session interactive : unblockne bloque QUE la séance visée — les autres séances de la semaine s'écrivent et se poussent normalement. Pour la séance flaguée, le coach ne l'écrit ni ne la pousse telle quelle : il PROPOSE une alternative sûre (ex. remplacer une séance de qualité par du facile/repos) en une phrase citant lemessagede la violation, écrit tout de suite unedecisionoutcome: "proposed", et ne pousse (ni n'écrit) l'alternative qu'une fois l'athlète d'accord. Une fois confirmée : la décision devient un NOUVEAU fichierdecision(outcome: "applied",supersedesvers leproposedci-dessus, qui repasse lui-même àoutcome: "superseded"). Le style et l'intensité de coaching ([coaching].style/.intensity), et les « Préférences de coaching » du profil de l'athlète, ne changent que le ton de cette phrase — jamais la décision, jamais l'ordre écriture-après- confirmation.ok=false, synchronisation headless (/garmin-daily-sync) : jamais de push ni d'écriture de plan, jamais même une proposition « applied » — seulement unedecisionoutcome: "proposed", datée du jour de la séance flaguée (potentiellement DEMAIN, pas forcément le jour du run). Cette décision fait alors passer la 5e ligne duresumedeAlerte :àPourquoi :(raison de l'ajustement, #56 — l'étape 5 du skill interroge le journal des décisions pour aujourd'hui ET demain) plutôt que de s'y ajouter, et n'apparaît JAMAIS aussi dans la ligneAlerte :— voirskills/garmin-daily-sync/SKILL.md.ok=trueavec deswarn/info: écriture/push autorisés, la violation est mentionnée brièvement.- Exit 2 : entrée invalide — le coach le signale et ne pousse rien ; ce n'est jamais interprété comme un verdict de garde-fou.
- Traçabilité : toute séance changée, remplacée ou annulée à cause d'une
violation (ou du bilan matinal, ou d'une donnée médicale) devient un fichier
decision(planning/YYYY-MM-DD_decision_<slug>.md, typedecisiondu contrat de données, détaillé champ par champ dansskills/workspace-data-contract/SKILL.md) —trigger: "guardrail",rule_idsrepris tels quels depuisviolations[].rule_id,before/aftersur la séance concernée. Le fichier semaine n'est réécrit qu'avec le contenu réellement appliqué (jamais la version encore flaguée), et toujours AVANT le fichierdecisionqui le référence.
Drapeau composite de risque de blessure (#57)¶
Combine des signaux déjà calculés ailleurs — ACWR/monotonie réels
(aucune semaine proposée n'entre ici, contrairement à check ci-dessus),
douleur déclarée (health.pain, champ contractuel dédié — voir
skills/workspace-data-contract/SKILL.md), et un écart entre l'effort perçu
(RPE) et la charge mesurée par FC — en un score déterministe à 3 niveaux.
Non-diagnostique : chaque sortie porte un disclaimer obligatoire
(« signal de vigilance », jamais « risque de blessure avéré » ni le nom
d'une pathologie).
python3 scripts/arc_guardrails.py injury-risk
python3 scripts/arc_guardrails.py injury-risk --today 2026-09-24 --workspace .
Facteurs, seuils et gates¶
| id | ce qu'il vérifie | seuil par défaut | poids si contributeur | sauté quand |
|---|---|---|---|---|
acwr |
ACWR réel du jour (mêmes constantes que R1) | 1.3 |
1 | historique réel < 84 j, ou condition trop faible |
monotony |
monotonie de Foster réelle, 7 j glissants (mêmes constantes que R4) | 2.0 |
1 | historique réel < 14 j, ou fenêtre incomplète |
pain |
douleur maximale déclarée (health.pain[].score), fenêtre glissante de 3 j |
4/10 |
2 | aucun fichier santé sur la fenêtre (no_health_file) — un fichier présent sans douleur compte comme une observation (observed: 0), pas un saut |
rpe_hr_mismatch |
ratio (charge sRPE équivalente / charge TRIMP réelle) sur les séances avec FC ET RPE, médiane 14 j vs médiane de référence 90 j | +30 % |
1 | moins de 3 séances avec les deux champs sur l'une des deux fenêtres |
sleep_debt |
dette de sommeil 7 j (#37, réutilise arc_index.sleep_debt_today), exposée en HEURES |
10 h cumulées |
1 | [health].morning_check != "full" (sauté à "minimal" ET à "off", pas seulement à "off") |
red_verdict |
verdict santé rouge aujourd'hui ou hier — fait booléen, observed/threshold valent null |
— | 1 | [health].morning_check == "off" |
Niveau = somme des poids des facteurs qui contribuent : < 2 faible,
2 à 3 modéré, >= 4 élevé — convention du projet, jamais une
classification validée cliniquement. La douleur pèse double : c'est le seul
facteur déclaré directement par l'athlète, les autres sont dérivés d'un
modèle de charge ou d'une mesure Garmin. Un facteur sauté ne contribue
jamais — un signal manquant n'est pas un signal favorable, mais le moteur ne
devine jamais une aggravation depuis une absence de donnée ; chaque facteur
sauté porte un reason_code explicite.
Douleur sévère : level forcé à high, consult: true
Si pain contribue ET que la douleur observée est >=
[injury_risk].pain_consult_threshold (défaut 7/10), le niveau est
forcé à high À LUI SEUL — pas besoin d'un second facteur. consult
(booléen, toujours présent dans la sortie) vaut alors true, et plus
généralement dès que level: "high" ET que pain contribue (même sous ce
second seuil, si d'autres facteurs ont déjà porté le niveau à high).
agents/medical.md/agents/coach.md recommandent explicitement un avis
professionnel dans ce cas — voir « Câblage agent » ci-dessous.
Seuils réglables via [injury_risk] — voir
Configuration.
Sortie JSON¶
{
"level": "high", "score": 2, "consult": true,
"factors": [
{"id": "pain", "observed": 8.0, "threshold": 4.0, "contributes": true,
"weight": 2, "label": "Douleur déclarée", "location": "genou droit"},
{"id": "sleep_debt", "observed": 8.5, "threshold": 10.0, "contributes": false,
"weight": 0, "label": "Dette de sommeil 7 jours"},
{"id": "red_verdict", "observed": null, "threshold": null, "contributes": false,
"weight": 0, "label": "Verdict santé rouge aujourd'hui ou hier"},
{"id": "acwr", "observed": null, "threshold": null, "contributes": false,
"weight": 0, "reason_code": "insufficient_history",
"reason": "historique réel trop court (< 84 j)...", "label": "..."}
],
"disclaimer": "Signal de vigilance calculé ... non-diagnostique ...",
"context": {"today": "2026-09-24", "morning_check": "full"}
}
label est un texte COURT, sans jargon de schéma (jamais un nom de champ
contractuel) — c'est ce qu'un agent ou le tableau de bord peuvent citer
directement. pain porte en plus location (zone déclarée au score maximal,
null si absente) ; sleep_debt est déjà en heures ; red_verdict n'a pas de
observed/threshold numériques (un fait booléen n'a rien à comparer à un
seuil).
Câblage agent¶
medical (gatekeeper) et coach lisent ce drapeau — voir la section
« INJURY-RISK FLAG » de agents/medical.md/agents/coach.md. medical
recommande explicitement un avis professionnel dès que consult: true (voir
l'encart ci-dessus) ; ni l'un ni l'autre ne nomme jamais une pathologie
précise. Le tableau de bord affiche une tuile compacte sur « Aujourd'hui »
dès level >= "moderate" (/api/injury-risk, web/js/app.js::injuryRiskTile).
Sources¶
acwr/monotony reprennent les mêmes repères que R1/R4 ci-dessus (Gabbett
2016, réserves d'Impellizzeri 2020 ; Foster 1998) — voir Sources.
pain_score_threshold (4/10), pain_consult_threshold (7/10 — repère
d'échelle numérique de douleur 0-10 courant en clinique pour qualifier une
douleur « sévère », pas une valeur calibrée sur ce moteur), la fenêtre de
3 jours, le ratio rpe_hr_mismatch (+30 %) et les poids par facteur sont des
conventions du projet : aucune étude publiée ne fixe ces valeurs
numériques précises pour un drapeau composite — seuls les principes
documentés (charge aiguë/chronique, monotonie de Foster, effort perçu vs
réponse cardiaque) le sont.
Pour aller plus loin¶
Fonction pure au cœur du moteur : evaluate(proposed_week, context, config) ->
dict, testée par tests/data/test_arc_guardrails.py (un cas par règle,
juste sous/juste au-dessus du seuil, plus les cas limites : historique court,
séances le même jour, séances manquées, prescriptions en distance seule,
semaine de récupération après une course). build_context(conn, config,
gconf, week_start, today) lit l'index dérivé (charge réelle, activités de la
semaine, allure récente, semaine(s) précédente(s), dernier verdict santé,
objectif actif) — c'est la seule partie du module qui touche à la base
SQLite.