From 1aa58667e949dbb74a2506ed0bb719d73f77ca4d Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 26 Oct 2026 07:29:03 +0800 Subject: [PATCH 1/2] emrg: the closing summary's two counts are measured against the lists they introduce --- tests/test_competition_prompt.py | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/test_competition_prompt.py b/tests/test_competition_prompt.py index 1d3976e7..175cd490 100644 --- a/tests/test_competition_prompt.py +++ b/tests/test_competition_prompt.py @@ -1358,3 +1358,154 @@ def test_the_browser_is_scoped_to_login_walls(rendered): f"the {label} of §0.1 does not carry {why}; {phrase!r} is missing, so 'browser " f"first' and the cheaper path are no longer one instruction" ) + +# --- the two counts §5 states about its own lists ------------------------------------------------ +# +# The defect class #2576 fixed for the promote template's reflection heading, found here by a +# section-scoped survey of spelled-out counts: §5 says "Seven questions" and "Three more", and until +# this pin nothing compared either sentence against the list it introduces. The sentence is the only +# instruction a round reads before it starts answering, so a list edited without it — or the reverse — +# leaves a round owing the wrong number with nothing to notice. +# +# The slice comes from `_section`, which this module already uses, rather than from a helper of its +# own: two names for one boundary is how the two drift apart. + +_RECORDING_HEADING = "### 5. Recording" +_RECORDING_END = "\n### Error Handling" + +_NUMBER_WORDS = { + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, +} + +_SELF_REVIEW_CLAIM = r"\*\*(\w+) questions the closing summary must answer\*\*" +_HOST_CLAIM = r"\*\*(\w+) more the host asked for by name\." +_HOST_ANCHOR = "the host asked for by name" + + +def _as_count(word: str) -> int: + """A spelled-out number as an int, refusing a word this pin cannot read. + + Refusing rather than defaulting is the point: a claim rewritten as a digit ("7 questions") or as + a vague quantity would otherwise make every assertion built on it compare against nothing while + still reading green. "one" is deliberately absent — it is as often a pronoun as a count. + """ + try: + return _NUMBER_WORDS[word.lower()] + except KeyError: + raise AssertionError( + f"§5 states its count as {word!r}, which this pin cannot read as a number. State it as " + f"one of {sorted(_NUMBER_WORDS)} so the sentence and its list stay comparable" + ) from None + + +def _numbered(recording: str) -> list[str]: + """The numbered self-review questions, one entry per question.""" + return re.findall(r"^\d+\. \*\*(.+?)\*\*", recording, re.M) + + +def _bulleted_after_host_questions(recording: str) -> list[str]: + """The bulleted questions §5 attributes to the host: those after that sentence and before `---`.""" + parts = recording.split(_HOST_ANCHOR, 1) + assert len(parts) == 2, ( + f"§5 no longer carries the sentence naming the host's own questions ({_HOST_ANCHOR!r}), so a " + "count taken from that part of the section would be a count of the wrong list" + ) + return re.findall(r"^- \*\*(.+?)\*\*", parts[1].split("\n---", 1)[0], re.M) + + +def _claim(section: str, pattern: str) -> int: + """The count a sentence states, as an int, or a failure naming what is missing.""" + found = re.search(pattern, section) + assert found, ( + f"§5 no longer states the count matching {pattern!r}. The sentence is what tells a round how " + "many items it owes, so a section without one cannot be read as owing any" + ) + return _as_count(found.group(1)) + + +def test_the_closing_summary_counts_the_self_review_questions_it_lists() -> None: + """The sentence and the numbered list it introduces are one fact, read as one.""" + recording = _section(PROMPT.read_text(encoding="utf-8"), _RECORDING_HEADING, _RECORDING_END) + listed = _numbered(recording) + assert listed, ( + "§5 lists no numbered self-review questions, so a count taken from it compares against " + "nothing" + ) + claimed = _claim(recording, _SELF_REVIEW_CLAIM) + assert claimed == len(listed), ( + f"§5 claims {claimed} self-review questions and lists {len(listed)} — the count and the " + f"list have drifted apart: {listed!r}" + ) + + +def test_the_host_questions_count_is_the_number_the_section_says() -> None: + """The second count is measured apart from the first: it introduces a different list.""" + recording = _section(PROMPT.read_text(encoding="utf-8"), _RECORDING_HEADING, _RECORDING_END) + listed = _bulleted_after_host_questions(recording) + assert listed, ( + "§5 states how many questions the host asked for and then lists none, so the claim stands " + "without a subject" + ) + claimed = _claim(recording, _HOST_CLAIM) + assert claimed == len(listed), ( + f"§5 claims {claimed} questions the host asked for and lists {len(listed)} — {listed!r}" + ) + + +def test_the_closing_summary_count_pins_discriminate_in_both_directions() -> None: + """The failure the live template cannot supply, applied to synthetic text. + + A pin that only reads the live file passes today whether or not it could ever fail. These arms + supply both errors it exists for — a list edited without its sentence, and a sentence edited + without its list — plus the opposite error: a count that quietly widened to a neighbouring list + would read the first two arms green while measuring the wrong thing. + """ + section = ( + "### 5. Recording\n\n" + "**Two questions the closing summary must answer**:\n\n" + "1. **First?**\n" + "2. **Second?**\n\n" + "**Two more the host asked for by name.**\n\n" + "- **Third?**\n" + "- **Fourth?**\n" + ) + + assert len(_numbered(section)) == 2 + assert _claim(section, _SELF_REVIEW_CLAIM) == len(_numbered(section)) + assert len(_bulleted_after_host_questions(section)) == 2 + assert _claim(section, _HOST_CLAIM) == len(_bulleted_after_host_questions(section)) + + # a list grows and its sentence does not + grown = section.replace("2. **Second?**\n", "2. **Second?**\n3. **Third?**\n") + assert len(_numbered(grown)) == 3 + assert _claim(grown, _SELF_REVIEW_CLAIM) != len(_numbered(grown)) + + # the sentence grows and the list does not + claim_only = section.replace("**Two questions", "**Three questions") + assert _claim(claim_only, _SELF_REVIEW_CLAIM) != len(_numbered(claim_only)) + + # the host's list grows and its sentence does not + host_grown = section + "- **Fifth?**\n" + assert len(_bulleted_after_host_questions(host_grown)) == 3 + assert _claim(host_grown, _HOST_CLAIM) != len(_bulleted_after_host_questions(host_grown)) + + # a bullet placed BEFORE the host sentence belongs to no counted list, so it moves nothing + before = section.replace( + "**Two more the host", "- **A decoy bullet**\n\n**Two more the host" + ) + assert len(_bulleted_after_host_questions(before)) == 2 + assert _claim(before, _HOST_CLAIM) == len(_bulleted_after_host_questions(before)) + + # a count this pin cannot read is refused rather than defaulted + with pytest.raises(AssertionError, match="cannot read as a number"): + _claim(section.replace("**Two questions", "**7 questions"), _SELF_REVIEW_CLAIM) From f2b879f752948d66a16eede42e85b80afcc28743 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 26 Oct 2026 11:58:47 +0800 Subject: [PATCH 2/2] emrg: read both closing-summary lists by their markers, not their emphasis --- tests/test_competition_prompt.py | 78 ++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/tests/test_competition_prompt.py b/tests/test_competition_prompt.py index 7ad4366d..8c993cb5 100644 --- a/tests/test_competition_prompt.py +++ b/tests/test_competition_prompt.py @@ -1543,20 +1543,48 @@ def _as_count(word: str) -> int: ) from None +#: A list entry, by its marker. Emphasis is stripped afterwards, never required: an entry counts +#: because it sits at a marker, and whether its author bolded it is formatting, not length. +_LIST_ENTRY = re.compile(r"^(?:\d+\.|-)\s+(.*)$") + + +def _entry_text(line: str) -> str | None: + """The text of a list entry, emphasis removed, or `None` when the line is not one.""" + found = _LIST_ENTRY.match(line) + if not found: + return None + return re.sub(r"\*\*|__", "", found.group(1)).strip() + + def _numbered(recording: str) -> list[str]: - """The numbered self-review questions, one entry per question.""" - return re.findall(r"^\d+\. \*\*(.+?)\*\*", recording, re.M) + """The numbered self-review questions, one entry per question. + + Read by marker, not by emphasis. An earlier version matched `^\\d+\\. \\*\\*(.+?)\\*\\*`, so an + entry added in plain text was invisible to the pin — the drift the pin exists for walked straight + past it, which is the one failure a guard may not have. + """ + listed: list[str] = [] + for line in recording.split("\n"): + if re.match(r"^\d+\.\s", line): + text = _entry_text(line) + if text: + listed.append(text) + return listed def _bulleted_after_host_questions(recording: str) -> list[str]: """The bulleted questions §5 attributes to the host: the bullet list the sentence introduces. - The bound is the **contiguous run of bullets** that follows the sentence, not the rest of the - section. The section is one home for several subjects, and once it carried a second list the - wider bound read that one too: the count stayed right only because the other subject's items are - table rows rather than bullets, so a single bullet added there would redden this pin and blame - the host's list for a change that never touched it. A false red is how a guard gets weakened, so - the slice names the list it claims and stops where that list stops. + Two properties, both measured: + + * **The bound is the contiguous run of bullets** that follows the sentence, not the rest of the + section. The section is one home for several subjects, and once it carried a second list the + wider bound read that one too: the count stayed right only because the other subject's items + are table rows rather than bullets, so a single bullet added there would redden this pin and + blame the host's list for a change that never touched it. A false red is how a guard gets + weakened, so the slice names the list it claims and stops where that list stops. + * **An entry is read by its marker**, with the emphasis stripped afterwards. Reading only bolded + entries made a plain one invisible, which is the same defect one layer down. """ parts = recording.split(_HOST_ANCHOR, 1) assert len(parts) == 2, ( @@ -1565,11 +1593,13 @@ def _bulleted_after_host_questions(recording: str) -> list[str]: ) listed: list[str] = [] for line in parts[1].split("\n"): - found = re.match(r"^- \*\*(.+?)\*\*", line) - if found: - listed.append(found.group(1)) - elif listed: - break + if not line.startswith("- "): + if listed: + break + continue + text = _entry_text(line) + if text: + listed.append(text) return listed @@ -1666,6 +1696,28 @@ def test_the_closing_summary_count_pins_discriminate_in_both_directions() -> Non ) assert _claim(later, _HOST_CLAIM) == len(_bulleted_after_host_questions(later)) + # a numbered entry added in PLAIN text moves the count too. An earlier reader matched the + # emphasis rather than the marker, so this arm went green while the list had grown — the drift + # the pin exists for walked past it, which is the one failure a guard may not have. + plain = section.replace("2. **Second?**\n", "2. **Second?**\n3. Third?\n") + assert len(_numbered(plain)) == 3, ( + "a numbered entry added without bold produced no new entry — the reader is matching the " + "emphasis instead of the marker, and a list can grow while its count reads green" + ) + assert _claim(plain, _SELF_REVIEW_CLAIM) != len(_numbered(plain)) + + # the same defect one layer down: a host question added in plain text + host_plain = section + "- Fifth?\n" + assert len(_bulleted_after_host_questions(host_plain)) == 3, ( + "a bulleted entry added without bold produced no new entry — the host's list can grow while " + "its count reads green" + ) + assert _claim(host_plain, _HOST_CLAIM) != len(_bulleted_after_host_questions(host_plain)) + + # emphasis is formatting, not length: an entry that is bolded in part still counts once + partial = section.replace("2. **Second?**\n", "2. **Second?**\n3. a **half-bold** entry\n") + assert len(_numbered(partial)) == 3 + # a count this pin cannot read is refused rather than defaulted with pytest.raises(AssertionError, match="cannot read as a number"): _claim(section.replace("**Two questions", "**7 questions"), _SELF_REVIEW_CLAIM)