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
93 changes: 89 additions & 4 deletions scripts/notebook_tools/count_exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ``<name> = 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
Expand Down Expand Up @@ -549,6 +567,64 @@ def _body_computes_result(source: str) -> bool:
return False


#: ``<name> = 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 ``<name> = 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."""
Expand Down Expand Up @@ -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 ``<name> = 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()
Expand Down
124 changes: 122 additions & 2 deletions scripts/notebook_tools/tests/test_count_exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ``<name> = None``
marker is retained only for the placeholder-passthrough shape -- the
None-assigned name flows UNCHANGED to a bare ``return <name>`` 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 ``<name> = None`` + bare ``return <name>`` 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
Expand Down
Loading