From 2f2e7fe8dcfc614f44bae79f341e02dba445a397 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 30 Sep 2026 20:35:57 +0200 Subject: [PATCH] feat(guard,#18606): carve-out meta-notes par tag metadata (voie 2, decision ai-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une cellule markdown AJOUTEE portant un tag de la liste fermee (audit-notice / sota-notice / source-notice) declare une meta-note (renvoi methodologique), pas une lecture : non signalée, et son unité est retirée du budget de lecture de sa sortie. Quatre gardes de la decision c.5916807033 : 1. liste fermee de tags (meta_notice_tag, META_NOTICE_TAGS) ; 2. cellules AJOUTEES seulement -- le check vit apres les exemptions de revision, une reecriture ou un deplacement ne se blanchit pas ; 3. chaque exemption est RENDUE dans le resume du check-run (notebook, id de cellule, tag) via exempted_out + rendu CLI ratchet EXEMPT-* ; 4. contre-exemples en tests : lecture Source sans tag restee signalee, tag sur reecriture sans effet (budget non reduit), tag hors liste sans effet, exemption ne deplace pas le blame sur la voisine. 121 passed (8 nouveaux) + self-test 6/6. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/split-reading-advisory.yml | 7 + .../check_split_reading_cells.py | 77 ++++++- .../tests/test_check_split_reading_cells.py | 188 ++++++++++++++++++ 3 files changed, 269 insertions(+), 3 deletions(-) diff --git a/.github/workflows/split-reading-advisory.yml b/.github/workflows/split-reading-advisory.yml index c10e1be3d5..6cbe5642e9 100644 --- a/.github/workflows/split-reading-advisory.yml +++ b/.github/workflows/split-reading-advisory.yml @@ -32,6 +32,13 @@ name: Split-reading ratchet (identity, manual dispatch) # Wiring : scripts/ci/fast_lane_registry.py :: TRANCHE14. # Identite : Guard.name == job.name (cf check_absorbed_check_run_identity.py). # Tests : scripts/tests/test_check_split_reading_cells.py. +# +# Carve-out meta-notes (#18606, decision ai-01 30/09, voie 2) : une cellule +# markdown AJOUTEE portant un tag metadata de la liste fermee +# (audit-notice / sota-notice / source-notice) declare une meta-note, pas une +# lecture -- non signalée, budget de sa sortie non consommé, et l'exemption +# est RENDUE dans le résumé du check-run (notebook, id de cellule, tag). +# Cellules ajoutées seulement : réécriture ou déplacement ne se blanchit pas. on: workflow_dispatch: diff --git a/scripts/notebook_tools/check_split_reading_cells.py b/scripts/notebook_tools/check_split_reading_cells.py index 051aa0e8f7..2d9b20af0b 100644 --- a/scripts/notebook_tools/check_split_reading_cells.py +++ b/scripts/notebook_tools/check_split_reading_cells.py @@ -190,6 +190,28 @@ re.IGNORECASE, ) +# Carve-out meta-notes (#18606, decision ai-01 30/09 c.5916807033, « voie 2 ») : +# une cellule markdown AJOUTEE portant un tag de la liste fermee ci-dessous +# declare une meta-note -- un renvoi methodologique (audit, verdict SOTA, +# source des donnees), pas une lecture d'interpretation. Quatre gardes : +# 1. liste fermee : tout autre tag reste sans effet ; +# 2. cellules AJOUTEES seulement -- le check vit apres les exemptions de +# revision, une cellule reecrite ou deplacee ne se blanchit pas par tag ; +# 3. chaque exemption est RENDUE dans le resume du check-run (notebook, +# id de cellule, tag) -- elle sort du compte bloquant, pas du regard ; +# 4. contre-exemples portes par la suite de tests (lecture « Source » non +# taggee, tag sur reecriture, tag hors liste). +META_NOTICE_TAGS = ("audit-notice", "sota-notice", "source-notice") + + +def meta_notice_tag(cell: dict) -> str | None: + """Tag de meta-note porte par la cellule, ou None (liste fermee #18606).""" + tags = cell.get("metadata", {}).get("tags") or [] + for t in tags: + if t in META_NOTICE_TAGS: + return t + return None + def deaccent(s: str) -> str: return "".join( @@ -626,10 +648,18 @@ def _attached_sources(nb: dict, key: str) -> set[str]: } -def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: +def detect_added_readings(head_nb: dict, base_nb: dict | None, + exempted_out: list[dict] | None = None) -> list[dict]: """Mode DIFF (#17464) : signale les cellules markdown **ajoutees** dans une PR dont la position viole la regle user « une sortie = une lecture ». + Carve-out meta-notes (#18606) : une cellule AJOUTEE portant un tag de + ``META_NOTICE_TAGS`` (liste fermee) n'est pas signalée et ne consomme pas + le budget de lecture de sa sortie ; chaque exemption est apposee dans + ``exempted_out`` (si fourni) pour etre rendue dans le resume du check-run + (garde 3 de la decision). Une cellule reecrite ou deplacee ne passe jamais + par ce carve-out (garde 2). + Algorithme : 1. Si ``base_nb`` est None, retourne une liste vide. 2. Diff par multiset de sources (entre par sources, pas par id -- la @@ -696,6 +726,9 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: # verdict se tranche apres la boucle, quand toutes sont connues. pending: dict[str, list[int]] = {} reported: set[int] = set() + # Exemptions meta-notes #18606 (garde 3) : collectees pour le resume. + exempted: list[dict] = [] + exempted_idx: set[int] = set() for idx, src in enumerate(head_srcs): if head_counter[src] <= base_counter[src]: @@ -740,6 +773,23 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: base_counter[src] += 1 continue + # Carve-out meta-notes #18606 (garde 2) : le tag ne blanchit qu'une + # cellule AJOUTEE -- on est ici apres les exemptions de revision et + # l'enonce d'exercice, donc une reecriture ou un deplacement est deja + # reparti plus haut. L'exemption est collectee (garde 3) ; son unite + # est retiree du budget de sa sortie apres la boucle. + notice_tag = meta_notice_tag(cell) + if notice_tag is not None: + exempted.append({ + "cell_index": idx, + "cell_id": cell.get("id"), + "tag": notice_tag, + "src_first_120": src[:120], + }) + exempted_idx.add(idx) + base_counter[src] += 1 + continue + prev_role, next_role = _classify_context(head_cells, idx) key = _output_key_above(head_cells, idx) # Le COMPTE prime sur la topologie (#17044, decision c.5836401913). Une @@ -786,6 +836,15 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: reported.add(idx) base_counter[src] += 1 + # Coherence du budget #18606 : une meta-note exemptee ne compte pas comme + # lecture -- on retire son unite du deficit de SA sortie, sinon le sweep + # signalerait une AUTRE cellule rattachee a la meme sortie a la place de + # l'exemptee (deplacement du blame, pas une exemption). + for exc in exempted: + exc_key = _output_key_above(head_cells, exc["cell_index"]) + if exc_key is not None and excess.get(exc_key, 0) > 0: + excess[exc_key] -= 1 + # Le releve est par SORTIE, pas par cellule : plusieurs cellules peuvent se # disputer un meme budget, et celle qui a fait monter le compte n'est pas # toujours celle que les exemptions de revision ont laissee passer. Trois @@ -804,7 +863,7 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: pend = pending.get(key, []) attached = [ i for i, c in enumerate(head_cells) - if i not in reported and i not in pend + if i not in reported and i not in pend and i not in exempted_idx and _output_key_above(head_cells, i) == key ] moved = [i for i in attached if cell_source(head_cells[i]) not in held] @@ -814,6 +873,8 @@ def detect_added_readings(head_nb: dict, base_nb: dict | None) -> list[dict]: reported.add(idx) findings.sort(key=lambda f: f["cells"][0]) + if exempted_out is not None: + exempted_out.extend(exempted) return findings @@ -961,13 +1022,18 @@ def ratchet_rows(base_ref: str, head: str = "HEAD", 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) + exempted: list[dict] = [] + added = detect_added_readings(head_nb, base_nb, exempted_out=exempted) rows.append({ "notebook": head_path, "base_total": base_total, "head_total": head_total, "delta": head_total - base_total, "added": added, + # Garde 3 de la decision #18606 : le carve-out reste VISIBLE -- + # chaque cellule exemptionnee par tag est listee (notebook, id de + # cellule, tag) ; elle sort du compte bloquant, pas du regard. + "exempted": exempted, # Le verdict EST le constat (#17044, decision c.5836401913) : le # cliquet compte les lectures par sortie, il ne diffe plus les # sources. Les deux totaux restent rendus, mais pour l'information @@ -1251,6 +1317,11 @@ def main(argv: list[str] | None = None) -> int: for f in r["added"]: print(f" {f['type']:22s} cellules {f['cells']} " f"src[:120]={f.get('src_first_120', '')[:80]}") + for e in r.get("exempted", []): + # Garde 3 (#18606) : l'exemption est rendue dans le resume. + print(f" {'EXEMPT-' + e['tag']:22s} cellule " + f"{e['cell_index']} id={e.get('cell_id')} " + f"src[:120]={e.get('src_first_120', '')[:60]}") if bad: print("\nCliquet : la PR augmente les lectures scindees sur au " "moins un carnet qu'elle touche. Fusionner la lecture " diff --git a/scripts/tests/test_check_split_reading_cells.py b/scripts/tests/test_check_split_reading_cells.py index 41c1a171d1..0d621fcf8f 100644 --- a/scripts/tests/test_check_split_reading_cells.py +++ b/scripts/tests/test_check_split_reading_cells.py @@ -1843,3 +1843,191 @@ def test_cliquet_exempte_la_convention_archive(tmp_path): ) assert proc.returncode == 0, proc.stdout + proc.stderr assert json.loads(proc.stdout)["regressed"] == 0 + + +# ============================================================================= +# Carve-out meta-notes (#18606, decision ai-01 30/09 c.5916807033, voie 2) : +# tags de la liste fermee, cellules AJOUTEES seulement, exemption rendue, +# contre-exemples. Garde 4 de la decision : ces tests sont le contrat. +# ============================================================================= + +from check_split_reading_cells import ( # noqa: E402 + META_NOTICE_TAGS, + meta_notice_tag, +) + + +def _md_tagged(text, tag): + """Cellule markdown portant un tag de meta-note en metadata.""" + return { + "cell_type": "markdown", + "source": [text], + "metadata": {"tags": [tag]}, + "id": "meta-1", + } + + +def test_meta_notice_tag_liste_fermee(): + """Garde 1 : seuls les trois tags canoniques portent l'exemption.""" + for t in META_NOTICE_TAGS: + assert meta_notice_tag({"metadata": {"tags": [t]}}) == t + # hors liste : aucun effet, quelle que soit la casse ou la parente + for t in ("note", "audit_notice", "Audit-Notice", "source", "meta"): + assert meta_notice_tag({"metadata": {"tags": [t]}}) is None + assert meta_notice_tag({"metadata": {}}) is None + assert meta_notice_tag({}) is None + + +def test_meta_notice_exempte_une_lecture_ajoutee(): + """Le cas d'usage #18497/#18502 : une meta-note « Source » AJOUTEE sous + une sortie qui avait deja sa lecture ne declenche plus SECOND_READING -- + et l'exemption est collectee pour le resume (garde 3). + """ + base = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + ) + head = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + _md_tagged("**Source :** donnees CNIL 2024, exemptees de la regle " + "une-sortie-une-lecture.", "source-notice"), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + assert findings == [] + assert len(exempted) == 1 + e = exempted[0] + assert e["tag"] == "source-notice" + assert e["cell_index"] == 2 + assert e["cell_id"] == "meta-1" + assert e["src_first_120"].startswith("**Source :**") + + +def test_meta_notice_sans_tag_reste_signalee(): + """Contre-exemple garde 4 : la MEME cellule « Source » sans tag reste un + SECOND_READING -- l'exemption porte sur le tag, pas sur le contenu. + """ + base = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + ) + head = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + md("**Source :** donnees CNIL 2024."), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + assert [f["type"] for f in findings] == ["SECOND_READING"] + assert exempted == [] + + +def test_meta_notice_sur_reecriture_n_exempte_pas(): + """Contre-exemple garde 4 : un tag pose sur une cellule REECRITE (id de + base conserve, meme slot) ne blanchit rien -- et ne reduit pas le budget : + la vraie addition non taggee a cote reste signalee. + """ + base = nb( + code("print(1)"), + {"cell_type": "markdown", + "source": ["### Lecture du resultat\nConvergence nette."], + "metadata": {}, "id": "r1"}, + ) + head = nb( + code("print(1)"), + {"cell_type": "markdown", + "source": ["### Lecture du resultat\nConvergence nette. " + "Details dans la note d'audit."], + "metadata": {"tags": ["audit-notice"]}, "id": "r1"}, + md("### Lecture chiffree\nLe score atteint 0.94."), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + # la cellule taggee est une REECRITURE (id r1 conserve) : pas d'exemption ; + # l'ajout franc non tagge reste un SECOND_READING. + assert exempted == [] + assert [f["type"] for f in findings] == ["SECOND_READING"] + assert findings[0]["cells"] == [2] + + +def test_meta_notice_tag_hors_liste_sans_effet(): + """Contre-exemple garde 4 : un tag hors liste fermee n'exempte rien.""" + base = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + ) + head = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + _md_tagged("Note methodologique ajoutee.", "note"), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + assert [f["type"] for f in findings] == ["SECOND_READING"] + assert exempted == [] + + +def test_meta_notice_exempte_reading_before_code(): + """Couverture du bucket positionnel : une meta-note AJOUTEE devant un + code avec sortie est exemptee par tag (elle renvoie, elle n'introduit pas). + """ + base = nb(code("print(1)"), code("print(2)")) + head = nb( + code("print(1)"), + _md_tagged("**Verdict SOTA :** INTRINSIC, note en annexe.", "sota-notice"), + code("print(2)"), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + assert findings == [] + assert [e["tag"] for e in exempted] == ["sota-notice"] + + +def test_meta_notice_ne_deplace_pas_le_blame(): + """Garde budget : avec DEUX ajouts sous la meme sortie (un tagge, un non), + l'exemption retire exactement UNE unite -- l'ajout non tagge reste + signale, il ne herite pas de l'exemption de sa voisine. + """ + base = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + ) + head = nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + _md_tagged("**Source :** CNIL 2024.", "source-notice"), + md("### Lecture chiffree\nLe score atteint 0.94."), + ) + exempted = [] + findings = detect_added_readings(head, base, exempted_out=exempted) + assert [f["type"] for f in findings] == ["SECOND_READING"] + assert findings[0]["cells"] == [3] + assert [e["cell_index"] for e in exempted] == [2] + + +def test_cli_ratchet_rend_les_exemptions_dans_le_resume(tmp_path): + """Garde 3, bout en bout : le resume du check-run liste chaque cellule + exemptee (tag, index, id) -- elle sort du compte bloquant, pas du regard. + """ + NB_CLI = "MyIA.AI.Notebooks/Demo/Meta.ipynb" + repo = _repo(tmp_path) + base = _commit(repo, {NB_CLI: nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + )}, "base") + head = _commit(repo, {NB_CLI: nb( + code("print(1)"), + md("### Lecture du resultat\nConvergence nette."), + _md_tagged("**Source :** CNIL 2024.", "source-notice"), + )}, "meta-note taggee") + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--base-ref", base, "--head", head, + "--fail-on-findings"], + cwd=str(repo), capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "0 en regression" in proc.stdout + assert "EXEMPT-source-notice" in proc.stdout + assert "id=meta-1" in proc.stdout