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
232 changes: 221 additions & 11 deletions scripts/notebook_tools/count_exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<name> = 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 <lit> # ... <vocab>`` 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
Expand Down Expand Up @@ -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 ``<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_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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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


#: ``<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


def _none_assignment_in_signature(source: str) -> bool:
"""True when a ``<name> = 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 ``<name> = 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 ``<name> = 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."""
Expand Down Expand Up @@ -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 ``<name> = 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()
Expand Down
Loading
Loading