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
168 changes: 149 additions & 19 deletions scripts/check_markdown_claims_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,27 @@ def check_notebook(path: Path) -> dict:
# decimal reading is the dominant one.
_THOUSANDS_WITH_DECIMAL_RE = re.compile(r"(\d{1,3}(?:,\d{3})+)(\.\d+)")

# A grouped integer with SEVERAL comma groups ('3,145,728') is a magnitude
# context, not a precision conflict. A single comma followed by one group
# ('1,7565', '1,756') is NOT one: this repository reads it as the
# francophone DECIMAL (the convention `_normalize_num` documents), so it is
# examined as a decimal and never skipped. Two groups of exactly three
# digits are the only unambiguous thousands grouping.
_THOUSANDS_GROUPED_INT_RE = re.compile(r"^\d{1,3}(?:,\d{3}){2,}$")

# A number of the output, with its own notation preserved:
# '1750000' plain integer
# '3,145,728' grouped integer (several groups of exactly three digits)
# '19,798.0862' grouped integer with a decimal part
# '1.7565' english decimal
# '1,7565' francophone decimal
# The grouped form is tried first; the trailing negative lookahead keeps
# '1,7565' out of the grouped reading ('1,756' would be followed by a
# digit).
_NUM_TOKEN_RE = re.compile(
r"(?<![\d.,])(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:[.,]\d+)?)(?![\d])"
)


def _rounds_to_claim(norm: str, out_text: str) -> bool:
"""True when an output number rounds to the claimed value.
Expand Down Expand Up @@ -1315,19 +1336,33 @@ def _fuzzy_present(norm: str, out_text: str) -> bool:
(a) prefix-of-output: '1.3' matches '1.3B' for magnitude (followed by
non-digit). A prefix followed by a digit is NOT matched here --
'1.32 Go' is reached through clause (b), as a rounding, not as a
magnitude prefix.
magnitude prefix. A prefix SHORTER than the claim ('1.7' for the
claim '1.75') is matched only for a zero-padded claim ('2.40'
against '2.4' is the same number).
(b) rounded value: '0.24' matches '0.2385' (the claim is the output
number rounded to the claim's own precision), anywhere in the
output -- not only at its start.
(c) magnitude-prefix against comma-stripped output: '3.1' matches
'3,145,728' (output has 3,145,728 = 3.1M, claim is the magnitude
prefix when thousand separators are stripped).
prefix when thousand separators are stripped). A claim that
TRUNCATES an output number ('1.75' against '1.7565', which rounds
to 1.76) is REFUSED here -- see `_has_truncation_conflict` and
CoursIA #22910.
The minimum norm length is 3 chars (so '7' / '5' are skipped).
"""
if len(norm) < 3:
return False
# Clause (a): prefix is present in the output, followed by non-digit.
# A prefix SHORTER than the claim means the output carries a COARSER
# number than the claim ('1.7' for the claim '1.75'): the claim then
# asserts a precision the output does not have, and the coarser output
# cannot support it -- clause (b) refuses it for the same reason
# ('1.7' never satisfies '1.75'). The one legitimate case is a claim
# padded with zeros ('2.40' against '2.4' is the same number), so a
# partial prefix is accepted only when the dropped tail is all zeros.
for cut in range(len(norm), 2, -1):
if cut < len(norm) and norm[cut:].strip("0"):
continue
prefix = norm[:cut]
pos = 0
while True:
Expand All @@ -1346,29 +1381,124 @@ def _fuzzy_present(norm: str, out_text: str) -> bool:
# Both directions are defects; measured on CoursIA #22769 == #11435.
if _rounds_to_claim(norm, out_text):
return True
# Clause (c): norm is a magnitude prefix when output's thousand
# separators are stripped (output "3,145,728" -> "3145728", norm
# "3.1" -- treat the dot in the norm as a marker; we re-anchor the
# norm against the output by matching the leading digits before the
# dot). Gated by len(norm) >= 4 OR the output containing a comma
# (large-number signal: 3.1M magnitude against 3,145,728). The
# comma-gate distinguishes '3.1' vs '3,145,728' (magnitude) from
# '1.3' vs '1.32' (precision -- no comma, no magnitude match).
if "." in norm and len(norm) >= 3 and ("," in out_text or len(norm) >= 4):
# Clause (c): norm is a MAGNITUDE prefix of a large number printed by
# the output ('3.1' against '3,145,728' = 3.1M, '1.75' against
# '1750000' = 1.75M).
#
# #22910 -- the former form stripped every non-digit of the output into
# ONE digit run and looked for the claim's digits anywhere inside it.
# Two defects came with it. (1) The claim's digits could SPAN the
# decimal point of the output ('1.75' in '1.7565': the run '17565'
# starts with '175'), which is a TRUNCATION -- a precision the output
# contradicts -- and not a magnitude. (2) The digits could also span
# TWO different numbers ('1.75' in '17.2632': the run '172632'
# contains '17'... '175'), which is a coincidence of digits.
#
# #22910 (revue NanoClaw) -- gating the clause on the mere PRESENCE of
# a comma was itself the defect in francophone notation: a decimal
# comma ('1,7565') is a comma, so every notebook of this repository
# re-opened the digit run, while the truncation veto -- which used to
# skip every comma token -- could not see the very decimal it had to
# judge.
#
# The rule now: the claim's digits must be a PREFIX of the INTEGER PART
# of one number of the output. That is what makes the two readings
# separable without consulting the notation at all -- a magnitude has
# all its significant digits in the integer part ('1750000'), a
# truncation straddles the point ('1.7565' has integer part '1').
# The veto below then refuses the claim when the output ALSO prints a
# decimal that contradicts it.
if "." in norm and len(norm) >= 3:
head, tail = norm.split(".", 1)
digit_run = re.sub(r"[^0-9]", "", out_text)
combined = head + tail # e.g. "3.1" -> "31"
if combined and combined in digit_run:
pos = digit_run.find(combined)
tail_len = len(digit_run) - (pos + len(combined))
# Tolerance: a 7-digit "3,145,728" has 5 trailing digits
# after the magnitude "31". Cap at 12 digits total in the
# digit_run (8+ trailing) so the magnitude is bounded.
if tail_len <= 12:
if combined.isdigit():
for match in _NUM_TOKEN_RE.finditer(out_text):
token = match.group(1)
if not _is_magnitude_host(token):
continue
int_digits = token.split(".", 1)[0].replace(",", "")
if not int_digits.startswith(combined):
continue
tail_len = len(int_digits) - len(combined)
# Tolerance: a 7-digit "3,145,728" has 5 trailing digits
# after the magnitude "31". Cap at 12 trailing digits so the
# magnitude stays bounded.
if tail_len > 12:
continue
if _has_truncation_conflict(norm, out_text):
return False
return True
return False


def _is_magnitude_host(token: str) -> bool:
"""True when the token is a LARGE number -- the context clause (c)
exists to serve ('3,145,728' is 3.1M).

A plain integer ('1750000') is one. A grouped integer is one when the
grouping is unambiguous: several groups of exactly three digits
('3,145,728'), or a group arbitrated by a decimal point ('19,798.0862'
-- the point settles the comma as a separator). A SINGLE bare group
('1,756', '1,024') stays a francophone DECIMAL: with no point to
arbitrate, the decimal reading is the dominant one -- and a decimal is
a precision context, never a magnitude.
"""
int_part = token.split(".", 1)[0]
if "," not in int_part:
return True
if int_part.count(",") >= 2:
return True
return "." in token


def _has_truncation_conflict(norm: str, out_text: str) -> bool:
"""True when an output number carries the claim's digits as a STRICT
prefix without the claim rounding to it ('1.75' against '1.7565').

The magnitude clause (c) matches the claim's digits anywhere in the
output's digit run, so a value the prose has TRUNCATED is silently
accepted: the output prints 1.7565 (which rounds to 1.76) and the prose
asserts 1.75. The claim is then a precision the output contradicts, and
the organ must not treat it as supported (CoursIA #22910).

Only decimal tokens are examined, in BOTH notations: an anglophone
'1.7565' and a francophone '1,7565' are the same number, and this
repository's notebooks use the francophone one predominantly. A grouped
integer ('3,145,728' -- several comma groups of exactly three digits) is
a magnitude context, not a precision conflict -- it is the very case
clause (c) exists to serve, and it carries no decimal part to
contradict. The integer part must have the same number of digits as the
claim's, so that '17.56' is not read as a truncation of '1.75'.
"""
if "." not in norm:
return False
claim_int, _, claim_frac = norm.partition(".")
claim_digits = claim_int + claim_frac
if not claim_digits.isdigit():
return False
for match in _DECIMAL_TOKEN_RE.finditer(out_text):
token = match.group(0)
if "," in token:
# A grouped integer is a magnitude, not a precision: '3,145,728'
# has no decimal part. A francophone decimal ('1,7565') DOES.
if _THOUSANDS_GROUPED_INT_RE.match(token):
continue
int_part, _, frac_part = token.partition(",")
else:
int_part, _, frac_part = token.partition(".")
if len(int_part) != len(claim_int):
continue
digits = int_part + frac_part
if len(digits) <= len(claim_digits) or not digits.startswith(claim_digits):
continue
# A truncation candidate -- unless the claim rounds to that number
# ('2.40' against '2.400' is the same number, '1.75' against
# '1.7465' is the genuine rounding handled by clause (b)).
if not _rounds_to_claim(norm, token):
return True
return False


def _excerpt(src: str, raw: str) -> str:
"""Return a 70-char excerpt around the raw claim, for diagnostics."""
pos = src.find(raw)
Expand Down
174 changes: 174 additions & 0 deletions scripts/tests/test_check_markdown_claims_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,180 @@ def test_short_norm_skipped(self):
assert not _fuzzy_present("7", "0.2385")


class TestIssue22910Table:
"""The five rows of the #22910 table, turned into tests.

The table measured `_fuzzy_present` and `_rounds_to_claim` on five
(claim, output) pairs: three were false positives (a neighbouring or a
coarser value accepted), one was a genuine rounding, and the last was a
negative witness.

The fix: clause (c) now opens on the comma signal alone (the thousands
separator), and clause (a) no longer accepts a prefix shorter than the
claim unless the dropped tail is zeros ('2.40' against '2.4' is the same
number).

Measured note: the second row of the table did not come from clause (c)
but from clause (a) -- the prefix '1.7' occurs in the output '1.7' with
nothing after it, which clause (a) accepted. Measured here, not assumed.
"""

def test_neighbour_value_not_matched(self):
"""Row 1: '1.75' against '1.7565' -- the neighbouring value (1.7565
rounds to 1.76) does not support the claim."""
assert not _fuzzy_present("1.75", "1.7565")
assert not _rounds_to_claim("1.75", "1.7565")

def test_coarser_output_not_matched(self):
"""Row 2: '1.75' against '1.7' -- an output coarser than the claim
cannot support it."""
assert not _fuzzy_present("1.75", "1.7")
assert not _rounds_to_claim("1.75", "1.7")

def test_neighbour_value_two_decimals_not_matched(self):
"""Row 3: '2.40' against '2.4067' -- same family as row 1, on a
two-decimal claim."""
assert not _fuzzy_present("2.40", "2.4067")
assert not _rounds_to_claim("2.40", "2.4067")

def test_genuine_rounding_still_matched(self):
"""Row 4: the genuine rounding -- '1.75' is the two-decimal
rounding of '1.7465' quoted from a results table."""
assert _fuzzy_present("1.75", "SA 1.7465 12.8907 17.2632")
assert _rounds_to_claim("1.75", "SA 1.7465 12.8907 17.2632")

def test_negative_witness_still_refused(self):
"""Row 5: the negative witness -- '9.9999' must not be found in an
output that contains '1.7565'."""
assert not _fuzzy_present("9.9999", "SA 1.7565 12.8907")
assert not _rounds_to_claim("9.9999", "SA 1.7565 12.8907")

def test_zero_padded_claim_still_matched(self):
"""Non-regression: a claim padded with zeros is the same number
('2.40' == '2.4')."""
assert _fuzzy_present("2.40", "2.4 Go")

def test_magnitude_with_thousands_separator_still_matched(self):
"""Non-regression: clause (c) keeps its legitimate case -- a
magnitude against a large number grouped by commas."""
assert _fuzzy_present("3.1", "3,145,728")

def test_truncation_refused_even_when_output_carries_a_comma(self):
"""Robustness of the veto: a comma elsewhere in the output (a
grouped integer in a results table) must not re-enable the digit
run for a comma-less value that the prose has TRUNCATED."""
assert not _fuzzy_present("1.75", "SA 1,024 rows 1.7565 12.8907")
assert not _rounds_to_claim("1.75", "SA 1,024 rows 1.7565 12.8907")


class TestFrancophoneNotation:
"""The same defect in the notation this repository uses predominantly.

The francophone decimal comma ('1,7565') is a COMMA, so the
thousands-gate of clause (c) used to open on it -- and the truncation
veto used to skip every comma token, so it could not see the very
decimal it had to judge. The measured consequence: the claim '1.75'
was ACCEPTED against the output 'SA 1,7565 12,8907' -- the exact
false positive the issue closes, in the notation the notebooks of
this repository actually print.
"""

def test_francophone_truncation_is_refused(self):
"""The reproduced false positive: '1.75' against 'SA 1,7565
12,8907' was accepted (the digit run '17565128907' contains
'175')."""
assert not _fuzzy_present("1.75", "SA 1,7565 12,8907")

def test_francophone_truncation_two_decimals(self):
"""The same, on a two-decimal claim: '2.40' against '2,4067'."""
assert not _fuzzy_present("2.40", "SA 2,4067 3,8912")

def test_francophone_genuine_rounding_still_matched(self):
"""Non-regression: a genuine francophone rounding -- '1.75' is the
two-decimal rounding of '1,7465' -- must still be supported."""
assert _fuzzy_present("1.75", "SA 1,7465 12,8907")

def test_francophone_decimal_is_not_a_magnitude(self):
"""A francophone decimal is not a thousands grouping: the clause
must not treat '1,7565' as the magnitude context it exists to
serve ('3,145,728' is)."""
assert not _fuzzy_present("3.1", "1,7565")

def test_grouped_magnitude_still_matched(self):
"""Non-regression: the legitimate thousands grouping -- several
comma groups of exactly three digits -- still opens the clause."""
assert _fuzzy_present("3.1", "3,145,728")
assert _fuzzy_present("3.1", "SA 3,145,728 unites")

def test_english_and_french_notations_agree(self):
"""The two notations must give the SAME verdict on the same
numbers -- otherwise the organ's verdict depends on the notation
the notebook happens to print."""
for english, french in (
("1.7565 12.8907", "1,7565 12,8907"),
("1.7465 12.8907", "1,7465 12,8907"),
("2.4067 3.8912", "2,4067 3,8912"),
):
assert _fuzzy_present("1.75", english) == _fuzzy_present("1.75", french)
assert _fuzzy_present("2.40", english) == _fuzzy_present("2.40", french)


class TestMagnitudeVersusTruncation:
"""Where the claim's digits must lie to read as a MAGNITUDE.

Clause (c) used to strip every non-digit of the output into ONE digit
run and look for the claim's digits anywhere inside it. Two defects
came with that: the digits could straddle the output's decimal point
('1.75' in '1.7565' -- a truncation, not a magnitude) and they could
span TWO numbers ('1.75' in '17.2632' -- a coincidence of digits).
Gating the clause on the mere presence of a comma was the francophone
counterpart of the same defect: a decimal comma is a comma.

The rule now: the claim's digits must be a PREFIX of the INTEGER PART
of one number of the output. A magnitude has all its significant
digits in the integer part ('1750000'); a truncation straddles the
point ('1.7565' has integer part '1'). The notation does not enter the
decision -- which is what makes the two readings separable.
"""

def test_magnitude_without_separator_is_matched(self):
"""The legitimate magnitude written WITHOUT a separator:
'1.75' against '1750000' (1.75M). The former gate on a thousands
group refused it; the reserve asked for this case to be pinned."""
assert _fuzzy_present("1.75", "1750000")
assert _fuzzy_present("1.75", "SA 1750000 items")

def test_claim_digits_must_lie_in_the_integer_part(self):
"""The structural rule itself, on the two shapes that differ only
by where the point stands."""
assert not _fuzzy_present("1.75", "1.7565") # straddles the point
assert _fuzzy_present("1.75", "1750000") # inside the integer part

def test_claim_digits_may_not_span_two_numbers(self):
"""The digit run '172632' contains '175'? no -- but the pair
('1' from one number, '75' from the next) could: the run must
not be read across a boundary."""
assert not _fuzzy_present("1.75", "17.2632")
assert not _fuzzy_present("1.75", "SA 17.2632 12.8907")

def test_contradiction_veto_still_applies_over_a_magnitude(self):
"""The veto keeps its scope: a magnitude elsewhere in the output
does not license a claim the output also contradicts."""
assert not _fuzzy_present("1.75", "1750000 1.7565")

def test_grouped_magnitude_with_a_decimal_part(self):
"""A grouped integer carrying a decimal point ('1,234,567.89') is
a magnitude: the point arbitrates the comma as a separator."""
assert _fuzzy_present("1.23", "1,234,567.89")

def test_single_comma_group_stays_a_decimal_not_a_magnitude(self):
"""Without a point to arbitrate, a bare three-digit group keeps its
francophone decimal reading ('1,024' is 1.024), so it is a
precision context and never a magnitude."""
assert not _fuzzy_present("3.1", "1,024")
assert not _fuzzy_present("3.1", "SA 1,024 rows")


class TestRoundsToClaim:
"""Bounds of the rounding predicate itself (clause b), both directions."""

Expand Down
Loading