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
106 changes: 103 additions & 3 deletions scripts/notebook_tools/count_exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,14 @@ def _classify(
# 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.)
# C# form (#18741 PR C): the tail uses ``//`` and the vocabulary is the
# student-marker family -- ``return -1; // TODO etudiant`` (Aspire 01
# c23, the /health waiter). ``;?`` absorbs the C# statement terminator;
# ``TODO`` and ``etudiant`` join the vocabulary exactly as in
# ``_STUDENT_MARKER_VOCAB_RE``: a bare-number return annotated with a
# student TODO is a sentinel, not a computation.
re.compile(
r"\breturn\s+-?\d+\s*#.*\b(?:a compl[eé]ter|a remplir|placeholder|neutre|stub)\b",
r"\breturn\s+-?\d+\s*;?\s*(?://|#|--)\s*(?:TODO\b|[^\n]*\b(?:a compl[eé]ter|a remplir|placeholder|neutre|stub|etudiant)\b)",
re.IGNORECASE,
),
# Pure-sentinelle string literals: ``return "a determiner"``,
Expand Down Expand Up @@ -537,6 +543,30 @@ def _classify(
re.IGNORECASE,
)

#: (#18741 PR C) Reading-exercise declaration. Some markdown cell of the
#: notebook announces that its exercises are READING exercises -- Audio 06-3
#: c11 ``## 5. Trois exercices de lecture chiffree`` : the answer is prose
#: written next to the notebook, no code stub exists, so the numbered
#: headers below can never pair. Measured scope: 8 notebooks in the corpus
#: carry the phrase; the gate below only fires on headers that are ALSO
#: unpaired, so paired local exercises in those notebooks are untouched.
READING_EXERCISE_SECTION_RE = re.compile(r"exercices?\s+de\s+lecture", re.IGNORECASE)

#: (#18741 PR C) External write-space evidence in a numbered exercise
#: header -- the stub lives OUTSIDE the notebook, in the lab project beside
#: it. Direct form: a backticked source-file path (Orleans 01 c9 ``Ouvrir
#: `OrleansAgentLab/Grains.cs` et completer ...``). Indirect form:
#: ``Completer `Class.Member(...)``` (Orleans 02 c20/22/24) -- the dotted
#: member belongs to the lab file the exercise section points to; the
#: ``[^`]*`` tail absorbs argument lists inside the backticks. A header
#: whose local stub pairs never reaches these gates (pairing is checked
#: first), so the only headers counted are those with strictly no local
#: write-space AND positive external evidence in their own text.
EXTERNAL_CS_PATH_RE = re.compile(r"`[^`]*\.(?:cs|csproj|fs)`")
EXTERNAL_COMPLETE_MEMBER_RE = re.compile(
r"compl[eé]t\w*\s+`[A-Za-z_]\w*\.[A-Za-z_]\w*[^`]*`", re.IGNORECASE
)


def _executable_markers_are_deliberate(
pattern: "re.Pattern[str]", source: str
Expand Down Expand Up @@ -1059,7 +1089,7 @@ class ExerciseHit:
cell_index: int
cell_type: str # 'markdown' or 'code'
source: str # full cell source (joined)
detected_by: str # 'markdown_header' | 'code_cell_comment'
detected_by: str # 'markdown_header' | 'code_cell_comment' | 'reading_header' | 'external_header'

@property
def preview(self) -> str:
Expand Down Expand Up @@ -1425,6 +1455,24 @@ def count_exercises_in_notebook(path: Path) -> NotebookCount:
deferred_unpaired: dict[int, int] = {}
blocker_of: dict[int, int] = {}
unpaired_header_cells: set[int] = set()
#: (#18741 PR C) Headers counted by the reading/external gates, by kind.
#: The deferred-chain resolution below consults it: in a reading section
#: (Audio 06-3), consecutive numbered headers block EACH OTHER (c12
#: blocked by c13, c13 by c14), so the upper chain members are deferred
#: and only the terminal reaches the gate -- a gated terminal covers its
#: chain exactly the way a stub-paired terminal does.
gated_kind: dict[int, str] = {}
#: (#18741 PR C) Reading-exercise scope: a notebook that declares its
#: exercises are reading exercises somewhere in its markdown. Precomputed
#: once -- the declaration lives in a SECTION header (Audio 06-3 c11),
#: not in each numbered exercise header below it.
reading_scope = any(
READING_EXERCISE_SECTION_RE.search(
"".join(c.get("source", []))
)
for c in cells
if c.get("cell_type") == "markdown"
)
for idx in sorted(header_cell_indices):
instance_count = header_instance_counts[idx]
header_source = header_sources[idx]
Expand Down Expand Up @@ -1579,7 +1627,45 @@ def count_exercises_in_notebook(path: Path) -> NotebookCount:
# only if that nearer header itself finds no write-space
# (resolved after the loop, blocker chain walk).
deferred_unpaired[idx] = instance_count
elif not forward_has_code_cell:
continue
# (#18741 PR C) Two strictly-evidenced exceptions before the
# drop: the exercise is real but its write-space is NOT a local
# code cell. A reading exercise (the notebook declares
# "exercices de lecture" -- the answer is prose, Audio 06-3) or
# an external-file exercise (the header names the lab source to
# complete, Orleans 01/02 -- the stub lives in the .csproj
# beside the notebook and no local stub can ever pair). Both
# gates require positive evidence; the generic drop rule below
# is unchanged for headers without it. Counted hits do NOT
# inflate unpaired_markdown_instances: their write-space
# exists, elsewhere.
if reading_scope:
for _ in range(instance_count):
result.exercises.append(
ExerciseHit(
cell_index=idx,
cell_type="markdown",
source=header_source,
detected_by="reading_header",
)
)
gated_kind[idx] = "reading_header"
continue
if EXTERNAL_CS_PATH_RE.search(header_source) or (
EXTERNAL_COMPLETE_MEMBER_RE.search(header_source)
):
for _ in range(instance_count):
result.exercises.append(
ExerciseHit(
cell_index=idx,
cell_type="markdown",
source=header_source,
detected_by="external_header",
)
)
gated_kind[idx] = "external_header"
continue
if not forward_has_code_cell:
result.unpaired_markdown_instances += instance_count
unpaired_header_cells.add(idx)
continue
Expand Down Expand Up @@ -1609,6 +1695,20 @@ def count_exercises_in_notebook(path: Path) -> NotebookCount:
if terminal in unpaired_header_cells:
result.unpaired_markdown_instances += count
unpaired_header_cells.add(idx)
elif terminal in gated_kind:
# (#18741 PR C) The chain's terminal was counted by a
# reading/external gate: the deferred members above it are
# exercises of the same family (Audio 06-3 -- c12/c13 deferred
# behind their successors, terminal c14 reading-counted).
for _ in range(count):
result.exercises.append(
ExerciseHit(
cell_index=idx,
cell_type="markdown",
source=header_sources[idx],
detected_by=gated_kind[terminal],
)
)

# Second pass: code-cell exercises with NO preceding markdown header.
#
Expand Down
194 changes: 194 additions & 0 deletions scripts/notebook_tools/tests/test_count_exercises.py
Original file line number Diff line number Diff line change
Expand Up @@ -2575,3 +2575,197 @@ def test_pt11c_mini_notebook_counts_three(self, tmp_path):
"the worked verifier must stop reading as a stub so header 1 "
"pairs its real stub (got %d)" % result.count
)


# ---------------------------------------------------------------------------
# #18741 PR C -- C# sentinel returns, reading exercises, external write-space
# ---------------------------------------------------------------------------


class TestCsharpSentinelReturnTail:
"""``return -1; // TODO etudiant`` is a sentinel, not a computation.

Aspire 01 c23 -- the /health waiter. The C# statement terminator and the
``//`` comment marker kept the tail outside the sentinel-return pattern,
so the TODO marker was gated by ``_body_computes_result`` and the cell
read as a solution (Aspire 01: 2/3).
"""

def test_csharp_number_return_todo_tail_is_a_stub(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("## 6. Exercices"),
_code(
"// Exercice 2 : attendre la disponibilite d'un service.\n"
"async Task<int> WaitHealthyAsync(string baseUrl, int maxTries = 12) {\n"
" return -1; // TODO etudiant\n"
"}\n"
"Console.WriteLine(await WaitHealthyAsync($\"http://localhost:{portA}\"));\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 1, (
"the C# sentinel return with a student TODO tail must count "
"(got %d)" % result.count
)

def test_python_number_return_todo_tail_is_a_stub(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("## Exercices"),
_code(
"# Exercice 1 : sentinelle\n"
"def attendre(baseUrl):\n"
" return -1 # TODO etudiant\n"
"print(attendre('x'))\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 1

def test_computed_number_return_without_tail_stays_solution(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("### Exercice 1 : indice le plus proche"),
_code(
"def plus_proche(xs, cible):\n"
" meilleur = xs[0]\n"
" for x in xs:\n"
" if abs(x - cible) < abs(meilleur - cible):\n"
" meilleur = x\n"
" return -1 # convention : aucun element sous le seuil\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 0, (
"a computed return of -1 whose tail carries no placeholder "
"vocabulary stays a solution (got %d)" % result.count
)


class TestReadingExercises:
"""Audio 06-3 -- ``exercices de lecture`` : prose answers, no code stub.

The section declares the reading scope; the numbered headers below it can
never pair a code stub (there is none), and the deferred-chain resolution
must propagate the reading kind to headers blocked by their successors.
"""

def test_reading_section_headers_count(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("# Comparatif"),
_md(
"## 5. Trois exercices de lecture chiffree\n\n"
"Ils ne demandent pas d'execution mais une lecture des "
"sorties commises."
),
_md("### Exercice 1 — lecture du tableau"),
_md("### Exercice 2 — comparaison du facteur d'abstraction"),
_md("### Exercice 3 — decision operationnelle"),
_md("## Format de ce carnet"),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 3, (
"three reading exercises under a declared reading section must "
"all count, deferred chain included (got %d)" % result.count
)
kinds = {h.detected_by for h in result.exercises}
assert kinds == {"reading_header"}, kinds

def test_reading_gate_requires_scope_declaration(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("# Titre"),
_md("### Exercice 1 — un sujet"),
_md("### Exercice 2 — un autre"),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 0, (
"unpaired numbered headers WITHOUT the reading declaration stay "
"dropped (got %d)" % result.count
)


class TestExternalWriteSpace:
"""Orleans 01/02 -- the stub lives in the lab .csproj beside the notebook.

Orleans 01 headers name the file directly (``Ouvrir `Lab/Grains.cs```),
Orleans 02 headers name the member (``Completer `Grain.Method(...)```);
the witness code cells between headers are runners, not stubs, so the
headers never pair locally.
"""

def test_external_cs_path_header_counts(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("# Orleans"),
_md(
"### Exercice 1 — estimation du cout\n\n"
"Ouvrir `OrleansAgentLab/Grains.cs` et completer "
"`TokenCounterGrain.EstimateCostAsync`."
),
_code(
"// Exercice 1 — temoin attendu tant que la methode est stub.\n"
"var (_, pret) = LabShell.RestartSilo();\n"
"Console.WriteLine(LabShell.RunClient(\"ex1\"));\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 1, (
"an unpaired numbered header naming an external .cs file is an "
"external exercise (got %d)" % result.count
)
assert result.exercises[0].detected_by == "external_header"

def test_external_member_header_counts(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("# Orleans"),
_md(
"### Exercice 1 — resume d'une conversation\n\n"
"Completer `ConversationGrain.SummaryAsync` : retourner "
"le nombre de tours."
),
_code(
"// Exercice 1 — temoin attendu tant que la methode est stub.\n"
"var (_, pret) = LabShell.RestartSilo();\n"
"Console.WriteLine($\"[silo relance] gateway pret : {pret}\");\n"
"Console.WriteLine(LabShell.RunClient(\"ex1\").TrimEnd());\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 1
assert result.exercises[0].detected_by == "external_header"

def test_plain_unpaired_header_stays_dropped(self, tmp_path):
nb = _write_nb(
tmp_path / "a.ipynb",
[
_md("# Titre"),
_md("### Exercice 1 — un sujet sans espace d'ecriture"),
_code(
"var resultat = CalculComplet(42);\n"
"Console.WriteLine(resultat);\n"
),
],
)
result = count_exercises_in_notebook(nb)
assert result.count == 0, (
"the generic drop rule is unchanged for headers without "
"external evidence (got %d)" % result.count
)
Loading