diff --git a/.github/workflows/split-reading-advisory.yml b/.github/workflows/split-reading-advisory.yml index 290dcd159b..759af4e723 100644 --- a/.github/workflows/split-reading-advisory.yml +++ b/.github/workflows/split-reading-advisory.yml @@ -17,13 +17,17 @@ name: Split-reading advisory # cellule a UNE cellule de lecture. Si elle en a deja une, on la REECRIT. # On n'en ajoute jamais une seconde. » # -# Advisory, jamais bloquant : la dette corpus heritee au cablage est de -# 109 findings (98 generic_pair, 8 separated_by_code, 3 named_split — ces -# derniers fusionnes par la PR soeur #17025). Un garde bloquant rougirait -# chaque PR touchant un notebook porteur de dette heritee. Le passage -# eventuel en blocking sera tranche par le coordinateur apres resorption -# de la dette par serie (precedent : deaccent TRANCHE6 #14325, -# repeated-prose TRANCHE7). +# CLIQUET BLOQUANT depuis #17044 (2026-09-24). La dette corpus heritee au +# cablage (109 findings : 98 generic_pair, 8 separated_by_code, 3 named_split, +# ces derniers fusionnes par la PR soeur #17025) reste GRANDFATHERED : le +# cliquet ne juge que ce que la PR change -- lectures ajoutees ou compte de +# paires qui monte sur un carnet touche. Un garde a plancher absolu aurait +# rougi chaque PR touchant un carnet porteur, ce qui punit le voisin et non +# l'auteur ; c'est cette objection qui avait motive l'advisory, et le cliquet +# la leve sans renoncer au mandat. Mesure avant cablage : 0/11 faux positifs +# sur les 11 dernieres PR notebook mergees (controle positif fondateur 26+16 +# intact). Ce job reste workflow_dispatch-only : il ne rend AUCUN verdict de +# PR -- c'est le garde absorbe (registre TRANCHE14) qui le fait. # # Wiring : scripts/ci/fast_lane_registry.py :: TRANCHE14. # Identite : Guard.name == job.name (cf check_absorbed_check_run_identity.py). @@ -41,7 +45,7 @@ concurrency: jobs: split-reading-advisory: - name: "Split-reading-cells advisory (per-notebook, non-blocking)" + name: "Split-reading ratchet (base vs PR)" runs-on: [self-hosted, coursia-ephemeral, coursia-linux] if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index 7b15ccc0ed..a59037d658 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -319,9 +319,12 @@ # vehicule workflow_dispatch-ONLY servant de cible d'identite au # check-run absorbe par fast-lane (registre TRANCHE14) et de re-run # manuel du recensement sur main. Detecteur de paires de cellules de - # lecture scindees (check_split_reading_cells.py), advisory - # pur-Python stdlib-only, jamais bloquant, aucun secret. - # Rollback = revert de la PR (l'entree disparait de l'allowlist). + # lecture scindees (check_split_reading_cells.py), pur-Python + # stdlib-only, aucun secret. Promu en cliquet BLOQUANT par #17044 : le + # verdict de PR est rendu par le garde absorbe, PAS par ce workflow, qui + # ne tourne jamais sur un pull_request (dispatch-only) -- l'entree reste + # justifiee telle quelle. Rollback = revert de la PR (l'entree + # disparait de l'allowlist). "split-reading-advisory.yml", "stale-base-warning.yml", # fin de chantier #14283 (feu vert ai-01 2026-09-02) : les jobs diff --git a/scripts/ci/fast_lane_registry.py b/scripts/ci/fast_lane_registry.py index da6166371d..3b8b11eab5 100644 --- a/scripts/ci/fast_lane_registry.py +++ b/scripts/ci/fast_lane_registry.py @@ -1368,9 +1368,22 @@ class Guard: # classe de collision que le renommage TRANCHE9 -> TRANCHE10 plus haut -- le # POSTERIEUR cede l'index, jamais l'inverse (deux affectations du meme nom se # remplaceraient silencieusement et un garde disparaitrait du registre). +# +# PROMOTION EN CLIQUET (#17044, 2026-09-24). Le garde naissait advisory : la +# dette corpus heritee au cablage (109 findings) aurait rougi toute PR +# touchant un carnet porteur, ce qui punit le voisin, pas l'auteur. Le +# cliquet leve exactement cette objection sans renoncer au mandat user +# 2026-09-20 (« une sortie de cellule a UNE cellule de lecture ; si elle en a +# deja une, on la REECRIT, on n'en ajoute jamais une seconde ») : il ne +# regarde QUE ce que la PR change -- lectures AJOUTEES (detect_added_readings) +# ou compte de paires qui MONTE sur un carnet touche. Les 91 findings herites +# restent donc grandfathered, et le cliquet ne rougit que l'augmentation. +# Mesure avant cablage : 0/11 faux positifs sur les 11 dernieres PR notebook +# mergees, controle positif fondateur 26+16 intact (organe +# scripts/ci/check_17464_positive_control.py), self-test 5/5. TRANCHE14: list[Guard] = [ Guard( - name="Split-reading-cells advisory (per-notebook, non-blocking)", + name="Split-reading ratchet (base vs PR)", source="split-reading-advisory.yml", paths=[ "MyIA.AI.Notebooks/**/*.ipynb", @@ -1378,14 +1391,17 @@ class Guard: "scripts/tests/test_check_split_reading_cells.py", ".github/workflows/split-reading-advisory.yml", ], - iterate_paths=["MyIA.AI.Notebooks/**/*.ipynb"], + pre_argv=[ + "python", "scripts/notebook_tools/check_split_reading_cells.py", + "--self-test", + ], argv=[ "python", "scripts/notebook_tools/check_split_reading_cells.py", - "--json", "--fail-on-findings", "{changed_paths}", + "--base-ref", "{base_ref}", "--head", "HEAD", + "--json", "--fail-on-findings", ], - blocking=False, # advisory : signale les paires scindees, ne rougit jamais - iterates_paths=True, + blocking=True, # cliquet : rougit l'AJOUT de lecture scindee, jamais la dette heritee + needs_base=True, absorbed=True, - warn_rc=(1, 2), # rc=2 = findings (signale sans bloquer) ; rc=1 = vacuue ), ] diff --git a/scripts/notebook_tools/check_split_reading_cells.py b/scripts/notebook_tools/check_split_reading_cells.py index db8011a354..6de2cfcd04 100644 --- a/scripts/notebook_tools/check_split_reading_cells.py +++ b/scripts/notebook_tools/check_split_reading_cells.py @@ -52,10 +52,31 @@ passait sous l'organe consecutive d'origine -- le veto user dit pourtant la meme regle. -Codes de retour : 0 = aucun finding ; 1 = cible introuvable ou fichier designe -illisible ; 2 = findings (avec --fail-on-findings). En mode dossier, un carnet -illisible est **rapporte sur stderr et saute** : il n'interrompt pas le -recensement et ne fait pas rougir le scan (cf #17044). +Mode CLIQUET (#17044) : ``--base-ref [--head HEAD]`` compare chaque carnet +modifie entre la base et la tete et rend le verdict du cliquet -- rouge +seulement si la PR **augmente** ce que l'organe voit sur un carnet qu'elle +touche : + + - ``regressed`` = au moins une lecture AJOUTEE (mode diff, position-aware) OU + ``len(detect(head)) > len(detect(base))`` (le compte des paires consecutive). + Les findings deja sur ``main`` sont donc *grandfathered* : c'est un cliquet, + pas un plancher absolu. + - les renames sont resolus via ``--name-status -M`` (un carnet renomme est lu + a son ANCIEN chemin dans la base -- sans quoi le renommage passerait pour un + ajout et tous ses findings pour des augmentations) ; + - une base irresoluble est une ERREUR (rc=1), jamais un cliquet vide : lire un + carnet absent de la base comme « ajoute » ferait rougir la PR entiere sur un + probleme de fetch. + +``--self-test`` joue cinq controles, hors git et hors reseau : deux positifs +(lecture empilee nommee ; lecture ajoutee SANS en-tete, invisible au detecteur +consecutive) et trois negatifs (deux lectures fusionnees en une ; modification de +code sans lecture ajoutee ; encart sans code execute au-dessus). + +Codes de retour : 0 = aucun finding ; 1 = cible introuvable, fichier designe +illisible, ou base irresoluble ; 2 = findings (avec --fail-on-findings). En mode +dossier, un carnet illisible est **rapporte sur stderr et saute** : il +n'interrompt pas le recensement et ne fait pas rougir le scan (cf #17044). """ from __future__ import annotations @@ -63,6 +84,7 @@ import argparse import json import re +import subprocess import sys import unicodedata from collections import Counter @@ -371,6 +393,16 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: base_counter: Counter[str] = Counter(base_srcs) head_counter: Counter[str] = Counter(head_srcs) + # #17044 -- les IDs de la base, consommes un a un. Le discriminant + # d'origine exigeait la MEME position pour reconnaitre une rewrite par son + # id ; une fusion (ou toute insertion/suppression au-dessus) decale les + # index, et la cellule reecrite repartait alors comme un AJOUT. Mesure sur + # 11 PR notebook mergees : 3 rouges, dont 2 PR de FUSION -- le remede + # prescrit par le mandat user -- toutes deux expliquees par ce decalage. + base_id_pool: Counter[str] = Counter( + c.get("id") for c in base_cells if c.get("id") + ) + findings: list[dict] = [] for idx, src in enumerate(head_srcs): if head_counter[src] <= base_counter[src]: @@ -383,27 +415,45 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: # Discriminant 0 : REWRITE (pas d'insertion). On considere qu'une # cellule est une REWRITE -- et NON un ajout -- quand au moins - # l'UN des deux signaux tient : + # l'UN des trois signaux tient : # (a) meme position index-for-index ET meme source (meme - # contenu exact) ; - # (b) meme position index-for-index ET meme id de cellule - # (attribue par Jupyter dans les exports -- les rewriting - # preservent l'id, les insertions en ont un neuf ou - # doublonne). Le ticket #17464 note que la campagne a - # produit des cellules sans id ET des ids dupliques -- donc - # l'id seul ne suffit pas, mais combine a la position il - # tranche la majorite des cas de rewriting legitimes. + # contenu exact -- ne mord que sur un doublon de source, une + # revision par definition ne repasse pas ici) ; + # (b) l'id de la cellule existe encore en base -- MEME a un autre + # index (#17044). Une fusion retire ou insere des cellules + # au-dessus, donc l'index se decale : exiger la meme position + # faisait passer la rewrite pour un ajout. Le ticket #17464 note + # que la campagne a produit des cellules sans id ET des ids + # dupliques -- l'id est donc consomme une fois, ce qui borne les + # doublons sans reouvrir la porte ; + # (c) meme position ET les deux cellules sont des lectures : la tete + # a revise celle qui occupait ce slot (#17044). Signal + # topologique, indispensable sur les carnets SANS id (le corpus + # en contient -- cf. les deux carnets du controle positif). # La regle user dit « fusionner / reecrire, pas empiler » : la # reecriture EST l'action prescrite. Ne pas la signaler. is_rewrite = False - if idx < len(base_cells) and base_cells[idx].get("cell_type") == "markdown": - if cell_source(base_cells[idx]) == src: - is_rewrite = True - else: - base_id = base_cells[idx].get("id") - head_id = cell.get("id") - if base_id and head_id and base_id == head_id: - is_rewrite = True + head_id = cell.get("id") + if head_id and base_id_pool.get(head_id, 0) > 0: + base_id_pool[head_id] -= 1 + is_rewrite = True + if (not is_rewrite and idx < len(base_cells) + and base_cells[idx].get("cell_type") == "markdown" + and cell_source(base_cells[idx]) == src): + is_rewrite = True + # (c) meme position ET les DEUX cellules sont des lectures : la tete + # a REVISE celle qui occupait ce slot (#17044). Le mandat prescrit + # cette revision ; sans ce signal elle n'etait reconnue que sur + # les carnets porteurs d'ids, donc le remede etait puni des que + # les cellules n'en avaient pas (le corpus en contient : les deux + # carnets du controle positif #17028 sont dans ce cas). Un + # EMPILEMENT reel a cote n'est pas vu ici : la lecture empilee + # arrive a un index ou la base porte autre chose (ou rien), et + # le compte de paires, lui, monte. + if (not is_rewrite and idx < len(base_cells) + and is_reading_cell(base_cells[idx]) + and is_reading_cell(cell)): + is_rewrite = True if is_rewrite: base_counter[src] += 1 continue @@ -432,6 +482,17 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: if not already_had_md_after: # premiere lecture legitime pour ce code -> ne pas signaler bucket = None + # #17044 -- SECOND_READING apres une MARKDOWN : le bucket ne regardait + # que la topologie (md, md) et signalait donc tout encart insere entre + # deux cellules de prose (mesure : un bandeau « statut epistemique » en + # tete de notebook, #17484 -- aucun code au-dessus). La definition de + # l'issue est « deux cellules de lecture pour une MEME cellule de + # code » : sans code a sortie au-dessus, il n'y a rien a lire, donc pas + # de seconde lecture. Le cas fondateur reste capte -- ses paragraphes + # sans en-tete sont tous poses sous un code a sortie (mesure : 7/7). + if (bucket == "SECOND_READING" and prev_role == "md" + and not _reads_code_above(head_cells, idx)): + bucket = None # Filtre final : EXERCISE_READING_CANDIDATE -> EXERCISE_READING si # la cellule ressemble a une lecture (titre d'interpretation OU # prose > 80 chars apres la premiere ligne). On REJETTE les @@ -459,6 +520,24 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: return findings +def _reads_code_above(cells: list[dict], idx: int) -> bool: + """Vrai si la cellule ``idx`` commente la sortie d'un code situe au-dessus. + + Motif vise par l'issue #17044 : « deux cellules de lecture pour une MEME + cellule de code ». Une prose ajoutee dans une zone qui ne suit AUCUN code a + sortie (preamble d'un notebook, bandeau de statut, en-tete de section) n'est + pas une seconde lecture -- il n'y a rien a lire au-dessus. + """ + for j in range(idx - 1, -1, -1): + c = cells[j] + if c.get("cell_type") != "code": + continue + if is_exercise_cell(c): + return False + return bool(c.get("execution_count") is not None or (c.get("outputs") or [])) + return False + + def _bucket_for(prev_role: str, next_role: str) -> str | None: """Map (prev, next) vers un bucket preliminaire, ou None si rien a signaler. @@ -496,6 +575,210 @@ def _read_nb(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) +# --- #17044 : mode CLIQUET -- delta base vs PR ------------------------------- + +SKIP_PARTS = {".lake", "_output", ".ipynb_checkpoints", "node_modules", "_peters"} + + +def _git(args: list[str], cwd: str | Path | None = None) -> str | None: + """Sortie stdout d'une commande git, ou None si elle echoue.""" + try: + out = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, + encoding="utf-8", errors="replace", check=False, + ) + except OSError: + return None + return out.stdout if out.returncode == 0 else None + + +def resolve_base(base: str, head: str = "HEAD", + cwd: str | Path | None = None) -> str | None: + """merge-base(base, head), ou None si la base est irresoluble. + + Le point de comparaison est ``head``, pas le HEAD du depot : la lane passe + toujours ``HEAD``, mais rejouer une PR non encore mergee (controle + positif) exige de comparer a SA tete, pas a la branche courante. + """ + out = _git(["merge-base", base, head], cwd=cwd) + if out and out.strip(): + return out.strip() + # merge-base echoue aussi quand l'historique est superficiel ; on accepte + # alors la ref elle-meme, mais seulement si elle existe vraiment. + verify = _git(["rev-parse", "--verify", f"{base}^{{commit}}"], cwd=cwd) + return verify.strip() if verify and verify.strip() else None + + +def changed_notebook_pairs(base: str, head: str = "HEAD", + cwd: str | Path | None = None) -> list[tuple[str | None, str]]: + """(chemin en base, chemin en tete) pour chaque carnet touche. + + ``--name-status -M`` : un rename sort en ``R ancien nouveau``, et + c'est l'ANCIEN chemin qui porte le contenu de base. Le lire au nouveau + chemin rendrait ``None``, et le carnet renomme serait vu comme ajoute -- + tous ses findings deviendraient des augmentations (#17044). + """ + out = _git(["diff", "--name-status", "-M", base, head, "--", "*.ipynb"], cwd=cwd) + if out is None: + return [] + pairs: list[tuple[str | None, str]] = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 2: + continue + status = parts[0].strip() + if status.startswith("D"): + continue # carnet supprime : rien a comparer + if status.startswith("R") or status.startswith("C"): + if len(parts) < 3: + continue + base_path, head_path = parts[1], parts[2] + else: + base_path, head_path = parts[1], parts[1] + head_posix = head_path.strip().replace("\\", "/") + base_posix = base_path.strip().replace("\\", "/") + if SKIP_PARTS & set(Path(head_posix).parts): + continue + pairs.append((base_posix, head_posix)) + return sorted(pairs, key=lambda p: p[1]) + + +def read_notebook_at(ref: str, path: str, + cwd: str | Path | None = None) -> dict | None: + """Carnet JSON a une ref git (None = absent ou illisible).""" + raw = _git(["show", f"{ref}:{path}"], cwd=cwd) + if raw is None: + return None + try: + return json.loads(raw) + except ValueError: + return None + + +def ratchet_rows(base_ref: str, head: str = "HEAD", + cwd: str | Path | None = None) -> list[dict] | None: + """Lignes du cliquet : une par carnet touche. None = base irresoluble.""" + base = resolve_base(base_ref, head, cwd=cwd) + if base is None: + return None + rows: list[dict] = [] + for base_path, head_path in changed_notebook_pairs(base, head, cwd=cwd): + head_nb = read_notebook_at(head, head_path, cwd=cwd) + if head_nb is None: + continue # illisible en tete : le recensement ne peut rien dire + base_nb = read_notebook_at(base, base_path, cwd=cwd) if base_path else None + base_total = len(detect(base_nb)) if base_nb is not None else 0 + head_total = len(detect(head_nb)) + added = detect_added_readings(head_nb, base_nb) + rows.append({ + "notebook": head_path, + "base_total": base_total, + "head_total": head_total, + "delta": head_total - base_total, + "added": added, + "regressed": bool(added) or head_total > base_total, + }) + return rows + + +def _nb(cells: list[tuple[str, str, str | None]]) -> dict: + """Carnet minimal pour les controles : (cell_type, source, id). + + Les cellules de code portent une sortie et un execution_count : c'est la + condition d'une lecture (« rien a lire » sinon, cf ``_reads_code_above``), + donc l'omettre rendrait les controles positifs vacues. + """ + out = [] + for t, s, i in cells: + c: dict = {"cell_type": t, "source": s} + if i: + c["id"] = i + if t == "code": + c["execution_count"] = 1 + c["outputs"] = [{"output_type": "stream", "text": "1\n"}] + out.append(c) + return {"cells": out} + + +def self_test() -> int: + """Controles positifs et negatifs du cliquet, sans git ni reseau.""" + code = ("code", "print(1)", "c0") + lecture = ("markdown", "### Lecture\nLe total vaut 1.", "m1") + lecture_chiffree = ("markdown", "### Lecture chiffree\nLe total vaut 1, mesure.", "m2") + base = _nb([code, lecture]) + + checks: list[tuple[str, bool, str]] = [] + + # Positif 1 -- une lecture NOMMEE empilee derriere une lecture existante. + head = _nb([code, lecture, lecture_chiffree]) + added = detect_added_readings(head, base) + checks.append(( + "positif 1 lecture nommee empilee", + any(f["type"] == "SECOND_READING" for f in added) + and len(detect(head)) > len(detect(base)), + f"added={[f['type'] for f in added]} " + f"compte {len(detect(base))} -> {len(detect(head))}", + )) + + # Positif 2 -- lecture ajoutee SANS en-tete apres un code qui en portait + # deja une : le compte consecutive ne bouge pas, le mode diff mord. + base2 = _nb([code, lecture]) + head2 = _nb([ + code, + lecture, + ("markdown", "Le total vaut 1, et c'est bien le total attendu ici.", "m3"), + ]) + added2 = detect_added_readings(head2, base2) + checks.append(( + "positif 2 lecture sans en-tete (invisible au consecutive)", + bool(added2) and len(detect(head2)) == len(detect(base2)), + f"added={[f['type'] for f in added2]} " + f"compte {len(detect(base2))} -> {len(detect(head2))}", + )) + + # Negatif 1 -- deux lectures FUSIONNEES en une (le remede prescrit). + merged = ("markdown", "### Lecture\nLe total vaut 1, mesure et verifie.", "m1") + head3 = _nb([code, merged]) + base3 = _nb([code, lecture, lecture_chiffree]) + added3 = detect_added_readings(head3, base3) + checks.append(( + "negatif 1 lectures fusionnees", + not added3 and len(detect(head3)) <= len(detect(base3)), + f"added={[f['type'] for f in added3]} " + f"compte {len(detect(base3))} -> {len(detect(head3))}", + )) + + # Negatif 2 -- modification de code, aucune lecture ajoutee. + head4 = _nb([("code", "print(2)", "c0"), lecture]) + added4 = detect_added_readings(head4, base) + checks.append(( + "negatif 2 code modifie sans lecture ajoutee", + not added4 and len(detect(head4)) == len(detect(base)), + f"added={[f['type'] for f in added4]} " + f"compte {len(detect(base))} -> {len(detect(head4))}", + )) + + # Negatif 3 -- encart de prose sans code a sortie au-dessus (preamble, + # bandeau de statut) : il n'y a rien a lire, ce n'est pas une seconde + # lecture (mesure : #17484, bandeau « statut epistemique »). + titre = ("markdown", "# Titre du carnet\n\nPublic : Decouverte.", "m0") + head5 = _nb([titre, ("markdown", "> **Statut epistemique** -- sans verdict a ce jour.", "m1")]) + added5 = detect_added_readings(head5, _nb([titre])) + checks.append(( + "negatif 3 encart sans code au-dessus", + not added5, + f"added={[f['type'] for f in added5]}", + )) + + ok = True + for label, passed, detail in checks: + print(f" {'PASS' if passed else 'ECHEC'} {label} -- {detail}") + ok = ok and passed + print(f"self-test cliquet : {'PASS' if ok else 'ECHEC'} " + f"({sum(1 for _, p, _ in checks if p)}/{len(checks)})") + return 0 if ok else 1 + + def scan_root(root: Path, as_json: bool, fail_on_findings: bool = False, base_root: Path | None = None) -> int: """Mode dossier. Si ``base_root`` est fourni, applique le mode diff : pour @@ -598,7 +881,59 @@ def main(argv: list[str] | None = None) -> int: "consecutive d'en-tetes)." ), ) + ap.add_argument( + "--base-ref", + default=None, + help=( + "Ref git de BASE pour le mode CLIQUET (#17044) : l'organe resout " + "le merge-base, lit chaque carnet modifie a sa version de base, et " + "rougit seulement si la tete AUGMENTE ce qu'il voit (lecture " + "ajoutee, ou compte de paires superieur). Les findings deja sur la " + "base sont grandfathered." + ), + ) + ap.add_argument("--head", default="HEAD", + help="Ref git examinee en mode cliquet (defaut: HEAD)") + ap.add_argument( + "--self-test", + action="store_true", + help="Controles positif et negatif du cliquet, sans git ni reseau", + ) args = ap.parse_args(argv) + + if args.self_test: + return self_test() + + if args.base_ref: + rows = ratchet_rows(args.base_ref, args.head) + if rows is None: + print(f"base irresoluble : {args.base_ref}", file=sys.stderr) + return 1 + bad = [r for r in rows if r["regressed"]] + if args.as_json: + print(json.dumps({ + "base_ref": args.base_ref, "head": args.head, + "changed": len(rows), "regressed": len(bad), "rows": rows, + }, ensure_ascii=False, indent=1)) + else: + print(f"base {args.base_ref} | {len(rows)} carnet(s) modifie(s) | " + f"{len(bad)} en regression") + for r in rows: + tag = "REGRESSED" if r["regressed"] else "OK" + delta = f"+{r['delta']}" if r["delta"] >= 0 else str(r["delta"]) + print(f" {tag:9s} {r['notebook']} " + f"paires {r['base_total']} -> {r['head_total']} ({delta})") + for f in r["added"]: + print(f" {f['type']:22s} cellules {f['cells']} " + f"src[:120]={f.get('src_first_120', '')[:80]}") + if bad: + print("\nCliquet : la PR augmente les lectures scindees sur au " + "moins un carnet qu'elle touche. Fusionner la lecture " + "ajoutee dans la lecture existante (le mandat user : " + "« si on rajoute une lecture, on modifie le paragraphe " + "de lecture existant, on n'en rajoute pas un deuxieme »).") + return 2 if (args.fail_on_findings and bad) else 0 + target = Path(args.path) if not target.exists(): print(f"introuvable : {target}", file=sys.stderr) diff --git a/scripts/tests/test_check_split_reading_cells.py b/scripts/tests/test_check_split_reading_cells.py index fcedfca146..b8bcecf2b2 100644 --- a/scripts/tests/test_check_split_reading_cells.py +++ b/scripts/tests/test_check_split_reading_cells.py @@ -50,6 +50,7 @@ involontaire. """ import json +import subprocess import sys from pathlib import Path @@ -822,12 +823,24 @@ def test_diff_mute_si_reecriture_au_meme_endroit_avec_meme_source(): assert detect_added_readings(head, base) == [] -def test_diff_signale_reecriture_avec_nouveau_contenu(): +def test_diff_exempte_reecriture_en_place_d_une_lecture(): """Une PR qui REECRIT une lecture au meme slot mais avec un contenu different : pedagiquement c'est acceptable (l'organe n'a pas vocation a juger la qualite du contenu), mais le delta topologique EST un ajout (nouvelle prose). L'organe le signale comme SECOND_READING pour attirer l'attention -- le merge-gate coordonnateur tranche en lecture. + + CONTRAT SUPERSEDE PAR #17044 (ce test s'appelait + ``test_diff_signale_reecriture_avec_nouveau_contenu``). Sous un + detecteur ADVISORY, signaler la revision attirait l'attention sans rien + bloquer. Sous un CLIQUET BLOQUANT, la meme regle rougit le geste que le + mandat user PRESCRIT : « si on rajoute une lecture, on modifie le + paragraphe de lecture existant ». Mesure firsthand sur #17028 (au + merge-base) : les 2 findings de ce type etaient exactement deux revisions + en place -- « ### Interpretation : PyGAD sur Rastrigin » devenu + « ### Lecture** : PyGAD minimise... », meme slot. Le gate punissait le + remede, ce pourquoi le discriminant topologique (meme position, deux + lectures) les eteint. """ base = nb( code("print(1)"), @@ -839,12 +852,11 @@ def test_diff_signale_reecriture_avec_nouveau_contenu(): ) findings = detect_added_readings(head, base) # Multiset diff : +1 ajout (Nouvelle formulation), -1 retrait (Ancienne) - # net = 0, MAIS le second walk voit l'ajout comme 'new source' au meme - # slot -- le REWRITE-same-content ne fire pas -> passe au verdict - # topologique -> SECOND_READING (prev=code_with_output, le code en base - # etait suivi d'une md -> already_had_md_after=True). - assert len(findings) == 1 - assert findings[0]["type"] == "SECOND_READING" + # net = 0. Le discriminant 0 voit la cellule de base AU MEME SLOT, elle + # aussi lecture -> REVISION, donc pas d'ajout : c'est la carve prescrite. + # L'empilement reel -- une lecture qui arrive a un index ou la base n'en + # portait pas -- reste rouge (test_cliquet_mord_si_la_lecture_arrive_APRES). + assert findings == [] def test_diff_mute_si_ordre_inchange_et_contenu_identique(): @@ -1001,3 +1013,273 @@ def test_convention_le_titre_compte_dans_la_mesure(): findings = detect(nb(a, b)) assert findings[0]["jaccard"] == 0.125 assert findings[0]["rare_containment"] == 0.25 + + +# --- 6. Cliquet #17044 : base vs PR ------------------------------------------ +# +# Le cliquet ne juge QUE le delta : la dette heritee est grandfathered, et le +# remede prescrit (fusionner) ne doit jamais rougir -- « un gate qui punit le +# remede est pire que pas de gate ». Les trois mecanismes de faux positif +# mesures sur les 11 dernieres PR notebook mergees (3/11 avant correctif, +# 0/11 apres) sont epingles ci-dessous ; chacun est DECISIF : le retirer +# rallume le rouge qu'il eteint. + +from check_split_reading_cells import ( # noqa: E402 + changed_notebook_pairs, + ratchet_rows, +) + +SCRIPT = str(Path(__file__).resolve().parents[1] / "notebook_tools" + / "check_split_reading_cells.py") + + +def _run(repo, *args): + out = subprocess.run( + ["git", *args], cwd=str(repo), capture_output=True, text=True, + encoding="utf-8", errors="replace", check=True, + ) + return out.stdout.strip() + + +def _repo(tmp_path): + """Depot git jetable : hooks et signature desarmes, fins de ligne fixes.""" + repo = tmp_path / "cliquet" + repo.mkdir() + _run(repo, "init", "-q") + for k, v in (("user.email", "tests@localhost"), ("user.name", "tests"), + ("commit.gpgsign", "false"), ("core.hooksPath", "no-hooks"), + ("core.autocrlf", "false")): + _run(repo, "config", k, v) + return repo + + +def _commit(repo, files, message): + """Ecrit ``files`` puis commite. Une valeur ``None`` SUPPRIME le chemin. + + La suppression est ce qui distingue un renommage d'un ajout : sans elle, + l'ancien chemin survit dans l'arbre et git rend ``A`` (le test du + renommage serait alors vacue). + """ + for rel, payload in files.items(): + target = repo / rel + if payload is None: + target.unlink() + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload), encoding="utf-8") + _run(repo, "add", "-A") + _run(repo, "commit", "-q", "-m", message) + return _run(repo, "rev-parse", "HEAD") + + +def _with_id(cell, cid): + return {**cell, "id": cid} + + +NB = "MyIA.AI.Notebooks/Probas/Demo.ipynb" +CASCADE = nb( + code("print(1)"), + _with_id(md("### Lecture du resultat\nA"), "c1"), + _with_id(md("### Analyse de la sortie\nB"), "c2"), + _with_id(md("### Commentaire final\nC"), "c3"), +) + + +def test_cliquet_mord_sur_une_lecture_ajoutee(tmp_path): + """End-to-end : premiere lecture en base, seconde empilee en tete -> rc=2. + + C'est le mandat user 2026-09-20 lui-meme (« on n'en ajoute jamais une + seconde »), et la raison d'etre du passage advisory -> bloquant. + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: nb( + code("print(1)"), + _with_id(md("### Lecture du resultat\nConvergence nette."), "c1"), + )}, "base") + head = _commit(repo, {NB: nb( + code("print(1)"), + _with_id(md("### Lecture du resultat\nConvergence nette."), "c1"), + md("### Lecture chiffree\nLe score atteint 0.94 en 40 iterations."), + )}, "tete") + rows = ratchet_rows(base, head, cwd=str(repo)) + assert [r["regressed"] for r in rows] == [True] + assert rows[0]["base_total"] == 0 and rows[0]["head_total"] == 1 + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--base-ref", base, "--head", head, + "--json", "--fail-on-findings"], + cwd=str(repo), capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["regressed"] == 1 + + +def test_cliquet_ne_rougit_pas_la_dette_heritee(tmp_path): + """Critere 3 de #17044 : un carnet qui portait DEJA sa cascade reste vert. + + La PR ne touche que la cellule de code ; les trois lectures sont intactes. + Un plancher absolu aurait rougi ici, et c'est ce qui avait motive + l'advisory -- le cliquet leve l'objection sans renoncer au mandat. + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: CASCADE}, "base") + head = _commit(repo, {NB: nb( + code("print(42)"), + _with_id(md("### Lecture du resultat\nA"), "c1"), + _with_id(md("### Analyse de la sortie\nB"), "c2"), + _with_id(md("### Commentaire final\nC"), "c3"), + )}, "tete") + (row,) = ratchet_rows(base, head, cwd=str(repo)) + assert row["regressed"] is False + # 1 paire : « Lecture ... » + « Analyse de la sortie ... ». La troisieme + # cellule (« Commentaire final ») n'est pas un en-tete d'interpretation -- + # un encart de conclusion ne forme pas de paire. + assert row["base_total"] == row["head_total"] == 1 + + +def test_cliquet_ne_punit_pas_la_fusion_d_une_cascade(tmp_path): + """Mecanisme A (decalage d'index) -- mesure sur #17564 et #17553. + + La fusion CONSERVE la cellule (son id de base) et reecrit sa source ; une + insertion au-dessus decale les index. Sous le discriminant d'origine + (meme index ET meme source), la rewrite etait indiscernable d'un ajout, et + le cliquet rougissait les DEUX PR de fusion -- exactement le remede + prescrit par le mandat. + + La topologie est choisie pour que SEUL l'id tranche : la cellule de tete + arrive au-dela de la base (le signal topologique ne peut pas mordre), et + le code qu'elle suit existe en base, deja suivi d'une lecture -- sans le + signal d'id, le discriminant « code_with_output » la classe en + SECOND_READING et le test rougit (mesure : sans ce signal, #17564 rend + 2 carnets en regression). + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: nb( + _with_id(code("print(2)"), "k1"), + _with_id(md("### Lecture du resultat\nA"), "c1"), + )}, "base") + head = _commit(repo, {NB: nb( + code("print(0.5)"), + _with_id(code("print(2)"), "k1"), + _with_id(md("### Lecture du resultat\nA et B fusionnes"), "c1"), + )}, "tete") + (row,) = ratchet_rows(base, head, cwd=str(repo)) + assert row["added"] == [] + assert row["regressed"] is False + + +def test_cliquet_ne_punit_pas_la_fusion_sans_ids(tmp_path): + """Mecanisme C -- la meme fusion, sur un carnet SANS id de cellule. + + Le corpus en contient (les deux carnets du controle positif #17028 ont des + cellules sans id), et la carve du mandat y restait punie : sans id, la + revision en place n'etait reconnue que par « meme source », condition qui + ne peut pas mordre sur une revision -- une source identique ne traverse + jamais le diff de multiset. Le discriminant retenu est donc topologique : + meme position, et les DEUX cellules sont des lectures. + + Mesure sur #17028 (au merge-base) : les 2 findings que ce signal eteint + sont exactement deux revisions en place (« ### Interpretation : PyGAD sur + Rastrigin » -> « ### Lecture** : PyGAD minimise... »), meme slot. + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: nb( + code("print(1)"), + md("### Interpretation : hill-climber vs AG"), + )}, "base") + head = _commit(repo, {NB: nb( + code("print(1)"), + md("### Lecture : le hill-climber echoue sur le piege"), + )}, "tete") + (row,) = ratchet_rows(base, head, cwd=str(repo)) + assert row["added"] == [] + assert row["regressed"] is False + + +def test_cliquet_mord_si_la_lecture_arrive_APRES(tmp_path): + """Controle NEGATIF du mecanisme C : l'empilement reel reste rouge. + + La lecture empilee arrive a un index ou la base ne portait PAS de lecture + (ou rien) : le signal topologique ne l'exempte donc pas. Sans ce controle, + le mecanisme C pourrait etre un robinet ouvert -- il ne l'est pas. + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: nb( + code("print(1)"), + md("### Interpretation : hill-climber vs AG"), + )}, "base") + head = _commit(repo, {NB: nb( + code("print(1)"), + md("### Interpretation : hill-climber vs AG"), + md("### Lecture chiffree : le score atteint 0.94"), + )}, "tete") + (row,) = ratchet_rows(base, head, cwd=str(repo)) + assert [f["type"] for f in row["added"]] == ["SECOND_READING"] + assert row["regressed"] is True + + +def test_cliquet_exempte_un_encart_sans_code_execute_au_dessus(tmp_path): + """Mecanisme B (banniere) -- mesure sur #17484. + + Une md qui suit une autre md dont le code amont n'a NI execution_count NI + sortie n'est pas une lecture : il n'y a rien a lire. Sans le filtre, toute + PR inserant un encart sous une cellule en echec (banniere « program is not + installed ») rougissait. + """ + repo = _repo(tmp_path) + code_muet = {"cell_type": "code", "source": ["print(1)"], + "outputs": [], "execution_count": None} + base = _commit(repo, {NB: nb( + code_muet, + _with_id(md("### Lecture du resultat\nA"), "c1"), + )}, "base") + head = _commit(repo, {NB: nb( + code_muet, + _with_id(md("### Lecture du resultat\nA"), "c1"), + md("### Analyse de la sortie\nB"), + )}, "tete") + assert detect_added_readings(nb( + code_muet, + _with_id(md("### Lecture du resultat\nA"), "c1"), + md("### Analyse de la sortie\nB"), + ), nb(code_muet, _with_id(md("### Lecture du resultat\nA"), "c1"))) == [] + + +def test_cliquet_renomme_lit_le_contenu_de_base(tmp_path): + """Mecanisme C (renommage) : ``--name-status -M`` sort l'ANCIEN chemin. + + Lire la base au nouveau chemin rendrait None : base_total tomberait a 0 et + TOUTES les paires du carnet renomme seraient vues comme ajoutees. Le test + est decisif : sans la paire (ancien, nouveau), ce carnet rougit. + """ + repo = _repo(tmp_path) + base = _commit(repo, {NB: CASCADE}, "base") + head = _commit(repo, {"MyIA.AI.Notebooks/Probas/Renomme.ipynb": CASCADE, + NB: None}, "renommage") + pairs = changed_notebook_pairs(base, head, cwd=str(repo)) + assert pairs == [(NB, "MyIA.AI.Notebooks/Probas/Renomme.ipynb")] + (row,) = ratchet_rows(base, head, cwd=str(repo)) + # Sans la paire (ancien, nouveau), la base serait illisible au nouveau + # chemin -> base_total = 0 et head_total = 1 : le carnet renomme rougirait. + assert row["base_total"] == row["head_total"] == 1 + assert row["regressed"] is False + + +def test_cliquet_base_irresoluble_rend_rc1(tmp_path): + """Une base qui n'existe pas ne doit pas rendre un quitus muet. + + Rendre 0 sur une base irresoluble ferait passer la garde pour un verdict + vert alors qu'elle n'a RIEN compare -- le fail-loud est la seule sortie + honnete (meme contrat que le rc=1 du recensement). + """ + repo = _repo(tmp_path) + _commit(repo, {NB: CASCADE}, "base") + assert ratchet_rows("refs/heads/inexistante", "HEAD", cwd=str(repo)) is None + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--base-ref", "refs/heads/inexistante", + "--head", "HEAD", "--fail-on-findings"], + cwd=str(repo), capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + assert proc.returncode == 1 + assert "irresoluble" in (proc.stdout + proc.stderr) diff --git a/scripts/tests/test_fast_lane.py b/scripts/tests/test_fast_lane.py index ec6b1e71fd..55fda2bab2 100644 --- a/scripts/tests/test_fast_lane.py +++ b/scripts/tests/test_fast_lane.py @@ -1300,19 +1300,32 @@ def test_tranche13_reading_anchor_advisory_guard_is_wired(): def test_tranche14_split_reading_guard_is_wired(): - """Tranche 14 = garde split-reading-cells (#16762/#17031), advisory. + """Tranche 14 = garde split-reading-cells (#16762/#17031), cliquet #17044. Cable a l'origine comme deuxieme TRANCHE13, il ecrasait silencieusement le garde reading-anchor de #16704 (redefinition Python) : le postieur cede l'index (cf. registre, commentaire TRANCHE14). + + Forme CLIQUET depuis #17044 : ratchet autonome (le script fait son propre + diff base...HEAD -- d'ou `{base_ref}` et `needs_base`), self-test en + PRE-CONTROLE, et `blocking=True`. La bascule advisory -> bloquant ne tient + que parce que le cliquet ne regarde QUE l'ajout : un plancher absolu aurait + rougi les 91 findings herites de toute PR touchant un carnet porteur. """ assert len(TRANCHE14) == 1 guard = TRANCHE14[0] - assert guard.name == "Split-reading-cells advisory (per-notebook, non-blocking)" - assert not guard.blocking, "advisory : signale les paires scindees, ne rougit jamais" + assert guard.name == "Split-reading ratchet (base vs PR)" + assert guard.blocking, "cliquet #17044 : l'AJOUT de lecture scindee doit rougir" + assert guard.needs_base, f"{guard.name} compare HEAD a la base" assert guard.absorbed, f"{guard.name} doit porter absorbed=True" - assert guard.iterates_paths + assert "--self-test" in " ".join(guard.pre_argv) + assert "{base_ref}" in guard.argv, "ratchet autonome : la lane fournit la base" assert "--json" in guard.argv and "--fail-on-findings" in guard.argv + # Forme ratchet : plus d'iteration par chemin (le script choisit lui-meme + # les carnets du diff), donc plus de {changed_paths} ni de warn_rc. + assert not guard.iterates_paths + assert "{changed_paths}" not in guard.argv + assert not guard.warn_rc def test_both_reading_guards_alive_after_tranche14_split(): @@ -1327,5 +1340,5 @@ def test_both_reading_guards_alive_after_tranche14_split(): assert TRANCHE13[0].name != TRANCHE14[0].name assert {g.name for g in TRANCHE13 + TRANCHE14} == { "Reading-anchor advisory (lecture sans output, #16695)", - "Split-reading-cells advisory (per-notebook, non-blocking)", + "Split-reading ratchet (base vs PR)", }