diff --git a/scripts/notebook_tools/count_exercises.py b/scripts/notebook_tools/count_exercises.py index 01382d6942..faa8a4347a 100644 --- a/scripts/notebook_tools/count_exercises.py +++ b/scripts/notebook_tools/count_exercises.py @@ -363,10 +363,14 @@ def _classify( # ONLY (the original C.1 idiome), which under-counted 3 notebook audits: # AEV (13b_Agent_Evaluation) ``resultat = None``, Claudish # ``response_json = None``, OWUI uses ``return -1`` (covered by the next - # pattern). Any name, with optional inline / line-tail ``# TODO``/``# ... - # a completer`` (ignored by the matcher -- the assignment shape alone is - # the marker). NOT applied to the bare ``result = None`` form already - # covered; this is a superset. + # pattern). The assignment ALONE over-fired on demo cells that merely + # INITIALIZE a variable to None before computing (#15713, Hermes demand 1: + # Kokoro-01-5 cell 38 ``inflect_samples = None`` overwritten four lines + # later in a 109-line demo; an AI-Engine-WordPress cell whose ``croise = + # None`` is assigned a computed tuple under an ``if``) -- so this entry is + # NOT unconditional: ``_is_stub_code`` retains it only when + # ``_none_placeholder_passthrough`` confirms the placeholder shape (the + # name is never reassigned a computed value in its own scope). re.compile(r"^\s*[A-Za-z_]\w*\s*=\s*None\b", re.MULTILINE | re.IGNORECASE), # Sentinelle return: ``return -1 # valeur "a completer"``, ``return ... # # placeholder``, etc. The numeric/string literal alone doesn't distinguish @@ -448,6 +452,20 @@ def _classify( # truncated `# A COMPLETER` Problem skeleton), which must stay a stub. COMMENT_STUB_PATTERN_IDX = frozenset({3, 4, 5, 6, 7, 8}) +# Index of the generic `` = None`` assignment pattern above (the #15688 +# widening of ``result = None``). The COMMENT markers above are stubs UNLESS +# the body computes; this one is the opposite polarity -- the bare assignment +# is NOT a stub signal unless ``_none_placeholder_passthrough`` confirms the +# placeholder shape (the None-assigned name is never reassigned a computed +# value later in its own scope). Without the gate the pattern over-fired on +# demo cells that merely INITIALIZE a variable to None before computing: +# Kokoro-01-5 cell 38 (``inflect_samples = None`` overwritten four lines +# later in a 109-line Inflect-Nano demo, which then stole the forward pairing +# of the `Exercice 3` header above it) and an AI-Engine-WordPress cell +# (``croise = None`` assigned a computed tuple under an ``if``, 'exercice' +# present only in a print) -- #15713, Hermes demand 1. +NONE_PLACEHOLDER_PATTERN_IDX = frozenset({10}) + def _effective_code_lines(source: str) -> list[str]: """Non-comment, non-import code lines of a cell (mirrors the filtering in @@ -549,6 +567,64 @@ def _body_computes_result(source: str) -> bool: return False +#: `` = None`` assignment with the assigned name captured. See +#: :data:`NONE_PLACEHOLDER_PATTERN_IDX` -- the pattern is gated on the SHAPE +#: below, not on the bare assignment. +_NONE_ASSIGN_NAME_RE = re.compile( + r"^[ \t]*([A-Za-z_]\w*)\s*=\s*None\b", re.MULTILINE | re.IGNORECASE +) + + +def _none_placeholder_passthrough(source: str) -> bool: + """True when a `` = None`` assignment is the C.1 placeholder itself. + + Gates the generic ``= None`` marker in ``_is_stub_code`` (#15713, Hermes + demand 1). The assignment is retained when the None-assigned name is never + OVERWRITTEN with a computed value later in its own scope -- a + same-or-deeper indentation, after the assignment line. The measured + placeholder idiomes all satisfy this: the in-function passthrough (AEV + ``resultat = None`` / ``return resultat``), the cell-level result holder + (PT_09 ``result_median = None`` + an 'Exercice ... à compléter' print, no + return at all) and the scaffold flag next to a partial return (12-TTS + ``result = None`` below ``return codes_selectionnes, ...``). A demo cell + that merely INITIALIZES a variable to None before a computing pipeline + reassigns it in scope and is a solution, not a stub: Kokoro-01-5 cell 38 + (``inflect_samples = None`` overwritten four lines later inside the demo) + and the AI-Engine-WordPress crossed-delete cell (``croise = None`` then + ``croise = (statut_c, rep_c)`` under an ``if``). A reassignment at a + SHALLOWER indent is outside the assignment's scope -- driver code below + the exercise function (Claudish cell 16, ``resultats = + compare_tiers(...)`` at column 0) calls the stub and does not turn it into + a solution. + """ + lines = source.split("\n") + for m in _NONE_ASSIGN_NAME_RE.finditer(source): + name = m.group(1) + line_no = source.count("\n", 0, m.start()) + indent = len(lines[line_no]) - len(lines[line_no].lstrip(" \t")) + # NB: the `\s*` lives INSIDE the trailing lookahead -- between `=` and + # `(?!None\b)` a backtrackable `\s*` folds to zero width on ``x = + # None`` itself and the check would eat its own exception. The + # `(?:\w+,)*` prefix also catches tuple rebindings (``ok, croise = + # ...``). + reassign_re = re.compile( + rf"^[ \t]*(?:[A-Za-z_]\w*\s*,\s*)*{re.escape(name)}" + rf"\s*[+\-*/%]?=(?!=)(?!\s*None\b)", + re.IGNORECASE, + ) + reassigned = False + for j in range(line_no + 1, len(lines)): + if not reassign_re.match(lines[j]): + continue + j_indent = len(lines[j]) - len(lines[j].lstrip(" \t")) + if j_indent >= indent: + reassigned = True + break + if not reassigned: + return True + return False + + @dataclass class ExerciseHit: """A single detected exercise occurrence with evidence.""" @@ -622,6 +698,15 @@ def _is_stub_code(source: str) -> bool: # a completer" print / raise / assert) stay unconditional. if idx in COMMENT_STUB_PATTERN_IDX and _body_computes_result(source): continue + # The generic `` = None`` assignment is a stub marker only + # in its placeholder-passthrough shape (#15713): the bare + # assignment also matches demo cells that initialize a variable to + # None before computing. + if ( + idx in NONE_PLACEHOLDER_PATTERN_IDX + and not _none_placeholder_passthrough(source) + ): + continue return True lines = [ ln.strip() diff --git a/scripts/notebook_tools/tests/test_count_exercises.py b/scripts/notebook_tools/tests/test_count_exercises.py index f605ce5af3..6181f14947 100644 --- a/scripts/notebook_tools/tests/test_count_exercises.py +++ b/scripts/notebook_tools/tests/test_count_exercises.py @@ -1597,8 +1597,8 @@ def test_sentinel_string_return_is_stub_issue_15676(self, source): ``a trancher`` / ``unknown`` / ``a completer`` / ``a definir`` / ``TODO``); no line-tail comment is required. A REAL classifier returning ``"unknown"`` would currently over-flag -- accepted in - favour of not under-counting textbook placeholder cells. See - counter-test below. + favour of not under-counting textbook placeholder cells (the whitelist + is unconditional; no counter-test can pass against it today). """ assert _is_stub_code(source) is True, source @@ -1712,6 +1712,126 @@ def test_issue_15676_three_notebooks_count_3_3(self, tmp_path): ) +class TestGenericNoneAssignGate15713: + """#15713 (follow-up #15688, Hermes demand 1): the generic `` = None`` + marker is retained only for the placeholder-passthrough shape -- the + None-assigned name flows UNCHANGED to a bare ``return `` and is + never reassigned a computed value.""" + + @pytest.mark.parametrize( + "source", + [ + # Kokoro-01-5 cell 38 (distilled, Hermes demand 2a): the 109-line + # Inflect-Nano demo INITIALIZES ``inflect_samples = None`` then + # overwrites it inside a computing pipeline; no bare + # ``return inflect_samples`` exists. + "inflect_loaded = False\n" + "inflect_samples = None\n" + "inflect_sample_rate = 24000\n" + "try:\n" + " snap_dir = snapshot_download(repo_id='owensong/Inflect-Nano-v1')\n" + " inflect_samples = vmodel(mel).squeeze().detach().cpu().numpy()\n" + " inflect_samples = np.clip(inflect_samples, -1.0, 1.0)\n" + " print('INFLECT-NANO ok', len(inflect_samples))\n" + "except Exception as exc:\n" + " print('modele non disponible :', exc)\n", + # AI-Engine-WordPress crossed-delete cell (distilled): the None + # init is overwritten with a computed tuple under an ``if``; + # 'exercice' appears only inside a print. + "croise = None\n" + "if ADMIN_MDP:\n" + " ok = login_wordpress(session_autre, 'consent.admin', ADMIN_MDP)\n" + " statut_c, rep_c = api_files(session_autre, nonce, 'delete')\n" + " croise = (statut_c, rep_c)\n" + " print('delete croise :', statut_c)\n" + "else:\n" + " print('(absent : test croise non execute -- voir exercice 2)')\n", + # Guard-variable idiome in a working cell: ``best = None`` is a + # loop sentinel, OVERWRITTEN by the computing loop below -- a + # solution, not a placeholder. + "best = None\n" + "for score in scores:\n" + " if best is None or score > best:\n" + " best = score\n" + "print('meilleur :', best)\n", + ], + ) + def test_demo_none_initialization_is_not_stub_issue_15713(self, source): + assert _is_stub_code(source) is False, source + + def test_none_assign_reassigned_then_returned_is_not_stub_issue_15713(self): + """A function whose None default is OVERWRITTEN with a computed value + before ``return`` is a real solution, not a placeholder.""" + source = ( + "def synthese(donnees):\n" + " resultat = None\n" + " if donnees:\n" + " resultat = sum(donnees) / len(donnees)\n" + " return resultat\n" + ) + assert _is_stub_code(source) is False, source + + def test_passthrough_none_assign_stays_stub_issue_15713(self): + """The gate must not swallow the #15676 idioms it exists to protect: + AEV/Claudish `` = None`` + bare ``return `` passthrough.""" + source = ( + "def verificateur(code, probleme):\n" + " # Indice : passes == total.\n" + " resultat = None # TODO etudiant\n" + " return resultat\n" + ) + assert _is_stub_code(source) is True, source + + def test_header_does_not_pair_to_none_init_demo_issue_15713(self, tmp_path): + """Kokoro-01-5 layout (Hermes demand 2b): the ``Exercice 3`` header is + followed FIRST by the Inflect-Nano demo cell, which merely initializes + ``inflect_samples = None``. The demo must not steal the pairing: the + header finds no stub in its window and is dropped, and the real + Exercice 3 stub (after the demo, outside the window) is counted by the + code-cell pass -- the notebook keeps exactly its 3 exercises, not 4.""" + nb = _write_nb( + tmp_path / "kokoro_like.ipynb", + [ + _md("# Kokoro TTS local\n"), + _md("## Exercice 1 : premier rendu\n"), + _code( + "# Exercice 1 : premier rendu\n" + "rendu = None # TODO etudiant\n" + "return rendu\n" + ), + _md("## Exercice 2 : voix multiples\n"), + _code( + "# Exercice 2 : voix multiples\n" + "comparaison = None # TODO etudiant\n" + "return comparaison\n" + ), + _md("## Exercice 3 : dialogue multi-voix\n"), + _code( + "# Demonstration Inflect-Nano-v1 : TTS ultra-leger\n" + "print('INFLECT-NANO-V1 - TTS ULTRA-LEGER')\n" + "inflect_loaded = False\n" + "inflect_samples = None\n" + "try:\n" + " inflect_samples = vmodel(mel).numpy()\n" + " inflect_samples = np.clip(inflect_samples, -1.0, 1.0)\n" + "except Exception as exc:\n" + " print('modele absent :', exc)\n" + ), + _md("Duree estimee : 15-20 minutes. Objectif : alterner les voix.\n"), + _code( + "# Exercice 3 : dialogue multi-voix\n" + "dialogue = None # TODO etudiant\n" + "return dialogue\n" + ), + ], + ) + result = count_exercises_in_notebook(nb) + assert result.count == 3, ( + f"expected 3 exercises (the demo must not steal the Exercice 3 " + f"pairing), got {result.count}" + ) + + class TestD01UnpairedHeaders: def test_headers_with_no_code_cell_are_declared_instances(self, tmp_path): """CSK (01-GitHub-Copilot-SDK-Binding) holds three exercise headings whose