diff --git a/scripts/ci/fast_lane.py b/scripts/ci/fast_lane.py index e7a8f64c3b..96b8d1af75 100644 --- a/scripts/ci/fast_lane.py +++ b/scripts/ci/fast_lane.py @@ -41,7 +41,7 @@ from fast_lane_registry import ( # noqa: E402 PILOT, TRANCHE1, TRANCHE2, TRANCHE3, TRANCHE4, TRANCHE5, TRANCHE6, - TRANCHE7, TRANCHE8, Guard, + TRANCHE7, TRANCHE8, TRANCHE9, Guard, ) SHADOW_PREFIX = "fast-lane (ombre): " @@ -364,7 +364,7 @@ def main(argv: list[str] | None = None) -> int: f"contre {args.base_ref}") guards = [g for g in PILOT + TRANCHE1 + TRANCHE2 + TRANCHE3 + TRANCHE4 - + TRANCHE5 + TRANCHE6 + TRANCHE7 + TRANCHE8 + + TRANCHE5 + TRANCHE6 + TRANCHE7 + TRANCHE8 + TRANCHE9 if not args.only or g.name == args.only] selected = [g for g in guards if guard_applies(g, changed)] for guard in guards: diff --git a/scripts/ci/fast_lane_registry.py b/scripts/ci/fast_lane_registry.py index 7533ddf83c..2878bd13b9 100644 --- a/scripts/ci/fast_lane_registry.py +++ b/scripts/ci/fast_lane_registry.py @@ -976,3 +976,40 @@ class Guard: absorbed=True, ), ] + + +# --------------------------------------------------------------------------- +# TRANCHE 9 -- intervalle de credibilite DECLARE vs AFFICHE (#15592). +# +# Garde NATIF : il n'absorbe aucun workflow d'origine, il ferme une classe de +# defaut. C'est pour cela qu'il a sa propre tranche plutot qu'une place dans +# `PILOT` (qui absorbe des workflows existants) ou dans `TRANCHE8` (scopee +# Smart Contracts -- y ranger un garde arviz rendrait son en-tete faux). +# +# Le defaut fondeur est #15156 : `hdi_prob=` remplace par `ci_prob=` sans +# `ci_kind`, donc sans re-execution. En arviz 1.1, `ci_kind` vaut `None` par +# defaut et la bibliotheque le resout en `"eti"` -- la migration s'executait, +# la sortie restait en `hdi`, et plus rien ne comparait les deux. +# +# PORTEE : arbre ENTIER, et le garde est BLOQUANT. Ce n'est legitime que si la +# baseline est verte -- mesuree, pas supposee : 18 cellules a colonne +# d'intervalle sur 1254 notebooks, 0 desaccord, avant enregistrement. +# --------------------------------------------------------------------------- +TRANCHE9: list[Guard] = [ + Guard( + name="interval-kind-consistency-guard", + source=FAST_LANE_NATIVE, + paths=[ + "MyIA.AI.Notebooks/**/*.ipynb", + "scripts/notebook_tools/check_interval_kind_consistency.py", + "scripts/ci/fast_lane.py", + "scripts/ci/fast_lane_registry.py", + ], + argv=[ + "python", + "scripts/notebook_tools/check_interval_kind_consistency.py", + ], + blocking=True, + absorbed=True, + ), +] diff --git a/scripts/notebook_tools/check_interval_kind_consistency.py b/scripts/notebook_tools/check_interval_kind_consistency.py new file mode 100644 index 0000000000..647b2e24be --- /dev/null +++ b/scripts/notebook_tools/check_interval_kind_consistency.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Garde d'accord intervalle declare <-> intervalle affiche (#15592). + +Origine -- le laisser-passer de #15156 +-------------------------------------- +#15156 a migre 7 notebooks PyMC vers l'API arviz 1.1 en remplacant +`hdi_prob=0.89` par `ci_prob=0.89`, **sans `ci_kind`**. Or en arviz 1.1 +(`arviz_stats`), `ci_kind` a pour defaut `None`, que la bibliotheque resout en +`"eti"` (equal-tailed interval) -- **pas** en `"hdi"`. La migration s'executait +donc sans erreur tout en changeant **l'objet statistique affiche**, pendant que +la prose des notebooks continuait d'annoncer un HDI. + +L'issue #15592 nomme elle-meme la mesure manquante : + + "L'absence de garde `(hdi|eti)N_(lb|ub)` <-> prose est le vrai + laisser-passer de #15156. Un controle peu couteux -- extraire les noms de + colonnes des sorties, les confronter aux mentions `HDI`/`ETI` du markdown + -- aurait attrape les deux cas." + +CE QUE CE GARDE MESURE, ET POURQUOI PAS LA PROSE +----------------------------------------------- +Deux invariants etaient candidats. La mesure a tranche, pas la preference. + +**Retenu -- source declaree -> sortie affichee.** Pour chaque cellule de code +dont les sorties portent une colonne `(hdi|eti)_(lb|ub)`, on lit dans la +SOURCE le type d'intervalle demande (`ci_kind="hdi"`, `hdi_prob=`, `az.hdi(`) +et on le compare a la famille reellement presente dans la sortie. C'est +exactement le mecanisme du defaut : #15156 a change la source sans re-executer, +donc la sortie committée a cesse de correspondre a la source qui la porte -- +un manquement C.2/H.1, detectable statiquement, sans executer le notebook. + +**Rejete -- prose <-> sortie.** Mesure sur l'arbre entier (`origin/main@ +d14b1ac098`) : sur les **18** cellules du depot qui portent une colonne +d'intervalle, **16 n'ont aucune revendication `HDI`/`ETI` en amont**. Le garde +n'aurait donc regarde que 2 cellules sur 18, tout en ouvrant une surface de +faux positifs reelle : `HDI` apparait aussi dans les cellules qui **definissent** +le terme (« HDI = highest density interval ») sans rien revendiquer sur la +sortie affichee. Un garde qui couvre 11 % des cas et crie au loup ailleurs est +un garde qu'on desactive -- lecon #12586 / #15489 defaut 5. + +CE QUE CE GARDE NE FAIT PAS +--------------------------- +- Il ne juge pas la prose : cf ci-dessus. +- Il ne re-execute rien : il lit l'etat committe. Un notebook dont la source et + les sorties s'accordent mais qui ment sur son contenu statistique lui echappe. +- Il ne couvre que les colonnes d'intervalle nommees. Un `az.hdi(...)` dont le + resultat n'est pas affiche en colonne n'est pas vu. +- Il ignore les cellules mixtes (qui demandent explicitement deux types a la + fois) : ambigues par construction, elles sont denombrees, pas jugees. + +PORTEE +------ +Arbre ENTIER, pas delta. Mesure de la baseline sur `origin/main@d14b1ac098` : +18 cellules examinees, **0 desaccord**. Un garde bloquant sur un arbre vert ne +fabrique aucun mur rouge -- c'est la condition qui rend le mode bloquant +legitime ici, et elle est verifiee, pas supposee. + +Usage +----- + python scripts/notebook_tools/check_interval_kind_consistency.py + python scripts/notebook_tools/check_interval_kind_consistency.py --json + python scripts/notebook_tools/check_interval_kind_consistency.py --self-test + +Sortie : 0 = aucun desaccord ; 1 = desaccord ; 2 = erreur d'invocation. +Le denombrement des cellules examinees est TOUJOURS imprime : "rien trouve" et +"rien regarde" ne doivent jamais se confondre. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +NOTEBOOKS = REPO_ROOT / "MyIA.AI.Notebooks" + +#: Famille d'intervalle portee par une colonne de sortie, p.ex. `hdi89_lb`. +COLUMN = re.compile(r"\b(hdi|eti)(\d+)_(lb|ub)\b", re.I) + +#: Appels arviz qui acceptent le choix du type d'intervalle. Restreindre a ces +#: appelants evite de lire un `ci_kind=` qui appartiendrait a autre chose. +CALLERS = re.compile(r"\baz\.(summary|plot_dist|plot_posterior|plot_forest" + r"|plot_ppc|plot_elpd|hdi|plot_hdi|hdpi)\s*\(") + +#: Declarations explicites du type d'intervalle. +KIND_HDI = re.compile(r"""ci_kind\s*=\s*['"]hdi['"]""", re.I) +KIND_ETI = re.compile(r"""ci_kind\s*=\s*['"]eti['"]""", re.I) +LEGACY_HDI_PROB = re.compile(r"\bhdi_prob\s*=") +CI_PROB = re.compile(r"\bci_prob\s*=") + +#: Dossiers hors corpus pedagogique (libs vendorees, lakes externes). +EXCLUDED = ("/.lake/", "/_peters/") + +#: Le defaut d'arviz 1.1 : `ci_kind=None` est resolu en intervalle equal-tailed. +#: C'est CE defaut qui a rendu #15156 silencieux, donc c'est lui qu'on encode. +DEFAULT_KIND = "eti" + + +def _iter_notebooks(root: Path = NOTEBOOKS): + for nb in sorted(root.rglob("*.ipynb")): + if any(x in nb.as_posix() for x in EXCLUDED): + continue + yield nb + + +def _cell_source(cell: dict) -> str: + return "".join(cell.get("source", [])) + + +def output_kinds(cell: dict) -> set[str]: + """Familles d'intervalle presentes dans les SORTIES commitees de la cellule.""" + kinds: set[str] = set() + for out in cell.get("outputs", []) or []: + for m in COLUMN.finditer(json.dumps(out)): + kinds.add(m.group(1).lower()) + return kinds + + +def declared_kinds(source: str) -> set[str]: + """Familles que la SOURCE demande explicitement. + + Un appel d'intervalle sans `ci_kind` (ni `hdi_prob` legacy) ne demande rien + d'explicite : il retombe sur le defaut de la bibliotheque, qui est encode + separement (`DEFAULT_KIND`) pour que la part de deduction reste visible. + """ + kinds: set[str] = set() + if KIND_HDI.search(source) or LEGACY_HDI_PROB.search(source): + kinds.add("hdi") + if KIND_ETI.search(source): + kinds.add("eti") + return kinds + + +def examines_interval(source: str) -> bool: + """La source porte-t-elle un appel qui produit un intervalle ?""" + return bool(CALLERS.search(source) or CI_PROB.search(source)) + + +def examine(nb_path: Path) -> tuple[list[dict], dict]: + """Rend (desaccords, compteurs) pour un notebook. + + Un desaccord est enregistre quand la famille attendue -- celle que la + source demande, ou le defaut de la bibliotheque si elle ne demande rien -- + n'apparait dans AUCUNE colonne de sortie de la cellule. + """ + try: + doc = json.loads(nb_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [], {"unreadable": 1, "detail": str(exc)} + + try: + shown = nb_path.relative_to(REPO_ROOT).as_posix() + except ValueError: + shown = nb_path.as_posix() + + issues: list[dict] = [] + counts = {"cells": 0, "explicit": 0, "default": 0, "ambiguous": 0} + for idx, cell in enumerate(doc.get("cells", [])): + if cell.get("cell_type") != "code": + continue + shown_kinds = output_kinds(cell) + if not shown_kinds: + continue + counts["cells"] += 1 + source = _cell_source(cell) + declared = declared_kinds(source) + if len(declared) > 1: + counts["ambiguous"] += 1 + continue + if declared: + expected = next(iter(declared)) + counts["explicit"] += 1 + elif examines_interval(source): + expected = DEFAULT_KIND + counts["default"] += 1 + else: + # Aucune source d'intervalle identifiable (sortie heritee d'un + # appel non reconnu) : rien a confronter, on ne juge pas. + continue + if expected not in shown_kinds: + issues.append({ + "file": shown, + "cell": idx, + "expected": expected, + "shown": sorted(shown_kinds), + "basis": "explicit" if declared else "library-default", + }) + return issues, counts + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description="Refuser un notebook dont les colonnes d'intervalle " + "affichees ne correspondent pas au type demande par la " + "source (#15592).") + ap.add_argument("--json", action="store_true") + ap.add_argument("--root", default=str(NOTEBOOKS), + help="racine a scanner (defaut : MyIA.AI.Notebooks)") + a = ap.parse_args(argv) + + issues: list[dict] = [] + totals = {"cells": 0, "explicit": 0, "default": 0, "ambiguous": 0, + "notebooks": 0, "unreadable": 0} + for nb in _iter_notebooks(Path(a.root)): + found, counts = examine(nb) + totals["notebooks"] += 1 + totals["unreadable"] += counts.get("unreadable", 0) + for k in ("cells", "explicit", "default", "ambiguous"): + totals[k] += counts.get(k, 0) + issues.extend(found) + + if a.json: + print(json.dumps({"totals": totals, "issues": issues}, + ensure_ascii=False, indent=2)) + return 1 if issues else 0 + + print("notebooks lus : %d cellules a colonne d'intervalle : %d " + "(declare explicite=%d, defaut=%d, mixte ecarte=%d)" + % (totals["notebooks"], totals["cells"], totals["explicit"], + totals["default"], totals["ambiguous"])) + if totals["unreadable"]: + print(" (%d notebook(s) illisible(s) -- ignores, comptes)" + % totals["unreadable"]) + if not totals["cells"]: + print("VERDICT: OK -- aucune cellule a colonne d'intervalle, rien a " + "verifier.") + return 0 + if not issues: + print("VERDICT: OK -- les intervalles affiches correspondent au type " + "demande par la source.") + return 0 + print("") + print("VERDICT: INTERVALLE AFFICHE != INTERVALLE DECLARE (%d)" % len(issues)) + print("") + for it in issues: + print(" %s cellule %d : source demande %s, sortie porte %s (%s)" + % (it["file"], it["cell"], it["expected"].upper(), + "/".join(k.upper() for k in it["shown"]), it["basis"])) + print("") + print("En arviz 1.1, `ci_kind` vaut None par defaut et la bibliotheque le " + "resout en intervalle equal-tailed ('eti'). Un `ci_prob=` sans " + "`ci_kind` affiche donc un ETI, pas un HDI.") + print("Corriger la SOURCE (`ci_kind=\"hdi\"`) puis RE-EXECUTER : changer " + "la source sans re-executer laisse une sortie qui ne correspond plus " + "au code qui la porte (C.2/H.1). Jamais de retouche manuelle de la " + "sortie.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_check_interval_kind_consistency.py b/scripts/tests/test_check_interval_kind_consistency.py new file mode 100644 index 0000000000..1d43ec369f --- /dev/null +++ b/scripts/tests/test_check_interval_kind_consistency.py @@ -0,0 +1,189 @@ +"""Tests du garde d'accord intervalle declare <-> intervalle affiche (#15592). + +L'instance fondatrice est le defaut de #15156 : `hdi_prob=0.89` remplace par +`ci_prob=0.89` **sans `ci_kind`**, sans re-execution -- la source redescend +donc au defaut de la bibliotheque (`eti`) tandis que la sortie committée garde +des colonnes `hdi89_*`. Le test `test_instance_fondatrice_est_attrapee` +reconstruit cet etat, il ne le decrit pas. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "notebook_tools")) + +from check_interval_kind_consistency import ( # noqa: E402 + examine, + declared_kinds, + main, + output_kinds, +) + + +def _out(*cols: str) -> dict: + """Sortie de cellule portant des noms de colonnes, comme un `az.summary`.""" + hdr = " ".join(cols) + return {"output_type": "execute_result", + "data": {"text/plain": [hdr + "\n0 1.0 2.0"]}} + + +def _nb(tmp: Path, cells: list[dict], name: str = "Nb.ipynb") -> Path: + p = tmp / name + p.write_text(json.dumps({"cells": cells, "nbformat": 4, "nbformat_minor": 5}), + encoding="utf-8") + return p + + +def _code(src: str, *cols: str) -> dict: + cell = {"cell_type": "code", "source": src.splitlines(keepends=True), + "metadata": {}, "execution_count": 1, "outputs": []} + if cols: + cell["outputs"] = [_out(*cols)] + return cell + + +# --- lecture des deux cotes ------------------------------------------------- + +def test_declared_kinds_lit_ci_kind_hdi(): + assert declared_kinds('az.summary(t, ci_prob=0.89, ci_kind="hdi")') == {"hdi"} + + +def test_declared_kinds_lit_le_legacy_hdi_prob(): + assert declared_kinds("az.summary(t, hdi_prob=0.89)") == {"hdi"} + + +def test_declared_kinds_ne_devine_rien_sans_declaration(): + """`ci_prob=` seul ne demande RIEN d'explicite : c'est le defaut de la + bibliotheque qui tranche, et il est encode ailleurs.""" + assert declared_kinds("az.summary(t, ci_prob=0.89)") == set() + assert declared_kinds("az.summary(t, var_names=['theta'])") == set() + + +def test_output_kinds_lit_les_colonnes(): + assert output_kinds(_code("x", "hdi89_lb", "hdi89_ub")) == {"hdi"} + assert output_kinds(_code("x", "eti89_lb", "eti94_ub")) == {"eti"} + assert output_kinds(_code("x", "mean", "sd")) == set() + + +# --- l'invariant ----------------------------------------------------------- + +def test_instance_fondatrice_est_attrapee(tmp_path: Path): + """Reconstruit l'etat d'APRES #15156 et AVANT correctif : source sans + `ci_kind` (donc `eti` par defaut), sortie committée restee en `hdi`.""" + nb = _nb(tmp_path, [ + _code('az.summary(trace_rho, var_names=["rho"], ci_prob=0.89)', + "hdi89_lb", "hdi89_ub")]) + issues, counts = examine(nb) + assert len(issues) == 1 + assert issues[0]["expected"] == "eti" + assert issues[0]["shown"] == ["hdi"] + assert issues[0]["basis"] == "library-default" + assert counts["default"] == 1 + + +def test_desaccord_inverse_source_hdi_sortie_eti(tmp_path: Path): + nb = _nb(tmp_path, [ + _code('az.summary(t, ci_kind="hdi")', "eti89_lb", "eti89_ub")]) + issues, _ = examine(nb) + assert len(issues) == 1 + assert issues[0]["expected"] == "hdi" + assert issues[0]["basis"] == "explicit" + + +def test_accord_explicite_passe(tmp_path: Path): + nb = _nb(tmp_path, [ + _code('az.summary(t, ci_prob=0.89, ci_kind="hdi")', + "hdi89_lb", "hdi89_ub")]) + assert examine(nb) == ([], {"cells": 1, "explicit": 1, "default": 0, + "ambiguous": 0}) + + +def test_accord_par_defaut_passe(tmp_path: Path): + """Le cas le plus frequent du depot (16 des 18 cellules) : aucun argument + d'intervalle, sortie `eti` -- le defaut de la bibliotheque, coherent.""" + nb = _nb(tmp_path, [_code("az.summary(t)", "eti89_lb", "eti89_ub")]) + issues, counts = examine(nb) + assert issues == [] and counts["default"] == 1 + + +def test_defaut_eti_avec_sortie_hdi_rougit(tmp_path: Path): + nb = _nb(tmp_path, [_code("az.summary(t)", "hdi89_lb", "hdi89_ub")]) + issues, _ = examine(nb) + assert len(issues) == 1 and issues[0]["expected"] == "eti" + + +def test_cellule_mixte_est_ecartee_pas_jugee(tmp_path: Path): + """Une cellule qui demande explicitement les DEUX types est ambigue par + construction : elle est denombrée, jamais condamnee.""" + nb = _nb(tmp_path, [ + _code('az.summary(t, ci_kind="hdi")\naz.plot_dist(t, ci_kind="eti")', + "eti89_lb")]) + issues, counts = examine(nb) + assert issues == [] + assert counts["ambiguous"] == 1 and counts["cells"] == 1 + + +def test_cellule_sans_colonne_d_intervalle_est_ignoree(tmp_path: Path): + nb = _nb(tmp_path, [_code('az.summary(t, ci_kind="hdi")', "mean", "sd")]) + issues, counts = examine(nb) + assert issues == [] and counts["cells"] == 0 + + +def test_notebook_illisible_ne_fait_pas_tomber_le_garde(tmp_path: Path): + bad = tmp_path / "bad.ipynb" + bad.write_text("{ pas du json", encoding="utf-8") + issues, counts = examine(bad) + assert issues == [] and counts.get("unreadable") == 1 + + +# --- CLI ------------------------------------------------------------------- + +def test_main_arbre_sans_colonne_rend_0(tmp_path: Path, capsys): + _nb(tmp_path, [_code("print(1)", "mean")]) + assert main(["--root", str(tmp_path)]) == 0 + assert "rien a verifier" in capsys.readouterr().out + + +def test_main_rougit_et_nomme_la_cellule(tmp_path: Path, capsys): + _nb(tmp_path, [_code('az.summary(t, ci_prob=0.89)', "hdi89_lb")]) + assert main(["--root", str(tmp_path)]) == 1 + out = capsys.readouterr().out + assert "cellule 0" in out and "ETI" in out and "HDI" in out + + +def test_json_shape(tmp_path: Path, capsys): + _nb(tmp_path, [_code('az.summary(t, ci_prob=0.89)', "hdi89_lb")]) + assert main(["--root", str(tmp_path), "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["totals"]["cells"] == 1 + assert payload["issues"][0]["expected"] == "eti" + + +# --- verrou de baseline ---------------------------------------------------- +# +# La portee du garde est l'arbre ENTIER et il est bloquant : ce n'est legitime +# que si la baseline est verte. Ces deux tests verrouillent la baseline sur les +# deux seuls notebooks du depot qui declarent explicitement un HDI. Si l'un +# d'eux rederive, le garde rougit -- et ce test dit lequel. + +REAL = Path(__file__).resolve().parents[2] / "MyIA.AI.Notebooks" / "Probas" + + +@pytest.mark.parametrize("rel", [ + "DecisionTheory/PyMC/DecPyMC-2-Utility-Money.ipynb", + "DecisionTheory/PyMC/DecPyMC-8-Actuarial-Credibility.ipynb", +]) +def test_baseline_hdi_reelle_est_coherente(rel: str): + nb = REAL / rel + if not nb.exists(): + pytest.skip("notebook absent de cet arbre") + issues, counts = examine(nb) + assert issues == [], issues + assert counts["explicit"] >= 1, ( + "le notebook ne declare plus de HDI explicite -- le verrou de " + "baseline ne teste plus rien")