Skip to content

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_H4factor=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ù le 0 results uniforme 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) :

  1. Mauvais nom de méthode — appelle CVNTradeCache.get_from_session(...) (×3, lignes 1595/1596/1597) ; seule _get_from_session existe (cvntrade_cache_interface.py:98). → l'AttributeError observée.
  2. Clés non écritesaucun call-site de _store_in_session n'écrit last_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 de ablation_runner (écarte une clé dynamique f-string ou un writer hors-src/). Donc même en corrigeant (1), _get_from_session("last_trained_model") renverrait Noneevent=base_model_no_model → toujours 0 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_modelles 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 reste In 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_config sur le VariantResult et 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 sur VariantResult n'implique ni XCom ni sérialisation d'un modèle LGB.
  • Pourquoi supprimer le fallback : _session_cache est 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 par VariantResult l'é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)

  1. Sélectionner/ouvrir la Story de correctif (#1133) — fix Option 1 + test d'intégration ADR-58 couvrant la classe runtime.
  2. Traiter #1134 en prérequis observabilité (sinon les gardes #1117 — et le diagnostic de tout pod compute — restent invisibles).
  3. Re-lancer la validation S12 cost_scenario après #1133 → exécuter la preuve per-cost (COUNT(DISTINCT cost_bps) ≥ 2, 0 orpheline) → alors seulement In testing → Tested.

Liens

  • Cause : GH #1133 · Observabilité : GH #1134
  • Feature mergée (revue comité PASSED) ; validation système en attente de #1133 : PR #1117 (79d281f9) · Slippage follow-up : GH #1129 (bloqué par #1133)
  • Hub Story : S12 · Epic : CVN-N001-EI