diff --git a/scripts/notebook_tools/count_exercises.py b/scripts/notebook_tools/count_exercises.py index 9949f0b8d2..0f5a954963 100644 --- a/scripts/notebook_tools/count_exercises.py +++ b/scripts/notebook_tools/count_exercises.py @@ -359,7 +359,46 @@ def _classify( # `display` form, such stubs were under-counted (e.g. GameTheory-5 cell Ex2, # `display("Exercice 2 a completer ...")` with no `// TODO`/`// Indice`). re.compile(r'(?:Console\.WriteLine|display)\(\$?["\']Exercice', re.IGNORECASE), - re.compile(r"^\s*result\s*=\s*None\b", re.MULTILINE | re.IGNORECASE), + # Generic `` = None`` placeholder. Was previously ``result = None`` + # 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). 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 + # a computed ``return -1`` (rare in pedagogy) from a placeholder, so the + # marker is the **line-tail comment** carrying placeholder vocabulary + # (``a completer``, ``a remplir``, ``placeholder``, ``neutre``, ``stub``). + # Match the whole-line-tail ``return # ... `` shape -- a + # returned literal in mid-cell without a sentinel comment stays a derived + # return. (OWUI issue #15676 -- ``return -1 # valeur "a completer + # (placeholder neutre)"`` in cell 11.) + re.compile( + r"\breturn\s+-?\d+\s*#.*\b(?:a compl[eé]ter|a remplir|placeholder|neutre|stub)\b", + re.IGNORECASE, + ), + # Pure-sentinelle string literals: ``return "a determiner"``, + # ``return "a trancher"``, ``return "a completer"``, ``return "unknown"``, + # ``return "a definir"``, ``return "TODO"``. The string ITSELF spells the + # placeholder -- no line-tail comment needed. OWUI issue #15676 -- cells + # 13 (``classer`` -> ``return "a determiner"``) and 15 (``verdict`` -> + # ``return "a trancher"``); AEV uses the variable form (``return resultat`` + # with ``resultat = None``). Membership in a short whitelist is safer than + # a free regex: a real ``return "unknown"`` exists in some notebooks (a + # classifier output), and the cost of a missed exercise is much smaller + # than a false positive that over-counts a worked solution. + re.compile( + r'\breturn\s+["\'](?:a\s+(?:d[eé]terminer|trancher|compl[eé]ter|remplir|d[eé]finir)|unknown|TODO|à compléter)["\']', + re.IGNORECASE, + ), re.compile(r"^\s*raise\s+NotImplementedError", re.MULTILINE), re.compile(r"^\s*assert\s+False\b", re.MULTILINE), # "a completer" / "to complete" LINE-COMMENT stub markers. A scaffolded @@ -413,6 +452,21 @@ 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_assignment_is_stub`` confirms the +# placeholder shape (#15688): not a signature default, never reassigned in +# its own scope, and exposing a hole (the None-assigned name is returned, or +# another stub marker co-occurs). 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 @@ -454,13 +508,23 @@ def _function_param_names(source: str) -> set[str]: return names -def _return_is_derived(return_stmt: str, code_lines: list[str], params: set[str]) -> bool: +def _return_is_derived(return_stmt: str, code_lines_before: list[str], params: set[str]) -> bool: """True when a ``return`` statement yields a computed value, not a stub shape. - A derived return is an internal variable assigned in the body, a call, a - subscript, an attribute, a binary expression, or a non-empty literal. None, - an empty-typed literal (``[]``/``{}``/``()``/``0``/``""``/``set()``), or a - pass-through of an unchanged parameter are the stub shapes. + A derived return is an internal variable assigned a **non-None** value in + the body **before** the return, a call, a subscript, an attribute, a binary + expression, or a non-empty literal. None, an empty-typed literal + (``[]``/``{}``/``()``/``0``/``""``/``set()``), a pass-through of an + unchanged parameter, and ``x = None`` returned as ``return x`` are the stub + shapes. + + The placeholder shape is the C.1 stub idiome (AEV ``resultat = None`` / + ``return resultat``, Claudish ``response_json = None``): counting its own + ``= None`` assignment as "the body computes" made the comment-marker gate + swallow those stubs, which is why the generic ``= None`` pattern had to be + widened in the first place (#15688 reserve, ai-01 arbitrage 2026-09-13). + Only assignments *preceding* the return count -- a rebinding after the + return is dead code, not a computation of the returned value. """ m = re.match(r"^return\b(.*)$", return_stmt.strip()) if not m: @@ -484,9 +548,15 @@ def _return_is_derived(return_stmt: str, code_lines: list[str], params: set[str] base = operand if base in params: return False # unchanged parameter passthrough = placeholder stub - body = "\n".join(code_lines) - if re.search(rf"\b{re.escape(base)}\s*[+*/%]?=", body): - return True # assigned in the body (loop-built local, etc.) + assign_re = re.compile( + rf"^(?:[A-Za-z_]\w*\s*,\s*)*{re.escape(base)}\s*[+\-*/%]?=(?!=)" + ) + none_only_re = re.compile( + rf"^(?:[A-Za-z_]\w*\s*,\s*)*{re.escape(base)}\s*=\s*None\b" + ) + for ln in code_lines_before: + if assign_re.match(ln) and not none_only_re.match(ln): + return True # assigned a computed value before the return return False if re.search(r"[+\-*/%]|\b(?:and|or|in)\b|\bis\s+not\b", operand): return True # binary expression @@ -507,13 +577,144 @@ def _body_computes_result(source: str) -> bool: if len(code_lines) < 3: return False params = _function_param_names(source) - for ln in code_lines: + for pos, ln in enumerate(code_lines): if re.match(r"^return\b", ln.strip()): - if _return_is_derived(ln, code_lines, params): + if _return_is_derived(ln, code_lines[:pos], params): return True 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 + + +def _none_assignment_in_signature(source: str) -> bool: + """True when a `` = None`` match is a function-signature default. + + ``STUB_PATTERNS[10]`` is multiline-anchored and ``\\s`` matches the + newline, so a match can start on the line before its name -- the measured + false positive of #15688 (Search-03-Informed c4: a complete 102-line + ``class Node`` whose only marker hit is ``explored_order=None, + heuristic_name=""):``, a continued parameter list). A ``= None`` written + INSIDE an open bracket is an argument default (or a keyword argument in a + call), not a hole left for the student: the cell executes as-is. + """ + depth = 0 + for line in source.split("\n"): + if depth > 0 and _NONE_ASSIGN_NAME_RE.match(line): + return True + depth += line.count("(") + line.count("[") + line.count("{") + depth -= line.count(")") + line.count("]") + line.count("}") + if depth < 0: + depth = 0 + return False + + +def _none_placeholder_has_hole(source: str) -> bool: + """True when a `` = None`` placeholder exposes a hole to fill. + + Two shapes count (#15688): the None-assigned name is RETURNED (the AEV + ``resultat = None`` / ``return resultat`` idiom -- the student must + replace the None with the computed result), or another stub marker + co-occurs in the cell -- including the COMMENT markers (# TODO / + # Indice), because the placeholder idiome is typically ``result = None + # TODO etudiant`` (12-TTS c29, research_l1_tsmom c18, App-22 c17). A + ``best_M = None`` in a complete generator that never reads it back and + carries no other marker (GameTheory-16b c3) initializes a variable it + does not use: no hole, no exercise. + """ + for m in _NONE_ASSIGN_NAME_RE.finditer(source): + name = m.group(1) + if re.search(rf"\breturn\b[^\n]*\b{re.escape(name)}\b", source): + return True + return any( + pat.search(source) + for idx, pat in enumerate(STUB_PATTERNS) + if idx not in NONE_PLACEHOLDER_PATTERN_IDX + ) + + +def _none_assignment_is_stub(source: str) -> bool: + """Composed gate for the generic `` = None`` marker (#15688). + + The assignment counts as a stub placeholder only when ALL THREE shapes + hold: not a signature default (``_none_assignment_in_signature``), the + None-assigned name is never reassigned in its scope + (``_none_placeholder_passthrough``), and the None actually exposes a + hole (name returned, or another stub marker -- + ``_none_placeholder_has_hole``). + + Deliberately NOT gated on ``_body_computes_result`` (measured, #15688 + A/B): the three true positives this marker catches in mixed cells + (12-TTS c29, research_l1_tsmom c18, App-22 c17 -- a complete sibling + function plus a ``result = None # TODO etudiant`` placeholder) all have + ``_body_computes_result`` True from the SIBLING's derived return, so a + body-computes conjunct would un-count three real exercises. The hole + conjunct closes the measured false positives instead: a signature + default (Search-03-Informed c4) has no hole, and a dead ``best_M = + None`` in a complete generator (GameTheory-16b c3) exposes none either. + """ + return ( + not _none_assignment_in_signature(source) + and _none_placeholder_passthrough(source) + and _none_placeholder_has_hole(source) + ) + + @dataclass class ExerciseHit: """A single detected exercise occurrence with evidence.""" @@ -587,6 +788,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 shape (#15713 + #15688): the composed gate + # rejects signature defaults, demo initializers later reassigned, + # and dead ``= None`` in a complete body that exposes no hole. + if ( + idx in NONE_PLACEHOLDER_PATTERN_IDX + and not _none_assignment_is_stub(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 7cca446072..23cd065f06 100644 --- a/scripts/notebook_tools/tests/test_count_exercises.py +++ b/scripts/notebook_tools/tests/test_count_exercises.py @@ -1524,6 +1524,389 @@ def test_todo_with_passthrough_or_skeleton_remains_a_stub(self): "A scaffolded C# skeleton with // TODO is a student stub" ) + @pytest.mark.parametrize( + "source", + [ + # Issue #15676 -- STUB_PATTERNS previously required ``result = + # None`` literally; ``resultat = None``, ``response_json = None``, + # ``data = None`` (any identifier) escaped the matcher, and + # ``return `` of such a placeholder counted as a derived + # return by `_body_computes_result` (the variable IS assigned in + # the body). Now generalized to ``^\s*\s*=\s*None\b`` for + # any identifier, so a 6-cell stub with these shapes is + # recognized as a stub without further reduction. + # AEV 13b_Agent_Evaluation cell 18 -- ``resultat = None`` form: + "# Exercice 1 : verificateur deterministe.\n" + "# TODO etudiant : complete verificateur\n" + "def verificateur(code, probleme):\n" + " # Indice : passes == total.\n" + " resultat = None # TODO etudiant\n" + " return resultat\n", + # Claudish cell 14 -- ``response_json = None``: + "# Exercice 1 : appel brut.\n" + "def call_claudish_raw(prompt: str, model: str = \"glm-5.2\"):\n" + " response_json = None # TODO etudiant\n" + " return response_json\n", + # Generic data binding -- another common idiome: + "# Exercice 1 : charger le dataset.\n" + "def charger(path: str):\n" + " data = None\n" + " return data\n", + ], + ) + def test_generic_none_variable_is_stub_issue_15676(self, source): + """Issue #15676: `` = None`` (any identifier) is a stub. + + Was previously limited to ``result = None`` literally; under that + shape, AEV (``resultat = None``), Claudish (``response_json = None``) + and any ``data = None`` / ``reponse = None`` placeholder escaped + detection, falsely reading as a derived-body solution. The pattern is + now identifier-agnostic. + """ + assert _is_stub_code(source) is True, source + + @pytest.mark.parametrize( + "source", + [ + # Issue #15676 -- sentinel-return shapes whose value SPELLS the + # placeholder (``a determiner``, ``a trancher``, ``a completer``, + # ``unknown``, etc.). A worked ``return "unknown"`` IS possible + # in some classifiers, but the whitelist here is short and the + # cost of an exercise slightly under-counted is much smaller + # than the cost of falsely counting a worked classifier. See + # ``00-Parcours-QA-OWUI.ipynb`` cells 13 (`classer` returning + # ``"a determiner"``) and 15 (`verdict` returning + # ``"a trancher"``). + '# Exercice 1 : determiner la categorie.\n' + 'def classer(status, retries=0, reason=""):\n' + ' # TODO etudiant : completer\n' + ' return "a determiner"\n', + '# Exercice 2 : verdict go/no-go.\n' + "def verdict(echecs_reels, skips, total):\n" + " # TODO etudiant : completer\n" + ' return "a trancher"\n', + '# Exercice 3 : resultat inconnu.\n' + "def get_unknown():\n" + " return \"unknown\"\n", + ], + ) + def test_sentinel_string_return_is_stub_issue_15676(self, source): + """Issue #15676: ``return ""`` is a stub. + + The string ITSELF spells the placeholder (``a determiner`` / + ``a trancher`` / ``unknown`` / ``a completer`` / ``a definir`` / + ``TODO``); no line-tail comment is required. A REAL classifier + returning ``"unknown"`` would over-flag -- an accepted trade-off in + favour of not under-counting textbook placeholder cells: the + whitelist is deliberately unconditional, so no counter-test is + possible against it by design, not for lack of one (wording + reconciled in #15688; the earlier "no counter-test can pass today" + implied one was pending). + """ + assert _is_stub_code(source) is True, source + + def test_sentinel_numeric_return_with_placeholder_comment_is_stub_issue_15676( + self, + ): + """Issue #15676: ``return -1 # ... a completer / placeholder / neutre``. + + OWUI cell 11 (``tests_du_module`` returning ``return -1 # valeur + \"a completer\" (placeholder neutre)``) was under-counted because + ``return -1`` is not part of the empty-typed literals and the + line-tail comment vocabulary matches the new sentinelle comment. + """ + source = ( + "# Exercice 1 : compter les tests d'un module.\n" + "def tests_du_module(code):\n" + " # TODO etudiant : completer\n" + ' return -1 # valeur "a completer" (placeholder neutre)\n' + ) + assert _is_stub_code(source) is True, source + + def test_issue_15676_three_notebooks_count_3_3(self, tmp_path): + """Reproduce the audit H02 GenAI scenario at #15676. + + Three notebooks whose three idiomes (variable form / sentinelle + string / sentinelle numeric + comment) used to render 0/3 each now + render 3/3. + """ + # Notebook 1: ``resultat = None`` variable form (AEV analog). + nb1 = _write_nb( + tmp_path / "aev_like.ipynb", + [ + _md("# Audit GenAI 13b\n"), + _code( + "# Exercice 1 : verifier.\n" + "def verificateur(code, probleme):\n" + " # Indice : passes == total.\n" + " resultat = None # TODO etudiant\n" + " return resultat\n" + ), + _code( + "# Exercice 2 : ablater.\n" + "def ablater(outils, nom):\n" + " resultat = None # TODO etudiant\n" + " return resultat\n" + ), + _code( + "# Exercice 3 : renversement.\n" + "def renversement(v_ab, v_ba):\n" + " resultat = None # TODO etudiant\n" + " return resultat\n" + ), + ], + ) + # Notebook 2: sentinelle string return (OWUI analog). + nb2 = _write_nb( + tmp_path / "owui_like.ipynb", + [ + _md("# Parcours QA-OWUI\n"), + _md("## Exercice 1 — Compter les tests d'un module\n"), + _code( + "def tests_du_module(code):\n" + " # TODO etudiant\n" + ' return -1 # valeur "a completer" (placeholder neutre)\n' + ), + _md("## Exercice 2 — Qualifier un resultat de test\n"), + _code( + "def classer(status, retries=0, reason=\"\"):\n" + " # TODO etudiant\n" + ' return "a determiner"\n' + ), + _md("## Exercice 3 — Trancher : go / no-go\n"), + _code( + "def verdict(echecs_reels, skips, total):\n" + " # TODO etudiant\n" + ' return "a trancher"\n' + ), + ], + ) + # Notebook 3: ``response_json = None`` form (Claudish analog) + + # sentinelle string + numeric. Three distinct idiomes in one NB + # to cover the union. + nb3 = _write_nb( + tmp_path / "claudish_like.ipynb", + [ + _md("# 01-claude-code-via-claudish\n"), + _md("## 6. Exercice 1 : appel direct\n"), + _code( + "def call_claudish_raw(prompt: str, model: str = \"glm-5.2\"):\n" + " response_json = None # TODO etudiant\n" + " return response_json\n" + ), + _md("## 7. Exercice 2 : comparer 3 tiers\n"), + _code( + "def compare_tiers(question: str, max_tokens: int = 128):\n" + " resultat = None # TODO etudiant\n" + " return resultat\n" + ), + _md("## 8. Exercice 3 : classifier HTTP\n"), + _code( + "def classify_http_error(status_code: int) -> str:\n" + " # TODO etudiant\n" + ' return "a determiner"\n' + ), + ], + ) + for nb in (nb1, nb2, nb3): + cnt = count_exercises_in_notebook(nb) + assert cnt.count == 3, ( + f"{nb.name}: expected 3 exercises, got {cnt.count}" + ) + + +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 + + @pytest.mark.parametrize( + "source", + [ + # Search-03-Informed c4 -- a complete ``class Node`` whose only + # ``= None`` hit is a CONTINUED SIGNATURE DEFAULT. STUB_PATTERNS + # [10] is multiline-anchored and ``\s`` folds the newline, so the + # bare pattern fired on the parameter line (#15688, measured at + # PR head). + 'class Node:\n' + ' def __init__(\n' + ' self,\n' + ' grille=None,\n' + ' explored_order=None,\n' + ' heuristic_name=""):\n' + ' self.grille = grille\n' + ' self.explored = explored_order\n' + ' self.h = heuristic_name\n', + # App-26 c23 -- keyword default ``candidate_order=None`` on the + # second line of the ``def`` (second measured false positive of + # the same class, found by the #15688 corpus A/B). + "def greedy_cover(domains, strength, row_allowed=lambda _row: True,\n" + " candidate_order=None):\n" + " suite = couvrir(domains, strength, row_allowed)\n" + " return suite\n", + ], + ) + def test_none_signature_default_is_not_stub_issue_15688(self, source): + """#15688: a ``= None`` INSIDE an open bracket is an argument default + (or a keyword argument in a call), not a hole left for the student -- + the cell executes as-is.""" + assert _is_stub_code(source) is False, source + + def test_dead_none_in_complete_generator_is_not_stub_issue_15688(self): + """GameTheory-16b c3 (#15688, measured at PR head): ``best_M = None`` + never reassigned, never returned, no other marker, in a complete + generator whose return is a computed tuple -- a dead initializer, + not an exercise. Note ``\\bbest_M\\b`` does not match inside + ``best_M_partial`` (the underscore is a word character).""" + source = ( + "def generer_mecanisme(n):\n" + " best_M = None\n" + " best_M_partial = []\n" + " for i in range(n):\n" + " best_M_partial.append(construire(i))\n" + " payment_table = tabuler(best_M_partial)\n" + " best_J = max(j for j in range(n))\n" + " return (best_M_partial[0], payment_table), best_J\n" + ) + assert _is_stub_code(source) is False, source + + def test_mixed_cell_todo_none_placeholder_stays_stub_issue_15688(self): + """12-TTS c29 / research_l1_tsmom c18 / App-22 c17 (#15688 A/B): a + COMPLETE sibling function in the same cell makes the cell-level + ``_body_computes_result`` True, but the ``result = None # TODO + etudiant`` placeholder is a real exercise -- the composed + `` = None`` gate must not consult the cell-level + body-computes signal (measured: gating on it un-counted three real + exercises).""" + source = ( + "def similarite(a, b):\n" + " mots_a = set(a.split())\n" + " mots_b = set(b.split())\n" + " return len(mots_a & mots_b) / max(1, len(mots_a | mots_b))\n" + "\n" + "\n" + "def selectionner(codes):\n" + " codes_selectionnes = [c for c in codes if garde(c)]\n" + " seuil = calcule(codes_selectionnes)\n" + " result = None # TODO etudiant\n" + " return codes_selectionnes, seuil\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):