RCA — run de validation cost_scenario vide (no_data) : chemin runtime base-model cassé par construction¶
Root Cause Analysis du run de validation S12 (incrément 2) du 2026-06-07. Rattaché au hub Story S12. Bug tracké : GH #1133 (cause) · GH #1134 (observabilité). État live = OpenProject (wp#246, ADR-76).
En une phrase¶
Le run censé valider l'incrément 2 (peuplement de finetune_baselines par (crypto, fold, cost)) est vide : 0 results, 0 baselines, verdict=no_data. La cause n'est ni la donnée ni la config ni #1117 — c'est un bug de code pré-existant (introduit par #491, 2026-04-10) dans le chemin runtime partagé _train_base_model, resté dormant parce qu'aucun facteur runtime n'avait tourné depuis et qu'aucun test d'intégration (ADR-58) ne l'exerçait en CI. Un run vide = absence de preuve, pas une preuve : la Story reste In testing.
Symptôme¶
Run ftf_20260607_141946_362377_ATR0.5_1.5_H4 — factor=cost_scenario, crypto_group=defi_top5, power_mode=standard :
report_factor_generated factor=cost_scenario variants=0 metrics=0 verdict=no_data errors=0
report : total_runs=0, baselines={}, n_folds=3, n_trials=10
Factor cost_scenario crypto {UNIUSDC,OPUSDC,ARBUSDC,AAVEUSDC,LDOUSDC} complete: 0 results (les 5)
Les 5 cryptos démarrent (Running factor=cost_scenario crypto=X type=runtime variants=4) mais run_factor renvoie [] pour chacun → finetune_baselines ne reçoit aucune ligne → la preuve per-cost (COUNT(DISTINCT cost_bps) ≥ 2 + 0 ligne orpheline sur le join) n'a rien à certifier.
Preuve (log per-pod run_factor_crypto_standard, UNIUSDC fold 4)¶
{regime_trainer.py:935} INFO - event=weighted_variant_evaluated variant=base sortino=0.898 n_trades=35 return=28.07% n_oos_predictions=17568
{ablation_runner.py:1626} ERROR - event=base_model_exception crypto=UNIUSDC fold=4
Traceback (most recent call last):
File ".../ablation_runner.py", line 1595, in _train_base_model
cached_model = CVNTradeCache.get_from_session("last_trained_model")
AttributeError: type object 'CVNTradeCache' has no attribute 'get_from_session'. Did you mean: '_get_from_session'?
{ablation_runner.py:585} WARNING - event=skip_fold crypto=UNIUSDC fold=4 reason=base_model_failed
Lecture : le base-model s'entraîne parfaitement (sortino=0.898, n_trades=35 — vraies métriques). Le crash est dans la récupération de l'artefact, pas dans l'entraînement.
Généralisation 1 log → 15 cellules. Une seule cellule est citée (UNIUSDC fold 4) mais la conclusion vaut pour les 15
(crypto, fold): le défaut est structurel et indépendant des données (un appel d'attribut inexistant + un fallback mort), pas un échec data-dépendant — d'où le0 resultsuniforme sur les 5 cryptos. Ce n'est pas une extrapolation, c'est la signature d'un bug par construction.
Root cause — deux défauts dans le bloc fallback + la vraie cause amont¶
Bloc fautif ablation_runner.py:1593-1601 (fallback session-cache quand le modèle n'est pas porté par le VariantResult) :
- Mauvais nom de méthode — appelle
CVNTradeCache.get_from_session(...)(×3, lignes 1595/1596/1597) ; seule_get_from_sessionexiste (cvntrade_cache_interface.py:98). → l'AttributeErrorobservée. - Clés non écrites — aucun call-site de
_store_in_sessionn'écritlast_trained_model/last_trained_feature_names/last_trained_config(grep littéral des call-sites,src/) ; ces littéraux n'apparaissent nulle part dans le repo hors les 3 lectures deablation_runner(écarte une clé dynamique f-string ou un writer hors-src/). Donc même en corrigeant (1),_get_from_session("last_trained_model")renverraitNone→event=base_model_no_model→ toujours0 results.
Cause amont réelle (ablation_runner.py:1582) : model_dict["model"] = getattr(result, "model", None) renvoie None — le VariantResult de train_weighted_variant ne porte pas l'objet modèle, ce qui déclenche le fallback (mort). Les trois conditions sont cumulatives : le chemin runtime _train_base_model n'a donc jamais été fonctionnel depuis son écriture.
Pourquoi ça n'a jamais été attrapé — deux contributeurs distincts¶
| Contributeur | Nature | Preuve |
|---|---|---|
| N'a jamais tourné en prod | program management | Les facteurs runtime (cost_scenario, slippage_model, funding_rate, order_type) sont en Phase 6 « Cost Validation » (protocol.py:97) ; le tuning est en Phase 0/1a/1b. (Inférence renforcée par Loki : 1 seul report_factor_generated factor=cost_scenario sur 7 j — ce run ; vérif directe finetune_runs non faite, pas d'accès PG.) |
| Aucun test ne l'aurait attrapé en CI | trou de process — ADR-58 | ADR-58 exige pour tout facteur FTF un guardrail + test d'intégration. Aucun test d'intégration n'exerce un facteur runtime de bout en bout → pourriture latente non détectée. C'est la cause-racine organisationnelle. |
La racine n'est donc pas seulement « un mauvais nom de méthode » : c'est l'absence du test d'intégration ADR-58 pour la classe runtime, qui a laissé un chemin non fonctionnel passer la CI pendant 2 mois. Le correctif « + test e2e runtime » en découle directement.
Provenance — pré-existant, #1117 entièrement exonéré¶
| Vérif §0bis | Résultat |
|---|---|
git blame ligne 1595 (défaut 1) |
fab6dd8b (2026-04-10, « feat(#491): FTF Batch 2 — runner ») — ~2 mois avant #1117. (Le blame n'attribue à #491 que le défaut 1 ; les défauts 2 — writer absent — et 3 — l.1582 result.model=None — sont des omissions/conditions, pas des lignes ajoutées par #491. Net : #491 a écrit le chemin, qui n'a jamais été fonctionnel.) |
git show 79d281f9 (#1117) |
ne touche pas _train_base_model (la méthode qui contient les l.1582/1595), ni get_from_session/cached_model. → ni le crash, ni la cause amont (l.1582) ne sont des artefacts #1117. |
| Image du run | 79d281f9 (#1117 présent) — deploy 13:51 success, run 14:19, aucun deploy entre. L'hypothèse « image = ab2bc4ea sans la feature » était stale. |
| Prior data | btc_features a réussi récemment sur AAVEUSDC (un des 5 cryptos à 0 ici) → data + entraînement base-model marchent → bug factor-class, pas une panne data. |
Pourquoi c'était invisible dans Loki (lien #1134)¶
La cause a dû être lue dans le log Airflow per-pod, pas dans Loki : les events du logger module commun.finetune.ablation_runner (base_model_exception, skip_fold) et regime_trainer (weighted_variant_evaluated) ne remontent pas à Loki depuis les pods compute, alors que les lignes airflow.task du même pod, oui → propagation de logger, pas scrape (GH #1134). Conséquence transverse : les gardes fail-loud de l'incrément 2 (baseline_basis_mismatch, baseline_compute_failed) émettent via ce même logger sur ces mêmes pods → sans #1134, elles tirent dans le vide.
Impact — un bug de classe runtime, pas de cost_scenario¶
Le défaut est dans le chemin runtime partagé _train_base_model → les 4 facteurs de la Phase 6 « Cost Validation » sont morts à l'identique : cost_scenario, slippage_model, funding_rate, order_type (protocol.py:97) — tous factor_type="runtime" (vérifié dans ablation_matrix.py). Ce n'est pas un incident cost_scenario.
- Bloque CVN-N001-EI-S12 →
Tested: aucun facteur runtime ne produit de données → aucune baseline à valider. La Story resteIn testing(ne pas avancer = ne pas fabriquer le gate). - Dépendance croisée #1129 ↦ #1133 : le follow-up slippage #1129 porte sur
slippage_model, lui aussi runtime → #1129 n'est pas validable tant que #1133 n'est pas livré. (Bonus : ça confirme rétroactivement le report (b) du keying par slippage de l'incrément 2 — la dimension slippage n'est même pas exécutable avant #1133.) - Le périmètre du correctif #1133 et de son test e2e doit donc couvrir la classe runtime, pas seulement
cost_scenario.
Correctif proposé (Story dédiée — pas un one-liner)¶
Faire que _train_base_model obtienne réellement le modèle entraîné depuis train_weighted_variant :
- Option 1 (recommandée) : exposer le modèle fitté +
feature_names+training_configsur leVariantResultet les lire directement — supprimer le fallback session-cache mort. - Pourquoi c'est gratuit : le base-model reste in-process dans le pod — les variants runtime le perturbent dans le même process (
engine.run_with_model(base_model["model"], …)reçoit l'objet en mémoire), donc le porter surVariantResultn'implique ni XCom ni sérialisation d'un modèle LGB. - Pourquoi supprimer le fallback :
_session_cacheest un attribut de classe (cvntrade_cache_interface.py:52), donc un état process-global partagé ; s'en remettre à lui pour « le dernier modèle entraîné » est un couplage global fragile. Le passage explicite parVariantResultl'élimine. - Option 2 : si la réutilisation par session est voulue, écrire les clés côté entraînement (
_store_in_session("last_trained_model", …)) et corriger la lecture en_get_from_session— mais conserve le couplage session-global. - Dans les deux cas : test d'intégration ADR-58 faisant tourner un facteur de chaque classe (au moins un runtime) de bout en bout, pour que ce chemin ne pourrisse plus de façon latente.
Prévention — le couple anti-récurrence¶
Deux trous distincts, deux remèdes :
| Trou | Remède | Issue |
|---|---|---|
| Attraper plus tôt — un chemin non fonctionnel a passé la CI 2 mois | test d'intégration runtime e2e (ADR-58) dans la suite CI | #1133 |
| Voir quand ça arrive — la cause était invisible dans Loki | propager le logger commun.* des pods compute vers Loki |
#1134 |
Prochaines étapes (ordonnées)¶
- Sélectionner/ouvrir la Story de correctif (#1133) — fix Option 1 + test d'intégration ADR-58 couvrant la classe runtime.
- Traiter #1134 en prérequis observabilité (sinon les gardes #1117 — et le diagnostic de tout pod compute — restent invisibles).
- Re-lancer la validation S12
cost_scenarioaprès #1133 → exécuter la preuve per-cost (COUNT(DISTINCT cost_bps) ≥ 2, 0 orpheline) → alors seulementIn testing → Tested.