Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/split-reading-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
77 changes: 74 additions & 3 deletions scripts/notebook_tools/check_split_reading_cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
188 changes: 188 additions & 0 deletions scripts/tests/test_check_split_reading_cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading