diff --git a/docs/ledgers/18493-counter-scan-classification.md b/docs/ledgers/18493-counter-scan-classification.md new file mode 100644 index 0000000000..bf86aef286 --- /dev/null +++ b/docs/ledgers/18493-counter-scan-classification.md @@ -0,0 +1,82 @@ +# Issue #18493 — Scan des compteurs littéraux dans commentaires code + +## Origine + +Issue : #18493 (`hygiene(notebooks): compteurs litteraux dans commentaires de cellules code`). + +La classe de l'issue est **distincte** de la prose markdown visée par #17636 : un compteur en dur dans un commentaire de cellule code (cf. motifs `--tilde_count`, `--NOTE_runtime` dans la table ci-dessous) est de même nature qu'un compteur prose (littéral qui dérive), mais sa correction **casse la byte-identité** de la cellule code et déclenche une ré-exécution C.2 du carnet entier. Les relais md-only (#17636) ne s'y appliquent pas — ces compteurs exigent une PR par carnet. + +## Scan exhaustif + +Outil : `scripts/notebook_tools/scan_code_comment_counters.py` (ajouté par cette PR). + +```bash +python scripts/notebook_tools/scan_code_comment_counters.py +# total hits : voir la section "Mesure" ci-dessous +# notebooks touched : voir la section "Mesure" + +python scripts/notebook_tools/scan_code_comment_counters.py --json +# sortie structuree : by_kind, by_notebook, records (notebook, cell_index, line, kind, snippet) +``` + +### Mesure (regenerer avec le script ci-dessus) + +- 4 kinds identifies : `NOTE_runtime`, `tilde_count`, `gt_runtime_threshold`, `config_dict_doc`. +- Total hits et liste exacte par carnet dans la sortie du script. +- Disambiguation `RUNTIME_GUARDS` (3 regex) : `if x > N`, `delta_e = ...`, `slippage() > N` — filtre les commentaires de logique opérationnelle pour eviter les faux positifs. + +| Kind | Motif type | Action recommandée | +|---|---|---| +| `NOTE_runtime` | note technique avec approximation pédagogique | KEEP-with-annotation | +| `tilde_count` | calibration runtime (`var = N # ~K unite`) ou commentaire pédagogique | A-INVESTIGUER | +| `gt_runtime_threshold` | commentaire de logique opérationnelle (`# > 0 si ...`) | KEEP (PAS un compteur) | +| `config_dict_doc` | doc utilisateur pour choix de preset | KEEP | + +## Classification actionnable + +### Voie 1 — KEEP + +Aucun changement. Le compteur est soit absent (logique opérationnelle), soit une approximation pédagogique non-runtime, soit une doc utilisateur pour choix de preset. Les carnets concernés incluent notamment Lean-10 (c.2 et c.6), Lean-21 et Gibbard-Satterthwaite. Voir le script de scan pour la liste exacte. + +### Voie 2 — EDIT (à arbitrer par les lanes de chaque famille) + +Remplacer la calibration statique par un calcul runtime : +```python +SEQ_LEN = N # ~K mois de trading days +# devient +SEQ_LEN = int(N * factor_runtime) # recalibré runtime +``` + +**Coût** : modification de cellule code → ré-exécution C.2 du carnet entier → 17 PRs Papermill par carnet. **Bénéfice** : la calibration reste vraie si la fenêtre change. + +**Voie recommandée uniquement si** la calibration change avec le contexte d'exécution. Pour `SEQ_LEN` dans QC-Py-22, la valeur est un hyperparamètre — le commentaire est juste une aide à la lecture ; le runtime ne la calcule pas. + +Carnets candidats pour la voie EDIT (cf. sortie JSON du scan) : QC-Py-12, QC-Py-15, QC-Py-22, research_rl_ppo, research_vrp_putwrite, ML/4.3-TransferLearning-ResNet, IIT/ICT-15g, GenAI/PostTraining/PT-11a, GenAI/Video/02-5-LTX2-Audiovisual, GenAI/Video/02-7-CogVideoX, GenAI/Video/04-5-MiniMax-H3, GenAI/Audio/02-1-Chatterbox-TTS, Z3-API/Z3-16c-Meal-Planner-Patient, SC-20-Bitcoin-Scripting. + +### Voie 3 — REMOVE (proscrite) + +Retirer le commentaire sans valeur de remplacement perd l'information pédagogique. **Pas une voie de fix**, c'est une régression. + +## Décision de cette PR + +Cette PR livre **uniquement** : +1. Le script `scripts/notebook_tools/scan_code_comment_counters.py` (l'organe de mesure). +2. Ce rapport de classification (l'artefact durable). + +**Aucun carnet n'est édité** → aucune cellule code touchée → aucune ré-exécution C.2 due → aucune dépendance Papermill. + +Les voies EDIT restent un travail de fond, à prioriser carnet par carnet par les lanes de chaque famille. Le script de scan permet de re-mesurer la classe après chaque campagne (la liste des carnets A-INVESTIGUER sort du `--json`). + +## Acceptance + +- [x] Scan dédié produit la liste exhaustive (cf. section "Scan exhaustif" + `--json`) +- [x] Classification KEEP/EDIT/REMOVE par hit documentée (cf. section "Classification actionnable") +- [x] Aucun carnet édité → C.2 non applicable (cf. "Décision de cette PR") +- [x] Les compteurs restants sont classés KEEP ou A-INVESTIGUER avec justification + +## Liens + +- #18493 (issue d'origine) +- #17636 (campagne prose markdown — relay c15) : autre classe, byte-identité préservée +- `proactive-coordination.md` R6 — variété anti-monoculture : ce PR est `LIGHT/tooling`, pas un DEEP de contenu, et le DEEP est porté par PR #18506 (livrée juste avant) +- `scan_md_hierarchy drift` (workflow `md-hierarchy-drift.yml`) : autre ratchet sur la hiérarchie markdown diff --git a/scripts/notebook_tools/scan_code_comment_counters.py b/scripts/notebook_tools/scan_code_comment_counters.py new file mode 100644 index 0000000000..973d934543 --- /dev/null +++ b/scripts/notebook_tools/scan_code_comment_counters.py @@ -0,0 +1,153 @@ +"""Scan code-cell comments for quantitative literal counters (#18493). + +Different from prose-counts : the prose ratchet scans markdown cells, this +script scans **comments inside code cells**. The two ratchets are +complementary : + +- prose-counts : markdown text with embedded numbers ("127 notebooks") +- this script : Python comments with embedded numbers ("# ~1500 fichiers") + +The class of issues is the same (literals that drift) but the fix path is +different : markdown edits are byte-identical (no re-exec), code-cell +edits trigger C.2 re-exec (papermill or kernel) of the whole notebook. +See #18493 for the originating rationale. + +Output is a structured list (notebook, cell_index, kind, line) so a +follow-up PR can decide KEEP / EDIT / REMOVE per item without re-running +the scan. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from pathlib import Path +from typing import Iterator + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +# ---- patterns --------------------------------------------------------------- +# Each pattern captures a *kind* of literal counter seen in code comments. +# `kind` is the call-site action : KEEP (pedagogical/calibration comment) +# vs EDIT (replace with runtime computation) vs REMOVE (no value). + +KIND_PATTERNS = { + "NOTE_runtime": re.compile( + r"#\s*NOTE\s*:.*~?\d+\s+\w+", # "# NOTE: ~1500 fichiers" + ), + "tilde_count": re.compile( + r"#\s*~(\d+)\s+\w+", # "# ~10 mots" + ), + "gt_runtime_threshold": re.compile( + r"#\s*>\s*\d+\s*(\w+)", # "# >100k theoremes" (NOT a runtime `if x > N`) + ), + "config_dict_doc": re.compile( + r"#\s*\".*\"\s*:\s*\w+,\s*>?\d+\s*\w+", # "# 'large': mathlib4, >100k theoremes" + ), +} + +# Disambiguation patterns : things that LOOK like counters but are +# operational runtime code. They get a FALSE_POSITIVE classification. +RUNTIME_GUARDS = ( + re.compile(r"\bif\b.*[<>]\s*\d"), # `if x > N` -- code, not a counter + re.compile(r"\bdelta_?e?\s*=\s*.*[<>]\s*\d"), # `delta_e = y - y_new # > 0 si ...` + re.compile(r"\bslippage\(\)\s*>\s*\d"), # operational threshold +) + + +def iter_notebooks(root: Path) -> Iterator[Path]: + """Yield every deliverable notebook under MyIA.AI.Notebooks/.""" + for path in (root / "MyIA.AI.Notebooks").rglob("*.ipynb"): + # Skip output / archive copies + if "_output" in path.name or "_archive" in path.parts: + continue + yield path + + +def classify_line(line: str) -> tuple[str, str] | None: + """Return (kind, line) if the line matches any pattern, else None. + + A line is excluded if it looks like runtime code (RUNTIME_GUARDS). + """ + if any(g.search(line) for g in RUNTIME_GUARDS): + return None + for kind, pat in KIND_PATTERNS.items(): + if pat.search(line): + return kind, line.strip() + return None + + +def scan(root: Path = REPO_ROOT) -> list[dict]: + """Return the list of hits as structured records.""" + records: list[dict] = [] + for nb in iter_notebooks(root): + try: + data = json.loads(nb.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + for cell_idx, cell in enumerate(data.get("cells", [])): + if cell.get("cell_type") != "code": + continue + source = "".join(cell.get("source", [])) + for lineno, line in enumerate(source.splitlines(), start=1): + hit = classify_line(line) + if hit is None: + continue + kind, snippet = hit + records.append({ + "notebook": str(nb.relative_to(root)), + "cell_index": cell_idx, + "line": lineno, + "kind": kind, + "snippet": snippet[:200], + }) + return records + + +def summarize(records: list[dict]) -> str: + by_kind = Counter(r["kind"] for r in records) + by_nb = Counter(r["notebook"] for r in records) + out = [] + out.append(f"== total hits: {len(records)} ==") + out.append("") + out.append("== by kind ==") + for kind, n in by_kind.most_common(): + out.append(f" {kind:24s} : {n}") + out.append("") + out.append(f"== notebooks touched: {len(by_nb)} ==") + for nb, n in by_nb.most_common(10): + out.append(f" {nb:80s} : {n}") + if len(by_nb) > 10: + out.append(f" ... and {len(by_nb) - 10} more") + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Scan code-cell comments for quantitative literal counters (#18493)" + ) + ap.add_argument("--json", action="store_true", help="Machine-readable output") + ap.add_argument( + "--root", type=Path, default=REPO_ROOT, + help="Repo root (default: auto-detected)", + ) + args = ap.parse_args() + + records = scan(args.root) + if args.json: + print(json.dumps({ + "total": len(records), + "by_kind": dict(Counter(r["kind"] for r in records)), + "by_notebook": dict(Counter(r["notebook"] for r in records)), + "records": records, + }, ensure_ascii=False, indent=1)) + else: + print(summarize(records)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())