diff --git a/scripts/notebook_tools/count_exercises.py b/scripts/notebook_tools/count_exercises.py index eaaf19d36d..f7df1f3604 100644 --- a/scripts/notebook_tools/count_exercises.py +++ b/scripts/notebook_tools/count_exercises.py @@ -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"``, @@ -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 @@ -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: @@ -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] @@ -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 @@ -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. # diff --git a/scripts/notebook_tools/tests/test_count_exercises.py b/scripts/notebook_tools/tests/test_count_exercises.py index 5a3cf0e238..dd49284fdd 100644 --- a/scripts/notebook_tools/tests/test_count_exercises.py +++ b/scripts/notebook_tools/tests/test_count_exercises.py @@ -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 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 + )