core/tests/test_suite_reachability.py
Claude 294bbe7b9b
feat(ci,tests): PR-4b — promote teaching onto the gate, and close pin 3's own blind spot
The reachability gap turns by PROMOTION for the first time: 15 -> 14. PR-3b's
earlier turn was by deletion, which is free. This one costs 22.1s on every push,
which is exactly why it is a decision rather than a default.

THE RECOMMENDATION WAS RE-DERIVED, NOT EXECUTED — and it did not survive as
written. PR-4b recommended `teaching` on the strength of G-22:
test_ratification_ceremony.py was red on main, in `teaching`, run by no gate.
That hole was already closed a different way — G-22's fix promoted that single
file into smoke. So `teaching` would have added 9 net-new files, NONE with a
demonstrated escape, and promoting on a justification that has already been
spent is precisely the failure this arc exists to close.

Promoted on a stronger, doctrinal basis instead.
tests/test_epistemic_invariants.py enforces AGENTS.md's "Teaching and mutation
safety" non-negotiables in substance — INV-22/INV-23: an unmarked proposal and
an unmarked reviewed example default to SPECULATIVE, the status enum has exactly
its four positions, a pack-mutation proposal carries no hardening flag. Durable
corpus mutation is the one irreversible thing in this system, and the suite
guarding it ran on no pre-merge gate. That justification does not require an
incident to have happened yet, which makes it the better one.

Measured: 22.1s / 122 tests across 9 net-new files (the tenth was already on the
gate). Both invocation sites updated, GATE_SUITES extended, `teaching` removed
from UNREACHABLE_BASELINE in the same edit, ratchet lowered <= 15 to <= 14 —
the both-directions discipline that has caught this author twice.

A HOLLOW SPOT IN PIN 3 ITSELF, found by sabotage-testing the promotion rather
than trusting it. _suites_invoked_by_gate_tiers() takes the UNION of
scripts/hooks/pre-push and scripts/ci/local-ci.sh, so a suite present in either
satisfies it. Removing `teaching` from local-ci.sh alone PASSED SILENTLY: the
two scripts could drift from each other invisibly while local-ci.sh states in
prose that it runs "the same four steps as scripts/hooks/pre-push". A developer
running one would get coverage the other advertises and does not deliver.

Closed by test_the_two_gate_scripts_invoke_the_same_suites, scoped to the
numbered (n/m) gate steps — a discriminator both files already use — so it reads
the gate tier without parsing shell control flow. The identical sabotage now
fires. Verified in three directions: removed from one script (fires, and did not
before), removed from both (the pre-existing assertion fires), restored (green).

THIS IS NOT THE CI-PARITY PIN N-9 KILLED, and the distinction is recorded at the
assertion site so it cannot be re-litigated a third time. That one asserted
TEST_SUITES["smoke"] == smoke.yml and was reverted in 50fa287d because
AGENTS.md:280 makes GitHub Actions billing-locked dead signals — parity with a
dead signal buys nothing. Both scripts here are LIVE local merge bars.

Gate is now four steps: smoke + warmed_session + deductive + teaching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wcw2pnMBwyvmNyQg4uPEt4
2026-07-28 08:12:26 +00:00

230 lines
10 KiB
Python

"""PR-4 pin 3 / G-7 — a curated suite that no gate invokes is not a guarantee.
PR-4's ratchet closed *membership*: a new test file cannot land outside every
curated suite. Within the hour it turned out membership was never the property
that mattered.
This arc's first full-tree run found ``tests/test_ratification_ceremony.py``
**red on main, in the curated suite `teaching`, and invoked by no gate tier**.
Membership was satisfied and bought nothing — which is precisely the objection
PR-4 itself raised against G-7's original formulation: *a curated suite nobody
runs is the same non-guarantee as ``full``, wearing a different name.* The fix
had the same blind spot as the thing it fixed.
Measured at 2026-07-28: **21 curated suites, 2 gate-reachable** — then **17 and 2** after PR-3b deleted four aliases nothing invoked. ``smoke`` and
``deductive`` are invoked by ``scripts/hooks/pre-push`` and
``scripts/ci/local-ci.sh --tier gate``; the other nineteen have members and no
caller. That is why this is a **baseline plus ratchet** and not a flat rule —
demanding nineteen ``post-merge-only`` justifications in one sitting is the
ceremony failure rejected for the 749-file membership baseline, and it would
buy a wall of text rather than a decision.
What this pin does:
* freezes the currently-unreachable set, so it cannot grow silently;
* fails when a **new** curated suite is added with no gate caller and no
explicit declaration;
* fails when a suite leaves the unreachable set (promoted to a gate) and its
baseline line is not removed — the same both-directions discipline that
caught this file's author twice already.
What it deliberately does **not** do: decide which of the nineteen belong on
the gate. That is a real decision with a real time cost, and it is Shay's.
This pin makes the state visible and stops it worsening.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
#: Suites invoked by a gate tier today. Parsed rather than hard-coded would be
#: nicer, but the invocations are shell (``step "…" suite smoke``) and a parse
#: that silently matched nothing would make this pin vacuous — the exact
#: failure mode (N-1) that has produced three false conclusions in this repo.
#: So the set is declared here and *verified* against the shell below.
GATE_SUITES: frozenset[str] = frozenset({"smoke", "deductive", "teaching"})
#: Curated suites with members and no gate caller.
#: 19 at first measurement; **15 after PR-3b (Wave 1)** deleted four unreferenced
#: per-phase aliases — `refusal`, `margin`, `rotor`, `inner-loop` — the ratchet
#: turning by deletion, which costs no gate time at all. **14 after PR-4b promoted
#: `teaching` onto the gate (2026-07-28)** — the first time it turned by PROMOTION,
#: which is the direction that actually buys coverage and the direction that costs.
#: This list may only SHRINK. A suite leaves it by being invoked from a gate
#: tier — not by being deleted from this file.
UNREACHABLE_BASELINE: frozenset[str] = frozenset(
{
"adr-0024",
"algebra",
"cognition",
"fast",
"formation",
"full",
"math",
"packs",
"phase5",
"phase6",
"proof",
"pulse",
"runtime",
"sensorium",
}
)
def _curated_suites() -> dict[str, tuple[str, ...]]:
src = (_ROOT / "core" / "cli_test.py").read_text(encoding="utf-8")
match = re.search(r"TEST_SUITES\s*(?::[^=]+)?=\s*\{", src)
assert match is not None, "TEST_SUITES literal not found"
start = match.end() - 1
depth = 0
end = len(src)
for i, ch in enumerate(src[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
return ast.literal_eval(src[start:end])
def _suites_invoked_by_gate_tiers() -> set[str]:
"""Suites named by a ``suite <name>`` step in the hook or the gate tier."""
found: set[str] = set()
for rel in ("scripts/hooks/pre-push", "scripts/ci/local-ci.sh"):
text = (_ROOT / rel).read_text(encoding="utf-8")
found.update(re.findall(r"--suite\s+([a-z0-9-]+)", text))
found.update(re.findall(r"\bsuite\s+([a-z0-9-]+)\s*$", text, re.MULTILINE))
# ``full`` is the arc-boundary tier, not a gate; it is not a merge bar.
found.discard("full")
found.discard("only") # prose match guard
return found
def test_declared_gate_suites_match_the_shell() -> None:
"""GATE_SUITES must be what the hook and gate tier actually invoke.
Without this, the pin below could pass while pointing at a fiction — a
declared set that drifted from the scripts it claims to describe.
"""
invoked = _suites_invoked_by_gate_tiers()
assert invoked, "parsed no suite invocations — the pin would be vacuous"
assert invoked == set(GATE_SUITES), (
f"gate tiers invoke {sorted(invoked)}, but GATE_SUITES declares "
f"{sorted(GATE_SUITES)} — update the constant with the change that "
"moved the gate, and move any promoted suite out of "
"UNREACHABLE_BASELINE in the same edit"
)
def test_no_new_gate_unreachable_suite() -> None:
"""A new curated suite must be gate-reachable or explicitly baselined.
If this fails on a suite you just added: either invoke it from a gate tier
(``scripts/hooks/pre-push`` / ``local-ci.sh --tier gate``), or add it to
UNREACHABLE_BASELINE with the reason in your commit message. The second is
available and is a decision, not a default — a suite nobody runs guarantees
nothing.
"""
suites = set(_curated_suites())
unreachable = suites - set(GATE_SUITES)
undeclared = sorted(unreachable - UNREACHABLE_BASELINE)
assert not undeclared, (
"these curated suites have members and no gate caller, and are not in "
f"the recorded baseline: {undeclared}"
)
def test_baseline_has_no_stale_entries() -> None:
"""Both directions: a promoted or deleted suite must leave the baseline.
Same discipline as ``test_suite_membership.py`` — a baseline that keeps
entries it no longer needs stops being a measurement and becomes a story
about one.
"""
suites = set(_curated_suites())
vanished = sorted(s for s in UNREACHABLE_BASELINE if s not in suites)
assert not vanished, (
f"baseline names suites that no longer exist: {vanished}"
)
promoted = sorted(s for s in UNREACHABLE_BASELINE if s in GATE_SUITES)
assert not promoted, (
"these suites are now gate-reachable (good) but are still listed as "
f"unreachable — remove them from UNREACHABLE_BASELINE: {promoted}"
)
def test_recorded_gate_gap_matches_the_measurement() -> None:
"""Pin the number so the gap moving is a reviewed decision, not a drift.
14 of 17, after PR-4b. (15 after PR-3b; 19 of 21 before it.) Shrinking this is
the point, and it shrinks two ways: by deleting a suite nobody calls (free) or by
putting one on the gate (costs gate time on every push, and is therefore a
decision someone has to make deliberately). PR-4b was the first of the second
kind — `teaching`, at a measured 22.1s for 9 net-new files.
"""
assert len(UNREACHABLE_BASELINE) <= 14, (
f"gate-unreachable suites grew to {len(UNREACHABLE_BASELINE)} (was 14; 15 "
"before PR-4b promoted `teaching`, 19 before PR-3b deleted four unreferenced "
"aliases). The ratchet only turns one way."
)
def test_the_two_gate_suites_actually_exist() -> None:
"""A gate that names a suite the CLI does not define would fail closed at
run time, but silently here. Pin it."""
suites = _curated_suites()
for name in GATE_SUITES:
assert name in suites, f"gate tier invokes undefined suite {name!r}"
assert suites[name], f"gate suite {name!r} is empty"
#: A gate step in either script, e.g. ``(4/4) teaching suite ... suite teaching``.
#: The ``(n/m)`` prefix is the discriminator: ``local-ci.sh`` defines other tiers
#: whose steps are unnumbered, so this scopes the parse to the gate tier without
#: needing to parse shell control flow.
_GATE_STEP_RE = re.compile(r"\(\d+/\d+\)[^\n]*?\bsuite[= ]+([a-z0-9-]+)")
def _gate_suites_in(rel: str) -> set[str]:
text = (_ROOT / rel).read_text(encoding="utf-8")
return set(_GATE_STEP_RE.findall(text))
def test_the_two_gate_scripts_invoke_the_same_suites() -> None:
"""``pre-push`` and ``local-ci.sh --tier gate`` must not drift apart.
Both scripts state that they are the same gate — ``local-ci.sh`` says so in
prose ("same four steps as scripts/hooks/pre-push"). Two records asserting the
same thing is the shape that goes stale, and until this pin the drift was
invisible: :func:`test_declared_gate_suites_match_the_shell` takes the UNION of
the two files, so a suite present in either satisfies it. Removing `teaching`
from ``local-ci.sh`` alone was sabotage-tested and passed — which is how this
gap was found.
**This is NOT the CI-parity pin that N-9 correctly killed.** That one asserted
``TEST_SUITES["smoke"] == smoke.yml`` and was reverted in ``50fa287d`` because
``AGENTS.md:280`` makes GitHub Actions billing-locked dead signals — parity with
a dead signal buys nothing. Both scripts here are live local merge bars, and a
developer running one is entitled to the coverage the other advertises.
"""
hook = _gate_suites_in("scripts/hooks/pre-push")
local = _gate_suites_in("scripts/ci/local-ci.sh")
assert hook, "parsed no numbered gate steps from scripts/hooks/pre-push"
assert local, "parsed no numbered gate steps from scripts/ci/local-ci.sh --tier gate"
assert hook == local, (
"the two gate scripts have drifted — a push through the hook and a run of "
"local-ci.sh --tier gate would cover different suites:\n"
f" pre-push only: {sorted(hook - local)}\n"
f" local-ci only: {sorted(local - hook)}"
)
assert hook <= set(GATE_SUITES), (
f"the scripts invoke suites GATE_SUITES does not declare: {sorted(hook - set(GATE_SUITES))}"
)