RCA — persistance runtime cassée : audit de parité base_result ↔ finetune_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_capturedré-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_resultsETfinetune_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_buy…n_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 .get → None → 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" …
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 None → NULL 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 None → INSERT 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),
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_resultnipersist_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/metrics — ni 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"],
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 validable → disposition = 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_resultsvide 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¶
- Story de correctif sibling sous l'Epic EI : fix
feature_hashETmodel_hyperparamsruntime + le test e2e-=-preuve-SQL. - #1134 : reproduire le non-déterminisme de shipping avant tout fix (prérequis fail-loud #1117).
- Discard la DLQ de
ftf_20260608_160819_071b72. - Re-run
cost_scenario→finetune_resultspeuplé 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¶
- Run :
ftf_20260608_160819_071b72_ATR0.5_1.5_H4 - Sibling : plan de fix S13 · RCA cost_scenario no_data · obs. #1134
- Couche 2 : #1115 (S12 inc-1a/1c,
200cb484) · Epic : CVN-N001-EI