From 7ec7b89e7a06151f47d665fab27965d7491b63dd Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 6 Sep 2026 20:27:23 +0200 Subject: [PATCH] fix(claims,#14905): ne pas traiter les coordonnees (2,2)->2.2 en decimal Le detecteur anti-fabrication normalisait la prose de coordonnees de grille "(2,2)" en "2.2", puis cherchait ce "2.2" dans la sortie 3x3 ou il n'existe jamais -- d'ou un faux positif de fabrication sur DecPyMC-7 md[67] ("un but en (2,2) et un obstacle en (1,1)"). On ajoute un filtre de famille D : un chiffre UNIQUE de part et d'autre d'une virgule, entre parentheses, est un tuple positionnel, pas un decimal francais. "(0,75)" (deux chiffres apres la virgule) reste un vrai decimal eligible. 3 tests de controle positif/negatif ajoutes (grille -> CLEAN, "(0,75)" -> FABRICATION, c.290 toujours attrape). Co-Authored-By: Claude-Code --- scripts/check_markdown_claims_output.py | 31 ++++++++ .../test_check_markdown_claims_output.py | 78 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/scripts/check_markdown_claims_output.py b/scripts/check_markdown_claims_output.py index 81800105ae..bf6393f243 100644 --- a/scripts/check_markdown_claims_output.py +++ b/scripts/check_markdown_claims_output.py @@ -464,6 +464,16 @@ def _nearest_inline_code_span(prose: str, pos: int) -> str | None: r"[<>≤≥]=?\s*\d|\d\s*[<>≤≥]=?", ) +# Family D (#14905): grid coordinates inside parentheses, like +# '(2,2)' / '(1,1)', are TUPLES (cell positions), not francophone decimals. +# _normalize_num collapses '(2,2)' -> '2.2', so the detector searches for a +# '2.2' that never exists in the 3x3-grid code output and flags a legitimate +# prose coordinate as fabricated (DecPyMC-7 md[67]: 'un but en (2,2) et un +# obstacle en (1,1)'). Criterion (acceptance #14905): a SINGLE digit on each +# side of a comma, wrapped in a parenthesized pair. '(0,75)' -- two digits +# after the comma -- is a genuine decimal and stays eligible. +_COORD_TUPLE_RE = re.compile(r"\(\s*\d\s*,\s*\d\s*\)") + def _line_around(src: str, match_pos: int, match_end: int) -> str: """Return the source line containing the [match_pos, match_end) span. @@ -554,6 +564,25 @@ def _is_threshold_expression(src: str, match_pos: int, match_end: int) -> bool: return bool(_THRESHOLD_OP_RE.search(span)) +def _is_coordinate_tuple(src: str, match_pos: int, match_end: int) -> bool: + """Family D (#14905): a SINGLE digit on each side of a comma wrapped in + parentheses -- '(2,2)' / '(1,1)' -- is a grid-coordinate tuple, not a + francophone decimal. _normalize_num collapses '(2,2)' -> '2.2', so the + detector hunts for a '2.2' that never exists in the 3x3-grid code output + and flags a legitimate prose coordinate as fabricated (DecPyMC-7 md[67]: + 'un but en (2,2) et un obstacle en (1,1)'). Suppressing this match + requires BOTH: the token is exactly ',' AND it is wrapped + by an immediately-enclosing parenthesized pair. '(0,75)' -- two digits + after the comma -- is a genuine decimal and stays eligible. + """ + token = src[match_pos:match_end].strip() + if not re.fullmatch(r"\d,\d", token): + return False + start = max(0, match_pos - 1) + end = min(len(src), match_end + 1) + return bool(_COORD_TUPLE_RE.search(src[start:end])) + + def _is_scalar(value: object) -> bool: """Return whether value is a finite JSON scalar supported by claims.""" if isinstance(value, bool): @@ -853,6 +882,8 @@ def check_notebook(path: Path) -> dict: continue if _is_threshold_expression(prose, match_pos, match_end): continue + if _is_coordinate_tuple(prose, match_pos, match_end): + continue # Search the normalized form in the output text (plus adjacent # variants: e.g. "0.24" present in "0.2385") if norm not in out_text and not _fuzzy_present(norm, raw_out_text): diff --git a/scripts/tests/test_check_markdown_claims_output.py b/scripts/tests/test_check_markdown_claims_output.py index 77cd111614..bf804b89f1 100644 --- a/scripts/tests/test_check_markdown_claims_output.py +++ b/scripts/tests/test_check_markdown_claims_output.py @@ -1041,6 +1041,84 @@ def test_diagnostic_medical_threshold_clean(self, tmp_path): ) +class TestCoordinateTupleFilter: + """c.NNN (#14905): token '(N,N)' avec un chiffre UNIQUE de part et + d'autre de la virgule, entre parentheses, est un tuple positionnel + (grille), PAS un decimal francais. `_normalize_num` collapsait + '(2,2)' -> '2.2', donc le detecteur cherchait un '2.2' inexistant + dans la sortie d'une grille 3x3 et flaguait une prose pedagogique + legitime comme fabriquee (DecPyMC-7 md[67]). Criteres d'acceptation + (#14905) : '(0,75)' -- deux chiffres apres la virgule -- reste un + vrai decimal (eligibile), et une VRAIE fabrication reste attrapee. + """ + + def _scan_tmp(self, tmp_path, cells): + import json as _json + nb = _mk_nb(cells) + p = tmp_path / "fixture.ipynb" + p.write_text(_json.dumps(nb, ensure_ascii=False), encoding="utf-8") + return check_notebook(p) + + def test_grid_coordinates_clean(self, tmp_path): + """Founding case: DecPyMC-7 md[67] -- cible/obstacle positions on a + 3x3 grid. Prose cites '(2,2)' and '(1,1)'; the grid output never + prints '2.2' nor '1.1'. BEFORE (issue #14905) this returned + FABRICATION_DETECTED; AFTER the filter it is CLEAN.""" + cells = [ + _code_cell( + "g = {(2,2):'B', (1,1):'O'}\n" + "for r in range(3):\n" + " for c in range(3):\n" + " print(r, c, '->', 'B' if (r,c) in g else 'E')\n", + [_stream_output( + "0 0 -> E\n0 1 -> E\n0 2 -> E\n" + "1 0 -> E\n1 1 -> O\n1 2 -> E\n" + "2 0 -> E\n2 1 -> E\n2 2 -> B\n" + )], + ), + _md_cell( + "Dans une grille 3x3, la cible est un but en (2,2) et " + "un obstacle en (1,1).\n" + ), + ] + result = self._scan_tmp(tmp_path, cells) + assert result["verdict"] == "CLEAN", ( + f"Expected CLEAN after #14905 coordinate-tuple filter, " + f"got {result['verdict']} with findings: {result['findings']}" + ) + + def test_parenthesized_short_decimal_still_eligible(self, tmp_path): + """'(0,75)' has TWO digits after the comma -- a genuine decimal, + not a single-digit coordinate. Acceptance criterion #14905: this + must stay eligible (a real fabrication here is still flagged). + Output prints nothing close to '0.75', so it is FABRICATION.""" + cells = [ + _code_cell("print('loss=0.23')", [_stream_output("loss=0.23")]), + _md_cell("La precision mesuree est (0,75).\n"), + ] + result = self._scan_tmp(tmp_path, cells) + assert result["verdict"] == "FABRICATION_DETECTED", ( + f"Expected FABRICATION_DETECTED for '(0,75)' (two digits, real " + f"decimal), got {result['verdict']} with findings: {result['findings']}" + ) + + def test_real_fabrication_still_detected(self, tmp_path): + """CONTROLE POSITIF (cf TestFounderPreserved): the c.290 pathologie + -- prose cites a magnitude ABSENT from the output -- must STILL be + flagged after the coordinate filter. Guards the false-negative + edge: '~1,2 M' / '0,09 %' remain detected.""" + cells = [ + _code_cell("print('trainable params: 3,145,728 (0.24%)')", [ + _stream_output("trainable params: 3,145,728 (0.24%)"), + ]), + _md_cell("On attend ~1,2 M de parametres entrainnables, soit ~0,09 %."), + ] + result = self._scan_tmp(tmp_path, cells) + assert result["verdict"] == "FABRICATION_DETECTED", result + norms = {f["normalized"] for f in result["findings"]} + assert "0.09" in norms, result + + # ----------------------------------------------------------------------- # c.415 (#11873) -- integration: REAL fabrication still detected # -----------------------------------------------------------------------