Ajouter au pod spec l'env Downward API : CVN_POD_CPU_LIMIT ← resourceFieldRef: {resource: limits.cpu}. Source du cap (spec K8s = vérité). Tous les diag DAGs qui en héritent l'obtiennent d'un coup.
Fractionnaire : limits.cpu arrondit au cœur sup (3.5→4). Lire en millicores si exactitude requise (divisor: 1m → 3500, floor/1000=3) ; sinon assumer limites entières (cas usuel — nos pods = limits.cpu=4). ADR-0096 validation notes.
B. s42 — belt-and-suspenders + config (A1, facteur §5.1)¶
Passer num_threads = int(os.environ.get("OMP_NUM_THREADS", 1)) dans les params LGB (belt-and-suspenders, ADR-0096 clause 4 ; l'env-level reste le fix primaire). Pas de n_jobs=-1 ailleurs (Inv 5).
dag_diagnostic__s42.py
skip_phase_a reste un param explicite par-run — le DÉFAUT N'EST PAS CHANGÉ (blocker committee #1). Le re-trigger warm passe skip_phase_a=trueexplicitement ; les runs par défaut gardent l'anchor-check → le signal A6 n'est PAS droppé silencieusement. Faire de skip le défaut est gated sur une détection A6 indépendante (cf. A6 ci-dessous), pas livré ici.
Déjà conforme : group_bootstrap_ci boucle B=2000 en série (pas de n_jobs) — mais le série n'est pas mono-thread (chaque op numpy utilise les 4 threads BLAS cappés). Action : MESURER le walltime série une fois (sur le smoke) → si viable (~minutes), LOCK série explicitement (commentaire « n_jobs interdit ici, série mesuré à Xs, ADR-0096 Inv 5 ») ; si trop lent, parallélisme borné (n_jobs × thread_cap ≤ limit). Ne pas laisser « série ou parallélisé » non-tranché → sinon quelqu'un parallélise « parce que c'est lent » dans 6 mois → re-livelock. Les probes (cold-train) héritent les caps env (rien de spécifique).
dag_diagnostic__s43.py
STANDARD_POD : même env Downward API CVN_POD_CPU_LIMIT (via _common.py).
tests/unit/test_s43_*.py
Inv I7 (ADR-0096 Inv 3, unit) : asserter que le chemin de fit ne passe pas n_jobs=-1 et que num_threads est sourcé de l'env (≠ None, ≠ hardcodé node cores). Le « ≤ cgroup » = smoke/integration.
execution_timeout sur discriminate_cell (s42/s43) = ~10-12h (couvre le worst-case légitime incluant le fallback cold ~2h/cellule). Tue un hang sans tuer un run lent-mais-sain.
Progress-log (A5)
event=sNN_fit_progress fit=<i>/<N> ... par fit → un hang devient visible en Loki (le dernier event utile était s22a1_hp_resolved, puis rien).
Prometheus panel (reco #4)
« fits completed vs CPU burnt » → détecte un slowdown de contention résiduelle.
Lint n_jobs (reco #3)
grep-gate CI : aucun n_jobs explicite > 1 dans les diagnostics compute (pas seulement =-1). Complémentaire à LOKY_MAX_CPU_COUNT** : LOKY borne le cas **auto** (n_jobs=-1→cpu_count()→ env-borné) ; mais unn_jobs=8` explicite override l'env → le lint le catche. Ensemble ils ferment auto + explicite.
threadpoolctl (reco #1)
check threadpool_info() au startup (ou CI) → confirme le BLAS réel (OpenBLAS vs MKL) et que le cap le touche.
E. A6 — détection replay-divergence INDÉPENDANTE (blocker committee #1, gate du défaut skip)¶
Le signal repro ne doit plus dépendre du FAIL d'ancre Phase A. Mécanisme autonome :
Item
Changement
Determinism check
Job (périodique OU CI nightly) : s18_step0_replay lancé 2× avec le même seed → asserter f1 bit-identical. Un écart = non-déterminisme (seed non figé / ordre LdP loop / fenêtre données). event=s18_replay_determinism status=PASS\|FAIL.
Canonical-divergence check
Comparer le f1 replay au f1 canonique stocké (le expected du verdict s18_step0) hors du chemin run : abs_delta > epsilon → event=s18_replay_divergence severity=warn abs_delta=<>. Indépendant de skip_phase_a.
Gate
skip_phase_a=true ne peut devenir défaut que quand ces deux checks tournent (sinon on perd le signal qui a révélé A6). Tant que non livrés → skip reste explicite par-run (§2.B).
Pré-conditions BLOQUANTES (blocker committee #2 — résolues AVANT le deploy dépendant, pas pendant)¶
Pré-condition
Gate
Inventaire pods hors-STANDARD_POD
grep -rl Dockerfile.k8s + lister les pod specs n'héritant pas de STANDARD_POD → chacun reçoit le Downward API OU est exclu → complété + commité AVANT le deploy de l'entrypoint (A).
Walltime bootstrap série s43
mesuré sur le smoke → décision lockée (série explicite si viable, sinon parallélisme borné) → AVANT l'impl/run s43 (C).
A6 détection (§2.E)
les 2 checks livrés → gate du défaut skip_phase_a=true (sinon skip reste par-run).
PRÉ-COND inventaire pods hors-STANDARD_POD (complété + commité) ── GATE du deploy A
A0-deploy Downward API dans STANDARD_POD PARTAGÉ (_common.py) ── tous les diag DAGs d'un coup
A (entrypoint dans la base image)
└─> DEPLOY PHASÉ (blast-radius = TOUS les pods) :
1. mode WARN d'abord (echo severity=warn, PAS d'exit 1)
2. observer Loki tous-namespaces → repérer les pods qui déclenchent le warn
3. fixer les stragglers (déclarer le Downward API)
4. SEULEMENT alors → flip fail-loud (exit 1) ── « observe avant d'enforcer » (ADR-0093)
B (s42 belt + skip_phase_a) ── PR avec A
D (task timeout + progress-log) ── PR avec A/B (backstop avant re-run)
└─> VÉRIF : re-trigger 1 CELLULE s42 (num_threads pinné, skip_phase_a=true)
├─ fits normaux (~3-5 min/fit, progress-log visible) → cause CLOSE (A0/A2)
└─ re-hang malgré caps → ROUVRIR (RCA A2 : ce n'était pas (que) ça)
A2 (re-trigger full s42 defi_top5) ── seulement si la vérif 1-cellule passe
C (s43 : mesurer série + lock + test I7) ── avant l'impl s43 complet
A6 (replay divergence) ── investigation séparée (+ détection propre si skip défaut)
Le deploy de l'entrypoint a un blast-radius « tous les pods » : le phasage WARN→observe→fail-loud le transforme d'un pari (« 100% des pods ont déjà le Downward API » — à prouver, pas supposer) en déploiement observable + réversible. Même prudence que la vérif 1-cellule appliquée à l'infra.
L'attribution OpenMP/BLAS reste inférentielle (pas de thread-dump, pods tués) ; la preuve directe = le fix qui marche. Avant le full run : re-trigger 1 cellule (crypto_group=OPUSDC, skip_phase_a=true). Lire Loki :
- ✅ class_balance_applied + s42_* events qui progressent, ~3-5 min/fit → cause confirmée close.
- ❌ re-hang (CPU ~3.2 cœurs, 0 progrès) → rouvrir : le busy-wait n'était pas (uniquement) celui qu'on pense (vérifier threadpool_info, chercher un autre vecteur).
Revert l'entrypoint cap + l'env Downward API → état pré-incident (threads non-bornés). ⚠️ Ce rollback RÉINTRODUIT la vulnérabilité (retour au livelock) — c'est une mesure d'urgence (si l'entrypoint lui-même casse le démarrage des pods), pas un fallback casual. Aucun impact data/logique (env + check startup). Le re-trigger s42 est read-only (ADR-2).
Contention nœud résiduelle (5×4=20>16) : slowdown ~25%, pas livelock (ADR-0096 Consequences). Accepté pour le verdict one-shot ; anti-affinity si industrialisé.
BLAS mismatch : si numpy lié OpenBLAS mais on ne cappe que MKL → cap raté. Mitigé par threadpool_info (reco #1) — couvert par les 4 env de toute façon.
Image deploy — blast-radius TOUS les pods (le risque #1, RÉSOLU §3) : l'entrypoint fail-loud peut tuer au deploy tout pod sans CVN_POD_CPU_LIMIT. Résolu par le deploy phasé WARN→observe→fail-loud (§3) + Downward API dans STANDARD_POD partagé + inventaire des pods hors-STANDARD_POD. Décision : déclarer le Downward API partout (cohérent avec « unconditional » d'ADR-0096) ; le WARN intermédiaire trouve les oublis avant qu'ils ne cassent.