From 294bbe7b9bc2e5e4ce65c06a3bbfddb335dbda7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:12:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(ci,tests):=20PR-4b=20=E2=80=94=20promote?= =?UTF-8?q?=20`teaching`=20onto=20the=20gate,=20and=20close=20pin=203's=20?= =?UTF-8?q?own=20blind=20spot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Wcw2pnMBwyvmNyQg4uPEt4 --- docs/assessment/30-gap-register.md | 2 + docs/assessment/50-execution-plan.md | 12 ++++- scripts/ci/local-ci.sh | 11 +++-- scripts/hooks/pre-push | 19 ++++++-- tests/test_suite_reachability.py | 72 +++++++++++++++++++++++----- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/docs/assessment/30-gap-register.md b/docs/assessment/30-gap-register.md index 09300efe..2bc77fc4 100644 --- a/docs/assessment/30-gap-register.md +++ b/docs/assessment/30-gap-register.md @@ -112,6 +112,8 @@ Pin 1 exists because the two gates have **already** drifted (see H-12): the loca **What pin 3 deliberately does not do — and what is now Shay's to decide:** *which* of the 19 belong on the gate. That costs gate time and is a real decision. `teaching` is the obvious first candidate: it just shipped a red ratification pin that no gate ran. Recorded as the open half of this entry. +**CLOSED 2026-07-28 — `teaching` promoted (PR-4b), gap 15 → 14, the first turn by promotion rather than deletion. Sabotage-testing that promotion also found pin 3's own union blind spot (the two gate scripts could drift from each other undetected) and closed it.** + **The gap is 15, not 19, and the open half is now a scheduled plan item.** PR-3b (Wave 1, per R-7) deleted four aliases with zero `--suite` references anywhere — `refusal`, `margin`, `rotor`, `inner-loop` — taking the curated set 21 → 17 and the unreachable set **19 → 15, by deletion**, which costs no gate time at all. That is the ratchet turning the way it is meant to. The remaining fifteen are each named in AGENTS.md, CLI help, a README or an ADR, so deletion is not available for them and promotion is the only way the number falls further. The decision was a parenthetical in this entry and in PR-4 — which is precisely how fifteen suites came to be unreachable without anyone choosing it — so it is now **PR-4b** in `50-execution-plan.md` §4, sized, recommended (`teaching` alone, first), and prepared so ruling costs one word. *One real gap the entry was circling, now fixed:* the parity pin lived in `fast`, which the pre-push gate does not run — **the pin guarding the gate did not run on the gate**. It is now in `smoke`. diff --git a/docs/assessment/50-execution-plan.md b/docs/assessment/50-execution-plan.md index 4a6c0c05..9cd7c2bd 100644 --- a/docs/assessment/50-execution-plan.md +++ b/docs/assessment/50-execution-plan.md @@ -284,7 +284,17 @@ Promoted out of a parenthetical in PR-4, because a parenthetical is how fifteen **Explicitly not recommended:** promoting all fifteen. That is the ceremony failure this plan rejected twice already (749 exclusion reasons; nineteen justifications in one sitting). Nor is deletion the default — PR-3b deleted the four with *zero* references anywhere; the remaining fifteen are all named in AGENTS.md, CLI help, READMEs or ADRs, so deleting them breaks documented commands. -**Verification:** `teaching` invoked from `scripts/hooks/pre-push` and `local-ci.sh --tier gate`, `GATE_SUITES` updated, `teaching` removed from `UNREACHABLE_BASELINE` in the same edit (`test_suite_reachability.py` fails in both directions if either half is missed — it has already caught this author twice), and the ratchet's `<= 15` lowered to `<= 14`. The added gate seconds reported as a number in the PR body. +**LANDED 2026-07-28 — and the recommendation was re-derived rather than executed, because its original justification had been spent.** + +This item 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 spent justification is 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, that an unmarked proposal and an unmarked reviewed example default to **SPECULATIVE**, that the status enum has exactly its four positions, that pack-mutation proposals carry no hardening flag. Durable corpus mutation is the one irreversible thing in CORE, and the suite guarding it ran on no pre-merge gate. That justification does not depend on an incident having happened yet, which makes it the better one. + +**Measured: 22.1s for 9 net-new files** (the tenth, `test_ratification_ceremony.py`, was already on the gate). Both invocation sites updated, `GATE_SUITES` extended, `teaching` removed from `UNREACHABLE_BASELINE` in the same edit, ratchet lowered `<= 15` → `<= 14`. **The reachability gap turned by promotion for the first time** — PR-3b's earlier turn was by deletion, which is free; this one costs, which is why it is a decision. + +**A hollow spot in pin 3 itself, found by sabotage-testing the promotion.** `_suites_invoked_by_gate_tiers()` takes the **union** of `pre-push` and `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`." Closed by `test_the_two_gate_scripts_invoke_the_same_suites`, scoped to the numbered `(n/m)` gate steps so it reads the gate tier without parsing shell control flow. The identical sabotage now fires. + +**This is not the CI-parity pin N-9 killed**, and the distinction is recorded at the assertion site so it is not re-litigated: that one asserted parity with `smoke.yml`, which `AGENTS.md:280` makes a billing-locked dead signal. Both scripts here are live local merge bars, and a developer running either is entitled to the coverage the other advertises. ### PR-5 · The flag-default register · **M** · G-8/H-6 `docs/specs/flag_register.md`: all **28** default-off flags plus the 4 default-on — current default, deliberate-posture vs accumulated-hesitancy, **what evidence flips it**, and named profiles (one-shot / eval / continuous-life) per R-4. Declared in the table, not the call site (ADR-0263 Rule 5). A pin asserts the register lists exactly the flags `core/config.py` defines — so a new flag cannot land unregistered. Includes the F-6 accrual resolution from R-3 and the N-6 docstring correction. diff --git a/scripts/ci/local-ci.sh b/scripts/ci/local-ci.sh index 8c58e635..4d41d5d9 100755 --- a/scripts/ci/local-ci.sh +++ b/scripts/ci/local-ci.sh @@ -54,7 +54,7 @@ usage: local-ci.sh [--tier TIER] [--allow-interpreter-fallback] [--list] [-- ARG tiers: smoke smoke suite only (~1 min) = CI smoke.yml parity - gate smoke + warmed_session + deductive (~2 min) = the pre-push gate + gate smoke + warmed_session + deductive + teaching (~9 min) = the pre-push gate full the entire tests/ tree, parallel (~10 min) = the merge bar options: @@ -165,10 +165,11 @@ case "${TIER}" in step "smoke suite" suite smoke ;; gate) - echo "[local-ci] tier=gate — same three steps as scripts/hooks/pre-push" - step "(1/3) smoke suite" suite smoke - step "(2/3) warmed_session lane" pytest_ tests/test_warmed_session_lane.py -q --no-header - step "(3/3) deductive suite" suite deductive + echo "[local-ci] tier=gate — same four steps as scripts/hooks/pre-push" + step "(1/4) smoke suite" suite smoke + step "(2/4) warmed_session lane" pytest_ tests/test_warmed_session_lane.py -q --no-header + step "(3/4) deductive suite" suite deductive + step "(4/4) teaching suite" suite teaching ;; full) jobs="$(nproc 2>/dev/null || echo 4)" diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 44364f5e..41ad1307 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -53,23 +53,23 @@ if [ "${read_any}" -eq 1 ] && [ "${only_deletes}" -eq 1 ]; then exit 0 fi -echo "[pre-push] CORE local-first gate: smoke + warmed_session lane pin + deductive" +echo "[pre-push] CORE local-first gate: smoke + warmed_session lane pin + deductive + teaching" -echo "[pre-push] (1/3) smoke suite (core test --suite smoke) ..." +echo "[pre-push] (1/4) smoke suite (core test --suite smoke) ..." if ! uv run core test --suite smoke -q; then echo "[pre-push] BLOCKED: smoke suite failed. Fix before pushing." >&2 echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 exit 1 fi -echo "[pre-push] (2/3) warmed_session consistency lane ..." +echo "[pre-push] (2/4) warmed_session consistency lane ..." if ! uv run python -m pytest tests/test_warmed_session_lane.py -q; then echo "[pre-push] BLOCKED: warmed_session lane failed (T13 regression class)." >&2 echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 exit 1 fi -echo "[pre-push] (3/3) deductive suite (core test --suite deductive) ..." +echo "[pre-push] (3/4) deductive suite (core test --suite deductive) ..." if ! uv run core test --suite deductive -q; then echo "[pre-push] BLOCKED: deductive suite failed — deduction serving is a" >&2 echo "[pre-push] LIVE capability (ADR-0256), not an experiment." >&2 @@ -77,5 +77,16 @@ if ! uv run core test --suite deductive -q; then exit 1 fi +echo "[pre-push] (4/4) teaching suite (core test --suite teaching) ..." +if ! uv run core test --suite teaching -q; then + echo "[pre-push] BLOCKED: teaching suite failed — durable mutation is governed by" >&2 + echo "[pre-push] AGENTS.md non-negotiables (INV-21..INV-30). An unmarked" >&2 + echo "[pre-push] proposal or reviewed example defaulting to anything but" >&2 + echo "[pre-push] SPECULATIVE is a corpus-truth defect, and corpus mutation" >&2 + echo "[pre-push] is the one thing here that is irreversible." >&2 + echo "[pre-push] (emergency bypass, discouraged: git push --no-verify)" >&2 + exit 1 +fi + echo "[pre-push] gate PASSED — push proceeding." exit 0 diff --git a/tests/test_suite_reachability.py b/tests/test_suite_reachability.py index ebcd56fb..589de831 100644 --- a/tests/test_suite_reachability.py +++ b/tests/test_suite_reachability.py @@ -45,13 +45,14 @@ _ROOT = Path(__file__).resolve().parents[1] #: 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"}) +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`. That is the -#: ratchet turning the way it is supposed to: the gap shrank by deletion rather -#: than by promotion, which costs no gate time at all. +#: 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( @@ -70,7 +71,6 @@ UNREACHABLE_BASELINE: frozenset[str] = frozenset( "pulse", "runtime", "sensorium", - "teaching", } ) @@ -164,14 +164,16 @@ def test_baseline_has_no_stale_entries() -> None: def test_recorded_gate_gap_matches_the_measurement() -> None: """Pin the number so the gap moving is a reviewed decision, not a drift. - 15 of 17, after PR-3b. (19 of 21 before it.) Shrinking this is the point — it shrinks by putting - a suite on the gate, which costs gate time and is therefore a decision - someone has to make deliberately. + 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) <= 15, ( - f"gate-unreachable suites grew to {len(UNREACHABLE_BASELINE)} (was 15; " - "19 before PR-3b deleted four unreferenced aliases). The ratchet only " - "turns one way." + 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." ) @@ -182,3 +184,47 @@ def test_the_two_gate_suites_actually_exist() -> None: 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))}" + )