Skip to content

RCA — persistance runtime cassée : audit de parité base_resultfinetune_results/finetune_baselines (révélé par S13)

Révision 3 — 2026-06-08 · statut : analyse (corrigée). Historique + delta en § Révisions. La v3 ferme réellement la série : 2ᵉ table de parité (finetune_baselines), predictions_captured ré-analysé (défaut .get, pas DEFAULT colonne), compte exact (68), couche-2 prouvée par diff, et #1134 = reproduire-avant-fixer (la race d'init est écartée par la timeline).

Root Cause Analysis de l'échec de persistance du run de validation S13/S12 du 2026-06-08 (ftf_20260608_160819_071b72_ATR0.5_1.5_H4). Découvert via loki-query. Sibling de S13 / RCA cost_scenario no_data.

Stratégie : le chemin runtime entier n'a jamais été exercé (masqué par le 0-results) → défauts latents qui se révèlent en série, un par run. Les traiter un-à-un = N cycles. Cette RCA fait l'audit de parité exhaustif des DEUX chemins de persistance (finetune_results ET finetune_baselines) pour fermer la série en une fois.

En une phrase

Le run valide le fix base-model de S13 (les facteurs runtime produisent enfin de vrais résultats, à cost_bps distincts dans les logs/DLQ) — mais l'e2e reste incomplet : la persistance finetune_results échoue. L'audit de parité révèle exactement 2 gaps vs le chemin training : feature_hash (DLQ, bruyant) et model_hyperparams (silencieux, dead-join-key). Fixer seulement le bruyant livrerait un re-run qui insère mais ne joint pas. Le 2ᵉ chemin (finetune_baselines) est sans gap.

Audit de parité #1 — _run_runtime_variant.base_result ↔ INSERT finetune_results (68 colonnes)

Méthode : énumération des 68 colonnes de l'INSERT (persistence.py:54-69), croisées avec les clés du base_result runtime (ablation_runner.py:1455-1488) et du row training (:1295-1345), + nullabilité + l'expression de valeur exacte dans persist_result.

Réconciliation des 68 colonnes (exacte, pas de « ~ »)

Bucket n couverture
Posées par training, omises par runtime 3 feature_hash, model_hyperparams, predictions_captured (analysées ci-dessous)
Métriques étendues (f1_buyn_train_samples, cols 30-64) 35 présentes des deux côtés via _extract_extended_metrics
Identité/clés + fenêtres + métriques de base (cols 1-21, 26-29, 65-67) 28 présentes des deux côtés (base_result direct) ou sourcées des params/config (run_id, git_sha, strategy, timeframe)
Omises des deux côtés, nullable (calibration_method, slippage_model, error, powered) 2 NULL des deux côtés — pas de régression runtime-spécifique
Total 68

Seuls 3 champs divergent training↔runtime ; 2 sont des gaps réels.

Les 3 champs divergents — et pourquoi l'asymétrie tient à l'expression .get de persist_result

Colonne valeur dans persist_result défaut .get ? runtime omet → insère NOT NULL ? RISQUE
feature_hash result.get("feature_hash") (:115) non NULL explicite OUI (DEFAULT 'unknown') DLQ
model_hyperparams json.dumps(…) if result.get("model_hyperparams") else None (:119) non (→ None) NULL silencieux non (nullable) SILENT-NONJOINABLE
predictions_captured bool(result.get("predictions_captured", **False**)) (:167) oui (False) False (jamais NULL) non (DEFAULT False) OK

Correction d'audit (rév. 3) : predictions_captured est sauvé non pas par le DEFAULT False de la colonne (qui serait court-circuité — voir sémantique Postgres ci-dessous, la colonne est listée) mais par le défaut .get("predictions_captured", False) dans persist_result. C'est donc bien 2 champs à fixer, pas 3 — mais pour la bonne raison. L'asymétrie est dans l'expression .get : feature_hash/model_hyperparams n'ont pas de défaut .getNone → NULL ; predictions_captured en a un → False.

Le gap bruyant — feature_hash (DLQ)

QUOTE :

{persistence.py:172} WARNING event=db_write_retry attempt=3/3 error=null value in column "feature_hash" of relation "finetune_results" violates not-null constraint
{persistence.py:372} ERROR   event=db_write_dlq run_id=ftf_20260608_160819_071b72_… error=null value in column "feature_hash" …
Sémantique Postgres (ancrée) : feature_hash TEXT NOT NULL DEFAULT 'unknown' — un DEFAULT n'est appliqué que si la colonne est OMISE de l'INSERT. persist_result liste la colonne et passe NoneNULL explicite → le DEFAULT n'est pas invoqué → violation NOT NULL → DLQ. (Réf. PostgreSQL : « If no value is given for a listed column, the column will be filled with its default value » — donc lister la colonne avec NULL insère NULL, pas le DEFAULT.)

Le gap silencieux — model_hyperparams (le vrai danger)

Colonne nullable : persist_result passe NoneINSERT réussit, valeur NULL, aucun DLQ. Conséquence : toutes les rows runtime seraient non-joignables à leur config HPO = exactement la dead-join-key (model_hyperparams 0/9216) que CVN-N001-EI-S11/S12 existent pour tuer — ressuscitée pour le runtime, invisible. Piège d'audit (b1 false-green) : la preuve b1 joint sur (run_id, crypto, fold, cost_bps), pas sur model_hyperparams. Donc une fois feature_hash corrigé, les rows s'insèrent, b1 passe, S12 → Tested vert — pendant que les rows runtime sont silencieusement orphelines de config. Fixer le seul feature_hash fabrique un vert sur le sin original.

Audit de parité #2 — _ensure_baseline dict ↔ INSERT finetune_baselines (sans gap)

Le chemin baseline construit un second dict à-la-main (ablation_runner.py _ensure_baseline) → persist_baseline (persistence.py:195-218) → finetune_baselines. Même failure-shape potentielle ; audité ici (pas seulement flaggé).

Colonne finetune_baselines NOT NULL ? source dans persist_baseline couvert ?
run_id OUI param run_id
strategy OUI config.get("strategy") ✅ (présent dans self._config)
crypto OUI dict _ensure_baseline
fold_id OUI dict
baseline_type OUI dict (buy_and_hold/random/naive)
cost_bps OUI (014) dict (int(cost_bps))
sortino/total_return/n_trades/win_rate/max_drawdown/test_start/test_end non dict + **bdata

Les 6 colonnes NOT NULL sont toutes peuplées → aucun DLQ baseline. La série de persistance est donc bornée aux 2 gaps de finetune_results : pas de 3ᵉ cycle caché côté baselines. (Note : finetune_baselines n'a ni feature_hash ni model_hyperparams — colonnes inexistantes ; le bug est spécifique à finetune_results.)

Provenance — trois couches (S13 exonéré ; #1115 = déclencheur, prouvé par diff)

Couche Quoi Commit Preuve
1 — omission base_result runtime à-la-main, sans feature_hash/model_hyperparams 67229093 (2026-04-17, ère FTF runner #491/#564) git blame du bloc base_result
2 — déclencheur retire le défaut "unknown" de feature_hash et ajoute model_hyperparams à l'INSERT 200cb484 (2026-06-05, S12 inc-1a/1c, #1115) git show (ci-dessous)
3 — révélateur le chemin runtime produit enfin des rows à persister #1136 (S13) logs run_start runtime

Couche 2 — diff direct (git show 200cb484 -- persistence.py), pas une inférence :

-                            result.get("feature_hash", "unknown"),
+                            result.get("feature_hash"),
+                            (json.dumps(result["model_hyperparams"]) if result.get("model_hyperparams") else None),
Avant #1115 : get("feature_hash", "unknown") → omission runtime insérait 'unknown' (sentinelle, pas de DLQ). #1115 a retiré le défaut "unknown" (pour écrire le vrai hash sur les rows training) → omission runtime devient None → NULL → DLQ. Et model_hyperparams est ajouté par #1115, nullable, jamais posé en runtime → NULL silencieux dès l'origine.

  • S13/#1136 exonéré : ne touche ni base_result ni persist_result.
  • S12 inc-1a/1c (#1115) = cause contributive prouvée : son but (tuer la sentinelle 'unknown', vrais hash/HP joignables) était juste — pour le seul chemin training ; il a converti l'omission runtime latente en DLQ (feature_hash) + dead-key silencieux (model_hyperparams). Ironie auditable : le commit qui tue la dead-join-key côté training la ressuscite côté runtime.

Challenge #1134 — reproduire avant de prescrire (la RCA s'applique sa propre serrure)

Contradiction réelle : au run no_data (2026-06-07), skip_fold/base_model_exception (logger CVNTrade.FTF.Runner) ont fait feu (log per-pod) mais étaient absents de Loki ; ce run, run_start (même logger) a shippé via stdout F.

Faits établis : - git diff --name-only 79d281f9 9a86bd66 ne touche aucun fichier logging/conftest/airflow-cfg/otel → config logging identique entre les deux images → l'hypothèse « un deploy a changé le logging » est écartée. - Le chemin est stdout (stdout F), pas OTLP.

Mécanisme — NON déterminé (et c'est le point) : la v2 prescrivait « race d'init du handler + fix : handler stdout explicite à l'entrée FTF ». Contredit par la timeline : les events ratés (skip_fold/base_model_exception) et l'event shippé (run_start) font tous feu après l'entraînement du base-model — des minutes après l'init du pod. Une race d'init serait résolue bien avant l'émission de l'un ou l'autre → elle n'explique ni le raté ni le shippé. Mécanisme retiré. Prescrire un fix matché à un mécanisme non confirmé = précisément le motif que cette RCA combat (fix → passe son propre test → prod cassée → cycle suivant).

Prescription (rév. 3) — appliquer la serrure : reproduire d'abord — forcer un event compute-pod (INFO + ERROR) dans des conditions comparables, observer le line-filter Loki → confirmer le facteur (niveau ? timing ? état pipeline d'ingestion ? identité de pod ?) → alors fixer. Verdict net entre-temps : le shipping CVNTrade.* → stdout est non-déterministe par run-de-pod → on ne peut pas affirmer « l'échec runtime serait visible dans Loki » → la prémisse des gardes fail-loud #1117 est compromise. #1134 grandit (observabilité non-déterministe), mais son fix attend la repro.

Côté positif — ce run valide le fix base-model de S13 (scope précis)

Les rows en DLQ + les logs run_start portent de vraies métriques à cost_bps distincts (10/20/30) → le fix base-model S13 fonctionne et le runtime produit des coûts distincts. Scope précis : ce qui est démontré, c'est la production de cost distincts (logs/DLQ), pas que la preuve b0-SQL (COUNT(DISTINCT cost_bps) sur finetune_results) passe — finetune_results est vide (tout en DLQ). L'e2e reste incomplet (persistance échoue).

Correctif proposé (les DEUX champs, même provenance que training)

Le model_dict posé par S13 porte model/feature_names/training_config/metricsni feature_hash ni hpo_params. Le fix les porte depuis le VariantResult du base-model (qui a les deux), puis les pose en runtime :

# _train_base_model — étendre model_dict (le VariantResult `result` est en scope) :
model_dict["feature_hash"] = result.feature_hash
model_dict["hpo_params"]   = result.hpo_params

# _run_runtime_variant — base_result (les variants partagent le base-model) :
"feature_hash":     base_model["feature_hash"],
"model_hyperparams": base_model["hpo_params"],
Provenance identique au training (les attributs du VariantResult du base-model), zéro re-dérivation (la v1 proposait _feature_set_hash(...) côté runtime → risque de divergence training↔runtime ; abandonné). Aligne avec l'intention S12 inc-1a/1c. Alternative rejetée : remettre 'unknown'/coalescer dans persist_result — réintroduit la sentinelle/dead-key que #1115 a combattue.

La serrure — un e2e dont les assertions SONT la preuve SQL de S12

Le test ADR-58 de S13 a laissé passer ce bug : il couvrait la production (results non vide), pas la persistance. Le test qui ferme la série asserte le critère de Tested lui-même : - une row runtime persistée (pas DLQ), - feature_hash ET model_hyperparams non-NULL (pas seulement « pas de DLQ » — sinon model_hyperparams se recache), - join per-cost : ≥2 cost_bps distincts, 0 row orpheline sur (run_id,crypto,fold,cost_bps).

Disposition de la DLQ (intégrité des données)

Rows de ce run_id dans committee/sessions/dlq/dlq_20260608.jsonl. Le re-run produira des rows fraîches sous un nouveau run_id. La DLQ de ftf_20260608_160819_071b72 = run échoué jamais validabledisposition = DISCARD (marquer/purger ce run_id), pas replay : un futur drain ressusciterait un run partiel (rows feature_hash/model_hyperparams NULL). Ne pas rejouer.

Impact

  • Bloque S12 → Tested : finetune_results vide ce run → preuve per-cost impossible. Re-run requis après le fix des deux champs.
  • Sibling follow-up de S13, même classe runtime.

Prochaines étapes

  1. Story de correctif sibling sous l'Epic EI : fix feature_hash ET model_hyperparams runtime + le test e2e-=-preuve-SQL.
  2. #1134 : reproduire le non-déterminisme de shipping avant tout fix (prérequis fail-loud #1117).
  3. Discard la DLQ de ftf_20260608_160819_071b72.
  4. Re-run cost_scenariofinetune_results peuplé et joignable → preuve SQL per-cost → S12 → Tested.

Révisions

Rév Date Delta Déclencheur
v1 2026-06-08 Analyse mono-champ : feature_hash omis → DLQ ; fix par re-dérivation _feature_set_hash(...). diagnostic initial
v2 2026-06-08 Audit de parité finetune_results. +jumeau silencieux model_hyperparams (b1 false-green) ; fix par sourçage (pas re-dérivation) ; couche-2 #1115 contributive ; #1134 tranché (config identique) ; DLQ=discard ; e2e-lock ; scope « fix base-model » ; journal de révisions. revue 1 (challenge « silencieux raté »)
v3 2026-06-08 Ferme réellement la série. (1) 2ᵉ table de parité finetune_baselines → sans gap (série bornée). (2) predictions_captured ré-analysé : OK par le défaut .get(...,False) de persist_result, pas le DEFAULT colonne (court-circuité) → 2 champs, bon rationale. (3) #1134 : race d'init écartée par la timeline (events post-entraînement) → prescription = reproduire avant fixer. (4) compte exact 68 réconcilié (3 divergents + 35 étendues + 30 autres), zéro « ~ ». (5) couche-2 prouvée par git show 200cb484 (get(…, "unknown")get(…)) ; b0 scopé (production, pas b0-SQL). revue 2 (5 points audit-grade)

Liens