diff --git a/.github/workflows/scripts-tests.yml b/.github/workflows/scripts-tests.yml index b05db358dd..cb613e163e 100644 --- a/.github/workflows/scripts-tests.yml +++ b/.github/workflows/scripts-tests.yml @@ -80,6 +80,7 @@ on: # ci-dessous. - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bake_*.py' - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json' + - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py' - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py' # #11835 no-op guard: scripts/tests/test_pr_gate_rerun_noop_guard.py pins # the job-level if: of pr-gate-rerun.yml. Without this entry a PR touching @@ -163,6 +164,7 @@ on: # #19695 banc de reference TTS (mirror of the push path above). - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bake_*.py' - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json' + - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py' - 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py' # #11835 no-op guard (mirror of the push path above). - '.github/workflows/pr-gate-rerun.yml' diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_README.md b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_README.md new file mode 100644 index 0000000000..446a2e9e54 --- /dev/null +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_README.md @@ -0,0 +1,142 @@ +# Banc de référence persistant des moteurs TTS + +**Issue** : [#19695](https://github.com/jsboige/CoursIA/issues/19695) +**Schéma** : `prosody_lab/bank_schema_v1.json` +**Outils** : `prosody_lab/bake_append.py` · `prosody_lab/bake_report.py` · `prosody_lab/bootstrap_bank.py` +**Tests** : `prosody_lab/test_bake_bank.py` + +## À quoi ça sert + +Chaque campagne de mesure TTS (`bakeoff_small/measure_*.py`, `bakeoff_large/banc_phase_a0.py`, re-rendus UAT sur `G:/Mon Drive/MyIA/Projets/BibliothequesSonores/A0-review-*`) produit un JSON isolé. **Avant le banc**, ces silos ne se cumulent pas : impossible de dire « PocketTTS est meilleur que CosyVoice3 sur la famille Boule de Suif » sans rouvrir chaque fichier un par un. + +**Avec le banc** : un fichier `bake_bank.json` (**tableau JSON**, un objet par run) cumule les runs par `(motor, extract, seed)`. + +**Soclage** : le socle d'écriture (`bake_append.py`, `bake_report.py`, `bank_schema_v1.json` requis) vient du socle durci mergé par #19820 ; cette PR (#19722) porte la couche étendue — ingestion bootstrap (`bootstrap_bank.py`) et champs optionnels de fidélité/prosodie/traçabilité moteur. **Deux vocabulaires de fidélité coexistent** dans le schéma : celui du socle (`inserted_words`, `omitted_segments`, `voice_stable`, `hallu_per_100_syl`) et les champs étendus du banc TTS (gate pré-UAT #17586 : `fidelity_added_words` 0 = pass, `fidelity_omitted_segments_3plus` 0 = pass, `voice_consistent` true = pass). Les deux sont optionnels : un run du socle seul reste valide. + +## Schéma v1 + +| Champ | Type | Description | +|---|---|---| +| `schema_version` | const `"v1"` | Version figée. Évolutions → v2/v3, jamais casser v1. | +| `ts` | ISO 8601 UTC | Timestamp du run. | +| `machine` | enum | `myia-po-2023`, `myia-po-2024`, `myia-po-2025`, `myia-po-2026`, `myia-po-2027`, `myia-ai-01`, `other`. | +| `motor` | string | Identifiant normalisé (ex. `qwen3_tts_1_7b_customvoice`). | +| `motor_size_b` | number | Taille en milliards de paramètres. | +| `motor_license` | string SPDX | Licence (Apache-2.0, MIT, Research-Only, etc.). | +| `extract` | string | Identifiant du passage de référence. Le schéma le type **`string`** (chaîne libre en snake_case), **pas** une énumération fermée : une valeur hors A-C reste valide au schéma. Valeurs en usage : A (narration courte), B (dialogue court), C (narration longue) — cf. `bank_schema_v1.json` et `prosody_lab/bench_extracts/extract_{A,B}_*.txt`. | +| `extract_text_sha256_prefix` | hex 16 | Verrou anti-réécriture silencieuse du texte. | +| `seed` | int | Graine de sampling (le schéma exige un entier ; défaut 42 à l'ingestion). | +| `wer` | float | Word Error Rate mesuré (0.0 = parfait). | +| `wer_model` | string | Modèle ASR utilisé (`openai/whisper-large-v3`, etc.). | +| `rtf` | float | Real-Time Factor (wallclock/duration_s ; <1 = plus rapide que le temps réel). | +| `vram_mb` | float | Pic VRAM (Mo). | +| `duration_s` | float | Durée audio synthétisée. | +| `fidelity_added_words` | int | Mots ajoutés au-delà du ref (0 = pass). | +| `fidelity_omitted_segments_3plus` | int | Segments 3+ mots omis sous ≥2 ASR/3 (0 = pass). | +| `voice_consistent` | bool | Voix stable sur la durée (true = pass). | +| `hallu_per_100_syl` | float | Densité d'hallucinations par 100 syllabes. | +| `inserted_words` | int | Mots insérés par l'ASR (vocabulaire socle #19820). | +| `omitted_segments` | int | Segments ≥3 mots absents de ≥2 ASR/3 (vocabulaire socle #19820). | +| `voice_stable` | bool | Stabilité vocale, verdict 3-ASR (vocabulaire socle #19820). | +| `notes` | string ≤ 2000 | Notes libres. | +| `source_path` | string | Chemin source ingéré (traçabilité bootstrap). | + +## Usage + +### Bootstrap (ingestion des sources existantes) + +```bash +python prosody_lab/bootstrap_bank.py --bank prosody_lab/bake_bank.json +# mode dry-run pour tester sans écrire : +python prosody_lab/bootstrap_bank.py --bank prosody_lab/bake_bank.json --dry-run +# limiter à une source (bakeoff_small | bakeoff_large | gdrive | all) : +python prosody_lab/bootstrap_bank.py --bank prosody_lab/bake_bank.json --source bakeoff_small +``` + +Le bootstrap scanne `bakeoff_small/results/`, `bakeoff_large/results/` et `BibliothequesSonores/A0-review-*`, mappe chaque JSON vers un run (socle + champs étendus présents) et **délègue l'écriture à `bake_append.py` du socle** (CLI `--run`). Les types hétérogènes entre campagnes sont normalisés (`size` `"0.5B"` → `0.5`, `g_st_range`/`g_cv` → `prosody_*`, sous-dicts GDrive `wer.wer`/`seed.base`/`synth.*` aplatis, graphie moteur ramenée en snake_case). Idempotent : l'upsert de `bake_append.py` par `(motor, extract, seed)` remplace un run de même clé au lieu de le dupliquer. + +### Append manuel (un run isolé) + +```bash +python prosody_lab/bake_append.py \ + --bank prosody_lab/bake_bank.json \ + --machine myia-po-2023 \ + --run '{ + "ts": "2026-10-10T12:00:00Z", + "machine": "myia-po-2023", + "motor": "qwen3_tts_1_7b_customvoice", + "extract": "A", + "seed": 42, + "duration_s": 14.2, + "wer": 0.08, + "motor_license": "Apache-2.0", + "source_path": "runs/run-20260924-143012/A0C-bakeoff/qwen3_tts_customvoice/metrics.json" + }' +``` + +Autres modes du socle : `--run fichier.json` (chemin vers un fichier JSON), `--file` (objet ou liste), `--ingest-root` (scan d'un répertoire de résultats), `--dry-run`, `--strict`. + +### Lecture (gate #17586) + +```bash +# Sortie stdout +python prosody_lab/bake_report.py --bank prosody_lab/bake_bank.json +# Écriture fichier (NON versionnée — usage local) +python prosody_lab/bake_report.py --bank prosody_lab/bake_bank.json \ + --out /tmp/bake_report_$(date +%Y%m%d-%H%M).md +``` + +Le tableau est trié par **une seule clé** (`--sort`, `wer` par défaut ; valeurs possibles `wer`, `rtf`, `duration_s`, `ts`), en ordre **croissant** — il n'y a ni clé secondaire ni tri par `extract`/`motor`. Les runs dont la clé de tri est **absente** (null) sont placés **en fin** de tableau, quel que soit l'ordre des autres. + +### Tests unitaires (suite CI existante — décision ai-01 sur #19695) + +```bash +python MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py +``` + +Script autonome (exit 0/1), collecté par la jambe `Scripts Tests (CPU)` de `.github/workflows/scripts-tests.yml` — ses fichiers-sujets (`bake_append.py`, `bake_report.py`, `bootstrap_bank.py`, `bank_schema_v1.json`) sont dans ses déclencheurs `paths:`. Pas de nouveau workflow CI bloquant (décision ai-01). + +15 cas : schéma valide, dry-run valide, échecs de validation (champs manquants, type, enum union), idempotence, upsert, tri du rapport (WER, chrono `--sort ts`), ingestion bakeoff_small, **couche #19722** (champs étendus optionnels au schéma, mapping `bootstrap_bank._metrics_to_run`, décodeurs GDrive, non-régression expansion unique de `main()` sur conteneur multi-extraits). + +## Intégration proposée dans p7_verify — **non livrée** + +**Statut : proposée, pas livrée.** Vérifié dans le fichier : `p7_verify.py` ne contient **aucune** occurrence de `bake_append` ni de `bank` — le runner de mesure du pipeline v4 n'appelle donc pas encore le banc. Le pseudo-code ci-dessous décrit l'intégration **envisagée** (hook best-effort en fin de run, dont l'échec d'ingestion n'arrête pas la mesure : c'est un signal, pas un gate) ; il n'est **pas** en vigueur et attend sa propre issue de suivi. + +```python +# p7_verify.py (pseudo-code — PROPOSÉ, non livré) +result = measure_pipeline(...) +try: + subprocess.run(["python", "prosody_lab/bake_append.py", + "--bank", "prosody_lab/bake_bank.json", + "--run", json.dumps({ + "ts": result.ts, "machine": result.machine, + "motor": result.motor_id, "extract": result.extract_id, + "seed": result.seed, "duration_s": result.duration_s, + "wer": result.wer, "source_path": result.metrics_path, + }, ensure_ascii=False)], + check=False, timeout=30) +except Exception as e: + log.warning(f"bake_append a échoué : {e}") +``` + +## Critères d'acceptance + +1. **Schéma versionné** (`bank_schema_v1.json`) commité, avec champs typés et commentaires. ✅ +2. **Au moins 6 runs ingérés** (3 moteurs × 2 extraits) au moment de la PR. +3. **Rapport markdown** généré, trié par WER. NON commité (lecture locale). +4. **CI** : vérif schéma et unicité via `pytest test_bake_bank.py` — **pas** de nouveau workflow CI (décision ai-01). +5. **Documentation** : ce README (usage, schéma, comment rejouer une mesure). ✅ + +## Pré-requis pour les 2 autres issues audio + +- **#19692** (CosyVoice3 pivot narrateur) : banc d'entrée `extract=A` au moment du pivot. +- **#19694** (S2 Pro local) : banc d'entrée `extract=B` au moment du dé-API. + +Ces deux issues sont **bloquées** par la disponibilité d'au moins 1 run par `(motor, extract)` à comparer. + +## Anti-patterns + +- **Modifier le schéma v1** : casserait la rétrocompat. Évolutions = v2. +- **Committer le rapport** : comme "preuve" — l'audit peut le régénérer via la commande, le diff d'un rapport n'est pas un signal de progression. +- **Ajouter une colonne au CSV sans version** : la table ne se lit qu'en tableau JSON validé par le schéma, ou alors exporter en CSV via un script dédié (out of scope v1). +- **Sauter la validation `jsonschema`** : le validateur interne est un filet, pas la garantie principale. Si jsonschema n'est pas disponible, `pip install jsonschema` dans le venv avant la première ingestion. diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json index f3fb7288a6..406c2a367f 100644 --- a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json @@ -1,92 +1,282 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/jsboige/CoursIA/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json", - "title": "bake_bank_v1", - "description": "Schéma v1 du banc de référence persistant des moteurs TTS (issue #19695, dispatch ai-01 07/10 09:08Z). Cumul cross-campagne wer/RTF/VRAM par (motor, extract, seed). Champs de fidélité ajoutes par cadrage coordinateur : inserted_words, omitted_segments, asr_models.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "required": ["ts", "machine", "motor", "extract", "seed", "duration_s"], - "properties": { - "ts": { - "type": "string", - "format": "date-time", - "description": "Timestamp ISO-8601 UTC du run (collecte first-hand, jamais retro-actif)." - }, - "machine": { - "type": "string", - "enum": ["myia-po-2027", "myia-ai-01", "myia-po-2024", "myia-po-2025", "myia-po-2026", "myia-po-2023", "other"], - "description": "Lane de la machine qui a execute le run." - }, - "motor": { - "type": "string", - "description": "Identifiant moteur (snake_case). Exemples : chatterbox_mtl_v3, qwen3_tts_customvoice, pocket_tts, cosyvoice3_0_5b, zonos_v0_1, oute_tts_1_0_1b." - }, - "extract": { - "type": "string", - "description": "Identifiant extrait audio (snake_case). Exemples : A (narration courte), B (dialogue court), C (narration longue)." - }, - "seed": { - "type": "integer", - "description": "Graine de generation (determinisme reproductibilite)." - }, - "wer": { - "type": ["number", "null"], - "minimum": 0.0, - "maximum": 1.0, - "description": "Word Error Rate (Levenshtein normalise). Null si ASR non charge au run." - }, - "rtf": { - "type": ["number", "null"], - "minimum": 0.0, - "description": "Real-Time Factor = wallclock / duration_s. <1 = plus rapide que wall-clock." - }, - "vram_mb": { - "type": ["integer", "null"], - "minimum": 0, - "description": "VRAM pic en MB pendant le run. Null si CPU-only." - }, - "duration_s": { - "type": "number", - "minimum": 0.0, - "description": "Duree du clip rendu en secondes." - }, - "voice_stable": { - "type": ["boolean", "null"], - "description": "Verdict subjectif stabilite vocale (DRIFTING detecte par 3ASR). Null si non evalue." - }, - "hallu_per_100_syl": { - "type": ["number", "null"], - "minimum": 0.0, - "description": "Hallucinations par 100 syllabes (mots inseres par l'ASR sur hyp non-reference). Null si non evalue." - }, - "inserted_words": { - "type": ["integer", "null"], - "minimum": 0, - "description": "Nombre de mots inseres par l'ASR (faux positifs). Detecte par Levenshtein sur le run. Cumul cross-ASR si asr_models contient plusieurs modeles." - }, - "omitted_segments": { - "type": ["integer", "null"], - "minimum": 0, - "description": "Segments de >=3 mots absents de >=2 ASR sur 3 (defaut CosyVoice3 / Chatterbox). Detecte par measure_3asr.py::_detect_omissions." - }, - "asr_models": { - "type": ["array", "null"], - "items": { - "type": "string", - "enum": ["tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3", "distil-large-v3"] - }, - "description": "Modeles ASR utilises pour le WER (subset de faster-whisper)." - }, - "notes": { - "type": ["string", "null"], - "description": "Notes libres (ex : 'CPU run, Whisper-tiny non charge', 'DRIFTING detecte')." - } - } - }, - "uniqueness": { - "description": "Cle d'unicite d'un run : (motor, extract, seed). Re-append avec meme cle = upsert (remplace l'ancien), pas de doublon.", - "key": ["motor", "extract", "seed"] - } -} \ No newline at end of file +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/jsboige/CoursIA/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json", + "title": "bake_bank_v1", + "description": "Schema v1 du banc de reference persistant des moteurs TTS (issue #19695). Cumul cross-campagne wer/RTF/VRAM par (motor, extract, seed). Deux vocabulaires de fidelite coexistent : celui du socle merge (#19820 : inserted_words, omitted_segments, voice_stable, hallu_per_100_syl) et les champs etendus optionnels du banc TTS (#19722 : fidelity_added_words, fidelity_omitted_segments_3plus, voice_consistent, prosody_*, traceabilite moteur). Les champs etendus sont OPTIONNELS : un run du socle seul reste valide.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": [ + "ts", + "machine", + "motor", + "extract", + "seed", + "duration_s" + ], + "properties": { + "ts": { + "type": "string", + "format": "date-time", + "description": "Timestamp ISO-8601 UTC du run (collecte first-hand, jamais retro-actif)." + }, + "machine": { + "type": "string", + "enum": [ + "myia-po-2027", + "myia-ai-01", + "myia-po-2024", + "myia-po-2025", + "myia-po-2026", + "myia-po-2023", + "other" + ], + "description": "Lane de la machine qui a execute le run." + }, + "motor": { + "type": "string", + "description": "Identifiant moteur (snake_case). Exemples : chatterbox_mtl_v3, qwen3_tts_customvoice, pocket_tts, cosyvoice3_0_5b, zonos_v0_1, oute_tts_1_0_1b." + }, + "extract": { + "type": "string", + "description": "Identifiant extrait audio (snake_case). Exemples : A (narration courte), B (dialogue court), C (narration longue)." + }, + "seed": { + "type": "integer", + "description": "Graine de generation (determinisme reproductibilite)." + }, + "wer": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "maximum": 1.0, + "description": "Word Error Rate (Levenshtein normalise). Null si ASR non charge au run." + }, + "rtf": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Real-Time Factor = wallclock / duration_s. <1 = plus rapide que wall-clock." + }, + "vram_mb": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "VRAM pic en MB pendant le run. Null si CPU-only." + }, + "duration_s": { + "type": "number", + "minimum": 0.0, + "description": "Duree du clip rendu en secondes." + }, + "voice_stable": { + "type": [ + "boolean", + "null" + ], + "description": "Verdict subjectif stabilite vocale (DRIFTING detecte par 3ASR). Null si non evalue." + }, + "hallu_per_100_syl": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Hallucinations par 100 syllabes (mots inseres par l'ASR sur hyp non-reference). Null si non evalue." + }, + "inserted_words": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Nombre de mots inseres par l'ASR (faux positifs). Detecte par Levenshtein sur le run. Cumul cross-ASR si asr_models contient plusieurs modeles." + }, + "omitted_segments": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Segments de >=3 mots absents de >=2 ASR sur 3 (defaut CosyVoice3 / Chatterbox). Detecte par measure_3asr.py::_detect_omissions." + }, + "asr_models": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tiny", + "base", + "small", + "medium", + "large-v1", + "large-v2", + "large-v3", + "distil-large-v3" + ] + }, + "description": "Modeles ASR utilises pour le WER (subset de faster-whisper)." + }, + "notes": { + "type": [ + "string", + "null" + ], + "description": "Notes libres (ex : 'CPU run, Whisper-tiny non charge', 'DRIFTING detecte')." + }, + "schema_version": { + "type": "string", + "const": "v1", + "description": "Version du schéma (figé à v1 pour cette ligne)." + }, + "motor_size_b": { + "type": [ + "number", + "null" + ], + "description": "Taille du moteur en milliards de paramètres (null si non applicable)." + }, + "motor_license": { + "type": "string", + "description": "Licence SPDX (Apache-2.0, MIT, Research-Only, etc.)." + }, + "extract_text_sha256_prefix": { + "type": "string", + "pattern": "^[a-f0-9]{16}$", + "description": "Préfixe 16-hex du sha256 du texte de référence (verrou anti-réécriture silencieuse)." + }, + "extract_text_chars": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Nombre de caractères du texte de référence." + }, + "speaker": { + "type": [ + "string", + "null" + ], + "description": "Identifiant de la voix (modèles multi-locuteurs)." + }, + "instruct": { + "type": [ + "string", + "null" + ], + "description": "Prompt d'instruction (pour les moteurs qui en acceptent)." + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "Code langue BCP-47 (fr, en, etc.)." + }, + "wer_model": { + "type": [ + "string", + "null" + ], + "description": "Identifiant du modèle ASR (openai/whisper-large-v3, faster-whisper-tiny, etc.)." + }, + "load_s": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Temps de chargement du modèle (secondes)." + }, + "wallclock_total_s": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Wallclock total du banc (incluant WER et prosodie)." + }, + "prosody_st_range": { + "type": [ + "number", + "null" + ], + "description": "Prosodie: ST-spread (semitones range, F0). null si non mesuré." + }, + "prosody_cv": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Prosodie: coefficient of variation F0." + }, + "prosody_velocity": { + "type": [ + "number", + "null" + ], + "minimum": 0.0, + "description": "Prosodie: vitesse (Hz/s)." + }, + "prosody_verdict": { + "type": [ + "string", + "null" + ], + "enum": [ + "EXPRESSIVE", + "INSUFFICIENT", + "FLAT", + null + ] + }, + "fidelity_added_words": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Fidélité (gate pré-UAT #17586): nombre de mots ajoutés par le moteur au-delà du ref. 0 = pass." + }, + "fidelity_omitted_segments_3plus": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Fidélité: nombre de segments de 3+ mots omis sous au moins 2 ASR sur 3. 0 = pass." + }, + "voice_consistent": { + "type": [ + "boolean", + "null" + ], + "description": "Fidélité: voix CONSISTENT sur tout le passage (true), DRIFTING / ERRATIC / FADING (false). null si non testé." + }, + "source_path": { + "type": "string", + "description": "Chemin du metrics.json source ingéré (traçabilité bootstrap)." + } + } + }, + "uniqueness": { + "description": "Cle d'unicite d'un run : (motor, extract, seed). Re-append avec meme cle = upsert (remplace l'ancien), pas de doublon.", + "key": [ + "motor", + "extract", + "seed" + ] + } +} diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py new file mode 100644 index 0000000000..ecbdefead6 --- /dev/null +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py @@ -0,0 +1,349 @@ +"""bootstrap_bank.py — ingère les JSON de mesures existants au banc v1 (couche #19722). + +Porté sur le socle durci mergé par #19820 : la découverte des sources et le +mapping des champs étendus (fidélité, prosodie, traçabilité moteur) vivent ici ; +l'écriture, la validation de schéma et l'upsert par (motor, extract, seed) sont +délégués à bake_append.py de main (CLI --run, JSON inline). + +Sources ingérées : +1. `prosody_lab/bakeoff_small/results/**/*.json` (chatterbox c.1462, pocket_tts c.17661) +2. `prosody_lab/bakeoff_large/results/**/metrics.json` (banc_phase_a0.py) +3. `G:/Mon Drive/MyIA/Projets/BibliothequesSonores/A0-review-*/**/*-metrics.json` (re-rendus UAT) + +Usage (depuis la racine du dépôt ou un worktree) : + python MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py \\ + --bank [--dry-run] [--source bakeoff_small] + +Idempotent : l'upsert de bake_append.py par (motor, extract, seed) remplace un +run de même clé au lieu de le dupliquer. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import re +import subprocess +import sys +from pathlib import Path + +PROSODY_LAB_ROOT = Path(__file__).resolve().parent +BAKE_APPEND = PROSODY_LAB_ROOT / "bake_append.py" + +# Racines des sources +SOURCES = [ + ("bakeoff_small", PROSODY_LAB_ROOT / "bakeoff_small" / "results"), + ("bakeoff_large", PROSODY_LAB_ROOT / "bakeoff_large" / "results"), +] +# GDrive-mount A0-review (Windows) +GDrive_REVIEW = Path(r"G:/Mon Drive/MyIA/Projets/BibliothequesSonores") + +# Champs du socle main +_SOCLE_KEYS = [ + "seed", "wer", "rtf", "vram_mb", "duration_s", + "voice_stable", "hallu_per_100_syl", "inserted_words", "omitted_segments", + "asr_models", "notes", +] +# Champs étendus #19722 (optionnels dans le schéma) +_EXTENDED_KEYS = [ + "motor_size_b", "motor_license", "wer_model", + "load_s", "wallclock_total_s", "extract_text_chars", + "extract_text_sha256_prefix", "prosody_st_range", "prosody_cv", + "prosody_velocity", "prosody_verdict", "fidelity_added_words", + "fidelity_omitted_segments_3plus", "voice_consistent", + "language", "speaker", "instruct", +] + + +def _detect_extract(path: Path) -> str | None: + """Déduit (A|B|C...) depuis le nom de fichier `A__xxx.json` ou `B__xxx.json`.""" + m = re.match(r"^([A-E])__", path.stem) + if m: + return m.group(1) + for p in [path.stem, path.parent.name]: + m = re.match(r"^([A-E])(?:__|-|$)", p) + if m: + return m.group(1) + return None + + +def _detect_motor(path: Path) -> str: + """Déduit le moteur depuis le chemin (segment parent snake_case le plus proche).""" + p = path + while p.parent != p: + parent = p.parent.name + if parent not in {"results", "prosody_lab"} and not parent.startswith("."): + if re.match(r"^[a-z0-9_]+$", parent): + return parent + p = p.parent + return "unknown" + + +def _gdrive_metrics_files() -> list[Path]: + """Liste les `*-metrics.json` sous BibliothequesSonores/A0-review-*.""" + if not GDrive_REVIEW.exists(): + return [] + out: list[Path] = [] + for review_dir in GDrive_REVIEW.glob("A0-review-*"): + out.extend(review_dir.glob("*-metrics.json")) + return out + + +def _gdrive_extract_from_stem(stem: str) -> str | None: + """Décode extract depuis un nom de fichier A0-review : campagne A0 = extrait A. + + Terrain (2026-10-10, A0-review-2026100{6,7}) : tous les stems `A0C-*` + portent l'ouverture de Boule de Suif, l'extrait A de la campagne A0. Le « C » + de « A0C » appartient au nom de campagne, ce n'est pas une lettre d'extrait + — l'ancien regex `^A0([A-E])` décodait « C » à tort (contradiction interne + de la version #19722 : la docstring disait A, la regex disait C, le texte + source tranche). + """ + return "A" if stem.startswith("A0") else None + + +def _gdrive_motor_from_stem(stem: str) -> str | None: + """Décode motor depuis `A0C--...-metrics`.""" + s = re.sub(r"^A0[A-E][-_]?", "", stem) + s = re.sub(r"-(?:chunked-)?(?:chunked-)?metrics$", "", s) + s = re.sub(r"-chunked$", "", s) + return s.strip("-_") or None + + +def _load_metrics(path: Path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + print(f" [SKIP] illisible : {path} : {e}", file=sys.stderr) + return None + + +def _gather_metrics_files() -> list[tuple[Path, str, str | None, str]]: + """Scanne les sources et retourne (path, source_tag, extract, motor). + + `bake_results.json` contient un tableau `results` (1 entrée par extrait) : + UN tuple par fichier (extract=None) ; l'expansion par extrait se fait + dans main() via _expand_per_extract — l'émettre aussi ici fait ingérer + chaque extrait N fois (N*N appels). + """ + out: list[tuple[Path, str, str | None, str]] = [] + for source_tag, root in SOURCES: + if not root.exists(): + continue + for p in root.rglob("*.json"): + if p.name == "bake_results.json": + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception as e: + print(f" [SKIP] bake_results illisible : {p} : {e}", file=sys.stderr) + continue + out.append((p, source_tag, None, + data.get("cell") or _detect_motor(p))) + continue + ex = _detect_extract(p) + mo = _detect_motor(p) + if ex is None: + print(f" [SKIP] Pas d'extract détecté : {p}", file=sys.stderr) + continue + out.append((p, source_tag, ex, mo)) + for p in _gdrive_metrics_files(): + ex = _gdrive_extract_from_stem(p.stem) + mo = _gdrive_motor_from_stem(p.stem) + if ex is None or not mo: + print(f" [SKIP] Pas de motor/extract détecté : {p}", file=sys.stderr) + continue + out.append((p, "gdrive", ex, mo)) + return out + + +def _expand_per_extract(path: Path) -> list[tuple[str, dict]]: + """Pour bake_results.json : retourne [(extract, per_extract_dict), ...]. + + Les métadonnées moteur du conteneur (cell/license/size) sont fusionnées dans + chaque entrée. + """ + out: list[tuple[str, dict]] = [] + data = json.loads(path.read_text(encoding="utf-8")) + base = { + "motor": data.get("cell") or _detect_motor(path), + "motor_license": data.get("license"), + "motor_size_b": data.get("size"), + } + for r in data.get("results", []): + ex = r.get("extract") + if ex: + out.append((ex, {**base, **r})) + return out + + +# Enum asr_models du schéma v1 (mapping id long -> court) +_ASR_ENUM = ["tiny", "base", "small", "medium", + "large-v1", "large-v2", "large-v3", "distil-large-v3"] +_LANGUAGE_NAMES = {"french": "fr", "english": "en", "français": "fr"} + + +def _flatten_gdrive(m: dict) -> dict: + """Aplatit un metrics.json A0-review (re-rendus UAT GDrive). + + Ces fichiers portent des sous-dicts (`wer.wer` + `wer.model`, `seed.base`, + `prosody.*`, `synth.*`) que le schéma v1 attend à plat. Seules les + correspondances sûres sont mappées ; `synth.model` (id HF) est conservé + sous `synth_model` pour traçabilité (additionalProperties). + """ + out = dict(m) + wer = m.get("wer") + if isinstance(wer, dict): + out["wer"] = wer.get("wer") + model = wer.get("model") or "" + if model: + out.setdefault("wer_model", model) + short = model.removeprefix("faster-whisper-") + if short in _ASR_ENUM: + out.setdefault("asr_models", [short]) + seed = m.get("seed") + if isinstance(seed, dict): + out["seed"] = seed.get("base") + prosody = m.get("prosody") + if isinstance(prosody, dict): + if isinstance(prosody.get("duration_s"), (int, float)): + out.setdefault("duration_s", prosody["duration_s"]) + if isinstance(prosody.get("melodic_span_p5p95_st"), (int, float)): + out.setdefault("prosody_st_range", prosody["melodic_span_p5p95_st"]) + if prosody.get("melody_verdict") in ("EXPRESSIVE", "INSUFFICIENT", "FLAT"): + out.setdefault("prosody_verdict", prosody["melody_verdict"]) + if "voice_verdict" in prosody: + out.setdefault("voice_consistent", + prosody.get("voice_verdict") == "CONSISTENT") + synth = m.get("synth") + if isinstance(synth, dict): + if isinstance(synth.get("duration_s"), (int, float)): + out.setdefault("duration_s", synth["duration_s"]) + if isinstance(synth.get("rtf"), (int, float)): + out.setdefault("rtf", synth["rtf"]) + if isinstance(synth.get("vram_peak_gb"), (int, float)): + out.setdefault("vram_mb", int(round(synth["vram_peak_gb"] * 1024))) + if synth.get("language"): + lang = str(synth["language"]) + out.setdefault("language", _LANGUAGE_NAMES.get(lang.lower(), lang)) + for k in ("speaker", "instruct"): + if synth.get(k) is not None: + out.setdefault(k, synth[k]) + if synth.get("model"): + out["synth_model"] = synth["model"] + return out + + +def _normalize_motor(motor: str) -> str: + """Uniformise la graphie d'un moteur en snake_case. + + Le même moteur arrive sous deux formes selon la campagne : `chatterbox_mtl_v3` + (bakeoff) et `chatterbox-mtl-v3` (stem GDrive A0-review). Sans uniformisation, + l'upsert par (motor, extract, seed) laisse deux lignes pour un seul moteur et + le cumul cross-campagne — la raison d'être du banc — est cassé. + """ + return motor.strip().replace("-", "_") + + +def _metrics_to_run(path: Path, metrics: dict, motor: str, extract: str) -> dict: + """Mappe un dict métriques source vers un run complet (socle + étendus). + + Les champs absents de la source ne sont pas posés (le schéma les veut + optionnels) ; les clés du sous-dict `metrics` des fichiers bakeoff_large / + per-extrait sont aplaties d'abord. + """ + flat = dict(metrics) + nested = metrics.get("metrics") + if isinstance(nested, dict): + for k, v in nested.items(): + flat.setdefault(k, v) + + # Normalisations de types hétérogènes entre campagnes : + # `size` des bake_results.json porte un label humain ("0.5B") ; + # `g_st_range`/`g_cv` des bakeoff_* sont les champs prosodie du schéma. + if isinstance(flat.get("motor_size_b"), str): + m = re.search(r"([0-9]+(?:\.[0-9]+)?)", flat["motor_size_b"]) + flat["motor_size_b"] = float(m.group(1)) if m else None + for src_key, dst_key in (("g_st_range", "prosody_st_range"), + ("g_cv", "prosody_cv")): + if dst_key not in flat and isinstance(flat.get(src_key), (int, float)): + flat[dst_key] = flat[src_key] + + src = str(path.relative_to(PROSODY_LAB_ROOT)) \ + if PROSODY_LAB_ROOT in path.parents else str(path) + run: dict = { + "schema_version": "v1", + "ts": flat.get("ts") or datetime.datetime.now(datetime.timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + "machine": flat.get("machine") or "myia-po-2027", + "motor": _normalize_motor(motor), + "extract": extract, + "seed": flat.get("seed") if isinstance(flat.get("seed"), int) else 42, + "duration_s": flat.get("duration_s"), + "notes": (flat.get("notes") or f"bootstrap:{path.name}")[:200], + "source_path": src, + } + for key in _SOCLE_KEYS + _EXTENDED_KEYS: + if key in flat and flat[key] is not None: + run[key] = flat[key] + if run.get("duration_s") is None: + # requis par le schéma main : valeur nulle refusée par validate_run + run["duration_s"] = 0.0 + run["notes"] = (run["notes"] + " ; duration_s inconnue")[:200] + return run + + +def _ingest_run(bank: Path, run: dict, dry_run: bool) -> tuple[int, str]: + """Pousse un run vers bake_append.py de main (CLI --run, JSON inline).""" + args = [sys.executable, str(BAKE_APPEND), + "--bank", str(bank), "--run", json.dumps(run, ensure_ascii=False)] + if dry_run: + args.append("--dry-run") + proc = subprocess.run(args, capture_output=True, text=True, + encoding="utf-8", errors="replace") + return proc.returncode, (proc.stderr or proc.stdout or "") + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--bank", required=True, help="Fichier banc cible (JSON).") + p.add_argument("--dry-run", action="store_true", + help="Valide et affiche sans écriture.") + p.add_argument("--source", + choices=["bakeoff_small", "bakeoff_large", "gdrive", "all"], + default="all", help="Limite l'ingestion à une source.") + args = p.parse_args() + + files = _gather_metrics_files() + if args.source != "all": + files = [f for f in files if f[1] == args.source] + print(f"Source files trouvés : {len(files)}", file=sys.stderr) + + successes, skipped, failed = 0, 0, 0 + for path, source_tag, ex, mo in files: + if path.name == "bake_results.json": + entries = _expand_per_extract(path) + else: + data = _load_metrics(path) + entries = [(ex or "", data)] if isinstance(data, dict) else [] + for entry_ex, metrics in entries: + extract_id = entry_ex or ex + if not extract_id: + skipped += 1 + continue + if source_tag == "gdrive": + metrics = _flatten_gdrive(metrics) + run = _metrics_to_run(path, metrics, mo, extract_id) + rc, err = _ingest_run(Path(args.bank), run, args.dry_run) + if rc == 0: + successes += 1 + else: + failed += 1 + print(f" [ERR] {path.name} ({mo}/{extract_id}) : {err.strip()[:200]}", + file=sys.stderr) + print(f"ok={successes} skip={skipped} err={failed}" + + (" (dry-run)" if args.dry_run else ""), file=sys.stderr) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py index 441845d3aa..47561ff056 100644 --- a/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py +++ b/MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py @@ -288,6 +288,191 @@ def test_ingest_bakeoff_small() -> None: print(f"[OK] test_ingest_bakeoff_small ({n_runs} runs)") +def test_extended_schema_fields() -> None: + """Couche #19722 : un run portant les champs étendus (fidelité, prosodie, + traçabilité moteur) doit valider ; les champs restent OPTIONNELS (le run + socle-seul de test_append_dry_run_valid le prouve déjà).""" + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + props = schema["items"]["properties"] + for field in ["fidelity_added_words", "fidelity_omitted_segments_3plus", + "voice_consistent", "prosody_verdict", "motor_license", + "motor_size_b", "wer_model", "source_path", "schema_version"]: + assert_true(f"schéma déclare {field}", field in props) + assert_true("required ignore les champs étendus", + "fidelity_added_words" not in schema["items"]["required"]) + rc, _, stderr = run([ + sys.executable, str(BAKE_APPEND), "--bank", str(THIS_DIR / "_test_bank.json"), + "--dry-run", + "--run", json.dumps({ + "schema_version": "v1", + "ts": "2026-10-08T02:00:00Z", + "machine": "myia-po-2027", + "motor": "test_motor_ext", + "extract": "A", + "seed": 7, + "duration_s": 9.5, + "fidelity_added_words": 0, + "fidelity_omitted_segments_3plus": 1, + "voice_consistent": True, + "prosody_st_range": 41.2, + "prosody_cv": 0.18, + "prosody_velocity": 3.4, + "prosody_verdict": "EXPRESSIVE", + "motor_license": "apache-2.0", + "motor_size_b": 0.5, + "wer_model": "large-v3", + "load_s": 11.2, + "wallclock_total_s": 30.1, + "extract_text_sha256_prefix": "deadbeefdeadbeef", + "source_path": "bakeoff_small/results/test/A__test.json", + }), + ]) + assert_eq("extended dry-run rc", rc, 0) + if rc != 0: + print(stderr[:400], file=sys.stderr) + print("[OK] test_extended_schema_fields") + + +def test_bootstrap_metrics_to_run() -> None: + """bootstrap_bank._metrics_to_run : aplatie metrics.*, porte license/size + du conteneur bake_results, et fournit duration_s=0.0+note si absent.""" + sys.path.insert(0, str(THIS_DIR)) + import bootstrap_bank # noqa: E402 + path = THIS_DIR / "bakeoff_small" / "results" / "demo_m" / "A__demo.json" + run1 = bootstrap_bank._metrics_to_run( + path, + {"cell": "demo_m", "motor_license": "mit", "motor_size_b": 2.0, + "metrics": {"duration_s": 8.4, "wer": 0.12}, "seed": 3}, + "demo_m", "A") + assert_eq("mapping duration_s (aplati)", run1["duration_s"], 8.4) + assert_eq("mapping wer (aplati)", run1["wer"], 0.12) + assert_eq("mapping seed", run1["seed"], 3) + assert_eq("mapping motor_license", run1["motor_license"], "mit") + assert_eq("mapping motor_size_b", run1["motor_size_b"], 2.0) + assert_eq("mapping schema_version", run1.get("schema_version"), "v1") + assert_true("mapping source_path relatif", not run1["source_path"].startswith((":\\", "/"))) + run2 = bootstrap_bank._metrics_to_run(path, {"wer": 0.4}, "demo_m", "A") + assert_eq("duration_s manquant -> 0.0", run2["duration_s"], 0.0) + assert_contains("note duration inconnue", run2["notes"], "duration_s inconnue") + # Label humain "0.5B" -> nombre ; seed null -> 42 ; g_* -> prosody_* + run3 = bootstrap_bank._metrics_to_run( + path, {"motor_size_b": "0.5B", "seed": None, + "g_st_range": 18.85, "g_cv": 0.21}, "demo_m", "A") + assert_eq("size '0.5B' -> 0.5", run3["motor_size_b"], 0.5) + assert_eq("seed None -> 42", run3["seed"], 42) + assert_eq("g_st_range -> prosody_st_range", run3["prosody_st_range"], 18.85) + assert_eq("g_cv -> prosody_cv", run3["prosody_cv"], 0.21) + # Graphie moteur uniformisée : stem GDrive `chatterbox-mtl-v3` == bakeoff + # `chatterbox_mtl_v3`, sinon l'upsert laisse deux lignes pour un moteur. + run4 = bootstrap_bank._metrics_to_run(path, {"wer": 0.4}, + "chatterbox-mtl-v3", "A") + assert_eq("motor snake_case", run4["motor"], "chatterbox_mtl_v3") + print("[OK] test_bootstrap_metrics_to_run") + + +def test_bootstrap_gdrive_decoders() -> None: + """bootstrap_bank : décodage extract/motor des stems A0-review. + + Terrain (2026-10-10) : tous les stems A0C-* de A0-review portent le texte + de l'extrait A (ouverture Boule de Suif) — le « C » de « A0C » appartient + au nom de campagne. L'ancien regex `^A0([A-E])` décodait « C » à tort. + """ + sys.path.insert(0, str(THIS_DIR)) + import bootstrap_bank # noqa: E402 + assert_eq("A0C-cosyvoice3-metrics -> extract A", + bootstrap_bank._gdrive_extract_from_stem("A0C-cosyvoice3-metrics"), "A") + assert_eq("A0C-cosyvoice3-metrics -> motor", + bootstrap_bank._gdrive_motor_from_stem("A0C-cosyvoice3-metrics"), + "cosyvoice3") + assert_eq("A0C-zonos-chunked-metrics -> motor", + bootstrap_bank._gdrive_motor_from_stem("A0C-zonos-chunked-metrics"), + "zonos") + assert_eq("stem hors pattern -> None", + bootstrap_bank._gdrive_extract_from_stem("metrics"), None) + print("[OK] test_bootstrap_gdrive_decoders") + + +def test_bootstrap_gdrive_flatten() -> None: + """_flatten_gdrive : wer.wer, seed.base, prosody/synth imbriqués -> à plat.""" + sys.path.insert(0, str(THIS_DIR)) + import bootstrap_bank # noqa: E402 + flat = bootstrap_bank._flatten_gdrive({ + "client": "cosyvoice3", + "text": "Pendant plusieurs jours...", + "wer": {"wer": 0.518, "model": "faster-whisper-tiny"}, + "seed": {"base": 42, "per_chunk": "torch.manual_seed(42 + i)"}, + "prosody": {"duration_s": 89.52, "melodic_span_p5p95_st": 13.27, + "melody_verdict": "EXPRESSIVE", "voice_verdict": "CONSISTENT"}, + "synth": {"model": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512", + "language": "French", "rtf": 1.13, "vram_peak_gb": 2.5, + "speaker": None, "instruct": None}, + "wallclock_total_s": 101.1, + }) + assert_eq("wer.wer aplati", flat["wer"], 0.518) + assert_eq("wer_model", flat["wer_model"], "faster-whisper-tiny") + assert_eq("asr_models court", flat["asr_models"], ["tiny"]) + assert_eq("seed.base", flat["seed"], 42) + assert_eq("duration_s (prosody)", flat["duration_s"], 89.52) + assert_eq("prosody_st_range", flat["prosody_st_range"], 13.27) + assert_eq("prosody_verdict", flat["prosody_verdict"], "EXPRESSIVE") + assert_true("voice_consistent", flat["voice_consistent"] is True) + assert_eq("rtf", flat["rtf"], 1.13) + assert_eq("vram_mb", flat["vram_mb"], 2560) + assert_eq("language BCP-47", flat["language"], "fr") + assert_eq("synth_model traçabilité", flat["synth_model"], + "FunAudioLLM/Fun-CosyVoice3-0.5B-2512") + assert_true("speaker None non posé", "speaker" not in flat) + print("[OK] test_bootstrap_gdrive_flatten") + + +def test_bootstrap_main_single_expansion() -> None: + """Non-régression expansion : main() sur un conteneur bake_results.json à + deux extraits doit produire exactement 2 appels d'ingestion (un par + extrait), dans l'ordre du conteneur — pas 2*N appels (double expansion + gather + main).""" + import importlib.util + spec = importlib.util.spec_from_file_location( + "bootstrap_bank_under_test", THIS_DIR / "bootstrap_bank.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + src_root = tdp / "results" + src_root.mkdir() + (src_root / "bake_results.json").write_text(json.dumps({ + "cell": "test_motor_2x", "license": "MIT", "size": "0.5B", + "results": [ + {"extract": "A", "wer": 0.10, "duration_s": 5.0, "rtf": 0.8}, + {"extract": "B", "wer": 0.20, "duration_s": 6.0, "rtf": 0.9}, + ], + }), encoding="utf-8") + + calls: list[tuple[str, str]] = [] + + def fake_ingest(bank, run, dry_run): + calls.append((run["motor"], run["extract"])) + return 0, "" + + mod._ingest_run = fake_ingest + mod.SOURCES = [("fixture", src_root)] + mod._gdrive_metrics_files = lambda: [] + + old_argv = sys.argv + sys.argv = ["bootstrap_bank.py", + "--bank", str(tdp / "bank.json"), "--dry-run"] + try: + rc = mod.main() + finally: + sys.argv = old_argv + + assert_eq("main() rc", rc, 0) + assert_eq("appels d'ingestion (un par extrait)", len(calls), 2) + assert_eq("séquence extraits", [e for _, e in calls], ["A", "B"]) + assert_eq("moteur passé à l'ingestion", calls[0][0], "test_motor_2x") + print("[OK] test_bootstrap_main_single_expansion") + + def main() -> int: tests = [ test_schema_valid, @@ -300,6 +485,11 @@ def main() -> int: test_report_sort_chronological, test_append_validation_union_enum, test_ingest_bakeoff_small, + test_extended_schema_fields, + test_bootstrap_metrics_to_run, + test_bootstrap_gdrive_decoders, + test_bootstrap_gdrive_flatten, + test_bootstrap_main_single_expansion, ] print(f"Running {len(tests)} tests...\n") failed = 0