From 358e6dea7b183bf31006c8307fa19daa8cb33ab6 Mon Sep 17 00:00:00 2001 From: Kilian Mateo <13885240+kilianmc@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:32:00 +0200 Subject: [PATCH] fix(library): dose the broken circuit at climbing pace, and guard the rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `broken_circuit_redpoint`'s `work_seconds` was 25 in STRENGTH and POWER against instructions saying "around twenty-five moves" — the move count leaked into a seconds field, at 1.00 s/move. Ruling 50: 90 s in all three phases, which deletes the 3.6x phase step rather than rescaling it. PERFORMANCE already carried 90; `sets` 4/4/3 and the rests are untouched, because a set of a work_seconds-carrying circuit is one attempt at the whole circuit by the library's own filing convention. No source states a move->seconds rate for An Pow, so the conversion is declared at the row as the app's own rather than dressed as sourced. The guard is the load-bearing half: that cell's `work_seconds` was read by NO guard in the repo, so it could have been 90, 120 or 180 with a green suite. - a move-rate band over every row stating a move count AND carrying `work_seconds` (1.50-4.17 s/move), reading BOTH ends so editing the prose to close a breach fails just as loudly - the progression arm's sampling unit widened from a week PAIR to a DRAWN CELL: `shorter_rest` 15 -> 189 cells, and this row 0 -> 99. Pair arm kept beside it - per-(rule, phase) coverage floors, so a phase falling silent turns red instead of going quiet Displacement, 72-plan sweep sabotage-checked at 9999 s first: only `power` moves, +256.3 min there, ruling 27's fill absorbs it; pooled -87.1 min / -9 blocks. `library_digest` ad0cbbc9 -> cca51ea1, which is the intended provenance signal. `GENERATOR_VERSION` unmoved: the library is a third input covered by the digest, and every past bump also changed the planner package. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- server/domain/exercises.py | 24 ++- tests/comment_budget_allowlist.toml | 18 ++- tests/test_exercise_library.py | 144 ++++++++++++++++- tests/test_planner_progression.py | 238 +++++++++++++++++++++++----- 5 files changed, 377 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index d4931b1..b8eca23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "climb-trainer", - "version": "8.13.0", + "version": "8.14.0", "private": true, "description": "Climbing training app — plan generator, guided session player, training diary", "engines": { diff --git a/server/domain/exercises.py b/server/domain/exercises.py index 29f3ea8..d386f20 100644 --- a/server/domain/exercises.py +++ b/server/domain/exercises.py @@ -1322,31 +1322,41 @@ def __post_init__(self) -> None: instructions=( "Set yourself a hard circuit of around twenty-five moves, split it into three or " "four sections, and work the sections one at a time before ever trying the whole " - "thing. Then link them: two sections, then three, then the lot. It is redpointing " - "as a training method rather than as an outcome, and the reason it belongs in a " - "gym is that you can build the circuit to be exactly what you are bad at." + "thing. Then link them: two sections, then three, then the lot. The prescribed " + "seconds are that whole circuit at climbing pace, so spend them on the sections " + "and the part-links until the whole thing goes, and on one continuous lap after " + "that. It is redpointing as a training method rather than as an outcome, and the " + "reason it belongs in a gym is that you can build the circuit to be exactly what " + "you are bad at." ), substitution_hint=( "Nothing long enough set? Build the circuit out of holds from three problems " "that share a panel." ), + # ⚠️ THE APP'S OWN CONVERSION, not a sourced figure (ruling 50). §5.4 doses this + # circuit by moves and sections only — "~25-move circuit split into 3-4 sections" — + # and gives it no seconds, no rest and no set count, so 90 s is this app reading 25 + # moves at the 1.50-4.17 s/move rate §7's Aero Pow and An Cap rows state for OTHER + # attributes. No source states a rate for An Pow. The band is asserted rather than + # left as prose by `tests/test_exercise_library.py`, and 25 s was 1.00 s/move — the + # move count leaked into a seconds field, which is what F26 was. prescriptions=( PrescriptionSpec( Phase.STRENGTH, sets=4, - work_seconds=25, + work_seconds=90, rest_between_sets_seconds=240, target_rpe=8, ), PrescriptionSpec( Phase.POWER, sets=4, - work_seconds=25, + work_seconds=90, rest_between_sets_seconds=300, target_rpe=9, ), - # The linking stage: fewer, longer pieces on a longer rest, which is what turns - # the worked sections into one continuous effort. + # The linking stage: the same 90 s spent as one continuous lap rather than on + # sections, on fewer attempts and a longer rest. PrescriptionSpec( Phase.PERFORMANCE, sets=3, diff --git a/tests/comment_budget_allowlist.toml b/tests/comment_budget_allowlist.toml index 6316b1e..ddae236 100644 --- a/tests/comment_budget_allowlist.toml +++ b/tests/comment_budget_allowlist.toml @@ -8833,8 +8833,8 @@ reason = "Records what this register replaced: a ceiling = authored pin that gua path = "tests/test_planner_progression.py" kind = "hash_run" anchor = "anti vacuity floors 90 of what the sweep measured" -limit = 15 -reason = "Carries the measured cell counts the floors are 90% of, so a future reader can tell a drifted sweep from a rule that has stopped firing altogether. GREW when ruling 41's wall row cut the shorter-rest arm from 57 cells to 15 and took boulders_on_the_two_minute out of it entirely; a re-based anti-vacuity floor that does not say what it lost is how a guard goes quietly thin." +limit = 13 +reason = "Carries the measured cell counts the floors are 90% of, so a future reader can tell a drifted sweep from a rule that has stopped firing altogether. Names both re-bases: ruling 41's wall row cut the shorter-rest arm from 57 pairs to 15, and ruling 50 answered that by widening the sampling unit rather than by lowering a floor, which is the one thing a reader must not undo here." [[exception]] path = "server/domain/planner/climbing.py" @@ -8980,3 +8980,17 @@ kind = "docstring" anchor = "tests.test_planner_selection.test_BOTH_of_F11s_SURFACES_RANK_BY_THE_SAME_AUTHORED_ORDER" limit = 14 reason = "Ruling 38's 'a fix to one surface only is not a fix', the independent-restatement reason (a test that asks the code what its own ordering is agrees with a reversed one), and the measurement refusing a rank WEIGHT on the filler surface. All three are what tell this arm from a tautology." + +[[exception]] +path = "server/domain/exercises.py" +kind = "hash_run" +anchor = "the app s own conversion not a sourced figure ruling 50" +limit = 7 +reason = "Ruling 50's declared divergence, at the row it describes: §5.4 gives this circuit no seconds at all, so the 90 s is the app converting the row's own move count at a rate borrowed from two OTHER attributes. Without it the dose reads as sourced, and the 25 s it replaces - the move count leaked into a seconds field - reads as a typo rather than as the class of defect F26 was." + +[[exception]] +path = "tests/test_planner_progression.py" +kind = "docstring" +anchor = "tests.test_planner_progression.test_a_DRAWN_CELL_AFTER_WEEK_ONE_LEFT_ITS_AUTHORED_DOSE_BEHIND" +limit = 7 +reason = "Why the sampling unit is a drawn CELL and not a week-PAIR, which is the whole of ruling 50's guard half: _pool_index rotates the pool by week, so a pair needs the same row drawn twice in one block and broken_circuit_redpoint made 0 pairs from 136 drawn blocks. A reader who does not know that deletes this arm as a duplicate of the pair arm beside it." diff --git a/tests/test_exercise_library.py b/tests/test_exercise_library.py index a952cda..51876d4 100644 --- a/tests/test_exercise_library.py +++ b/tests/test_exercise_library.py @@ -435,8 +435,10 @@ class _DoseShape: ProtocolKind.CIRCUIT, None, "⚠️ OUT OF SCOPE. §5.4 doses the An Pow broken circuit and the redpoint circuit by " - "sections and attempts, not by a ratio. F26 registers `broken_circuit_redpoint` at " - "4.67-12.00x and this guard does not close it.", + "sections and attempts, not by a ratio — ruling 50's research read §5.4 again and " + "found no seconds, rest or set figure there at all. `broken_circuit_redpoint` rests " + "2.67-4.67x and no band asserts it; what reads its `work_seconds` is the move-rate " + "arm below, which is the only guard in this repo that does.", ), _DoseShape( "endurance", @@ -544,6 +546,144 @@ def test_the_SECTION_7_SHAPE_REGISTER_names_every_shape_the_library_actually_HAS ) +# The move-rate band, ruling 50: the arm that would have caught F26, and the only one in the +# repo reading `broken_circuit_redpoint`'s `work_seconds` at all. + + +_BY_KEY = {spec.key: spec for spec in EXERCISES} + + +@dataclass(frozen=True, slots=True) +class _MoveCount: + """One row's move count, restated from its own prose, with the numerals it spells.""" + + low: int + high: int + words: tuple[str, ...] + + +# Restated from each row's `instructions` and never read out of them at runtime — the numbers +# are this file's claim ABOUT the text, and the arm two below proves the claim still holds. +MOVE_COUNTS = { + "two_problem_links": _MoveCount(12, 15, ("twelve", "fifteen")), + "traverse_intervals": _MoveCount(12, 15, ("twelve", "fifteen")), + "up_down_boulder_laps": _MoveCount(30, 30, ("thirty",)), + "broken_circuit_redpoint": _MoveCount(25, 25, ("twenty", "five")), +} + +# Named exclusions, with the reason as DATA so it reaches whoever sees the red rather than +# sitting in a comment. Leaving this band is a ruling; deleting a row from it is not. +MOVE_RATE_EXCLUSIONS = { + "long_boulder_link_ups": ( + "⚠️ Ruling 45's DECLARED DIVERGENCE, recorded at the row itself: 300 s over 'twenty " + "to thirty moves a lap' is 10.0-15.0 s/move, and the row is continuous Aero Cap " + "climbed with shakeouts and never so hard that a move is in doubt — so its work " + "period is a duration of easy climbing, not a rate at which hard moves are made." + ), + "explosive_move_intervals": ( + "⚠️ One big fast move, or a two-move burst, in 6 s. Over one or two moves a per-move " + "rate is the length of a single all-out effort rather than a climbing pace, which is " + "why §7 doses alactic work by its 8x rest instead — the same reason ruling 43 puts " + "`power` x `intervals` outside the rest:work guard's scope." + ), +} + +# The union of every move -> seconds rate the sources state, as (low, high) seconds per move. +MOVE_RATE_BAND = (1.50, 4.17) +MOVE_RATE_SOURCE = ( + "§7's Aero Pow row doses ~30 moves in 45-120 s = 1.50-4.00 s/move, and §7's An Cap row " + "with §5.2's body doses 12-15 moves in 30-50 s = 2.00-4.17, so 1.50-4.17 s/move is every " + "rate the sources state. NO source states one for An Pow: ruling 50 declares the " + "conversion as the app's own, at `broken_circuit_redpoint`, where the declaration lives." +) + +# Measured 2026-09-06: 4 rows and 16 (row, phase) prescriptions carry both ends of the claim. +MOVE_RATE_ROWS = 4 +MOVE_RATE_PRESCRIPTIONS = 16 + +# Enough words to clear "twelve to fifteen hard enough moves"; the numerals themselves are +# never converted to a value here, only detected, so the table above stays the sole source. +MOVE_WINDOW = 5 +NUMBER_WORDS = frozenset( + """one two three four five six seven eight nine ten eleven twelve thirteen fourteen + fifteen sixteen seventeen eighteen nineteen twenty thirty forty fifty sixty seventy + eighty ninety hundred dozen""".split() +) + + +def numerals_before_moves(text: str) -> frozenset[str]: + """Every number word within `MOVE_WINDOW` words before a 'move' or 'moves' in one row.""" + lowered = text.lower() + found: set[str] = set() + for match in re.finditer(r"\bmoves?\b", lowered): + head = re.findall(r"[a-z]+", lowered[: match.start()])[-MOVE_WINDOW:] + found.update(word for word in head if word in NUMBER_WORDS) + return frozenset(found) + + +def test_every_row_COUNTING_ITS_MOVES_climbs_them_at_a_RATE_THE_SOURCES_STATE() -> None: + """⚠️ GUARD, ruling 50, and it reads BOTH ENDS: the prose move count against the dose.""" + rows = 0 + inspected = 0 + low, high = MOVE_RATE_BAND + for spec in EXERCISES: + count = MOVE_COUNTS.get(spec.key) + if count is None: + continue + rows += 1 + for prescription in spec.prescriptions: + work = prescription.work_seconds + if work is None: + continue + inspected += 1 + fastest, slowest = work / count.high, work / count.low + assert low <= fastest and slowest <= high, ( + f"{spec.key}/{prescription.phase.value} doses {work} s of work against the " + f"{count.low}-{count.high} moves its OWN instructions state = " + f"{fastest:.2f}-{slowest:.2f} s/move, outside {low}-{high}. " + f"{MOVE_RATE_SOURCE} The prose and the dose are ONE claim here, so editing " + f"the move count instead of the dose moves the breach rather than closing it." + ) + assert rows >= MOVE_RATE_ROWS and inspected >= MOVE_RATE_PRESCRIPTIONS, ( + f"the move-rate arm read {rows} rows and {inspected} prescriptions against the " + f"{MOVE_RATE_ROWS} and {MOVE_RATE_PRESCRIPTIONS} measured. A row leaving `MOVE_COUNTS` " + f"takes its dose out of the only guard that reads a move count against a work period." + ) + + +def test_the_MOVE_COUNT_TABLE_still_matches_the_PROSE_IT_RESTATES() -> None: + """⚠️ GUARD, the other end. A reword that moves the number off 'moves' goes red here.""" + for key, count in MOVE_COUNTS.items(): + found = numerals_before_moves(_BY_KEY[key].instructions) + assert set(count.words) <= found, ( + f"{key}'s instructions no longer spell {sorted(set(count.words) - found)} next to " + f"a move count — the words found there are {sorted(found)}. `MOVE_COUNTS` says " + f"{count.low}-{count.high} moves and the row's own text is what that restates, so " + f"one of the two has drifted and the band above is now checking a number nobody " + f"authored." + ) + + +def test_EVERY_ROW_WHOSE_PROSE_COUNTS_MOVES_is_TABLED_or_NAMED_AS_AN_EXCLUSION() -> None: + """⚠️ GUARD, both directions, on the register's own completeness.""" + in_scope = { + spec.key + for spec in EXERCISES + if numerals_before_moves(spec.instructions) + and any(prescription.work_seconds is not None for prescription in spec.prescriptions) + } + named = set(MOVE_COUNTS) | set(MOVE_RATE_EXCLUSIONS) + assert in_scope == named, ( + f"counted in prose and dosed in seconds but in neither register: " + f"{sorted(in_scope - named)}; registered but no longer both: {sorted(named - in_scope)}. " + f"A row that states a move count and carries a work period either goes in `MOVE_COUNTS` " + f"with its number, or in `MOVE_RATE_EXCLUSIONS` with the reason a climbing rate is not " + f"what its work period measures. `short_rest_boulder_sets` states five to seven moves " + f"and carries NO `work_seconds`, so nothing here can read it — authoring one onto it " + f"arrives as this failure, which is the decision it is." + ) + + # `CLIMBING_ASPECTS["power_endurance"]`'s published sentence, verbatim. Pinned rather than # paraphrased, so a reword arrives at the arms its own number is derived from. POWER_ENDURANCE_COPY = ( diff --git a/tests/test_planner_progression.py b/tests/test_planner_progression.py index 4090739..8b46cae 100644 --- a/tests/test_planner_progression.py +++ b/tests/test_planner_progression.py @@ -65,15 +65,13 @@ # A rule reaching zero cells is a rule nobody is testing, which is how three prescribed mechanisms # shipped byte-identical in this package before anybody measured the counterfactual. # -# ⚠️ RE-BASED for ruling 41's `easy_climbing_flush`, and the shorter-rest arm lost more than a -# tenth. Re-measured with the row in the tree: longer-work 608 (floor unchanged, it still clears -# 573), more-rounds 498 → 438, shorter-rest **57 → 15**. The row takes a wall turn in `strength` -# and `power`, so an exercise that used to land in two loading weeks of the SAME block now often -# lands in one, and a week-PAIR is what this arm counts. What that costs is named, not averaged: -# `boulders_on_the_two_minute` contributed 30 of the 57 shorter-rest cells (18 `strength`, 12 -# `power`) and now contributes **none**, so the arm reads `short_rest_boulder_sets` alone — the -# very row ruling 43 records as carrying no `work_seconds` for its own guard to read. F26 hangs -# on this rule and this arm is now thin. Raising it back is a sweep widening, not a re-dose. +# ⚠️ RE-BASED for ruling 41's `easy_climbing_flush`: longer-work 608 (floor unchanged, it clears +# 573), more-rounds 498 → 438, shorter-rest **57 → 15**, because `boulders_on_the_two_minute` +# stopped landing in two loading weeks of one block and a week-PAIR is what THIS arm counts. +# +# ⚠️ Ruling 50 widened the SAMPLING UNIT instead of raising these: a drawn CELL needs one week +# where a pair needs two, so the cell arm below reads 189 shorter-rest cells where this reads 15. +# The pair arm STAYS — a pair is the stronger evidence when it fires. _CELLS_INSPECTED = {_LONGER_WORK: 573, _SHORTER_REST: 13, _MORE_ROUNDS: 394} # Pools of exactly one exercise, which no index can move week to week. Pinned so the arm below @@ -83,6 +81,38 @@ # A second on-wall `endurance` row in either phase takes this back down and must be a decision. _SINGLETON_WALL_POOLS = 6 +# ⚠️ THE CELL ARM (ruling 50). Floors are 90% of the measured 762 / 189 / 1279 later-loading-week +# cells, and the shorter-rest figure is a 12.6x widening of the pair arm's 15. +_LATER_WEEK_CELLS_INSPECTED = {_LONGER_WORK: 685, _SHORTER_REST: 170, _MORE_ROUNDS: 1151} + +# ⚠️ Per (rule, phase), because a POOLED floor reads green while a whole phase falls to zero — +# which is how the shorter-rest arm went quiet in `strength` and `performance`. (drawn, later). +_COVERAGE_FLOORS: Mapping[tuple[str, Phase], tuple[int, int]] = { + (_LONGER_WORK, Phase.BASE): (310, 202), + (_LONGER_WORK, Phase.STRENGTH): (340, 232), + (_LONGER_WORK, Phase.POWER): (186, 121), + (_LONGER_WORK, Phase.POWER_ENDURANCE): (194, 129), + (_SHORTER_REST, Phase.STRENGTH): (93, 0), + (_SHORTER_REST, Phase.POWER): (146, 89), + (_SHORTER_REST, Phase.POWER_ENDURANCE): (48, 16), + (_SHORTER_REST, Phase.PERFORMANCE): (97, 64), + (_MORE_ROUNDS, Phase.BASE): (220, 171), + (_MORE_ROUNDS, Phase.STRENGTH): (329, 234), + (_MORE_ROUNDS, Phase.POWER): (388, 283), + (_MORE_ROUNDS, Phase.POWER_ENDURANCE): (198, 140), + (_MORE_ROUNDS, Phase.PERFORMANCE): (439, 321), +} + +# The one cell the cell arm cannot reach, with its reason as DATA rather than as a comment. +_NO_LATER_WEEK_COVERAGE: Mapping[tuple[str, Phase], str] = { + (_SHORTER_REST, Phase.STRENGTH): ( + "`boulders_on_the_two_minute` is drawn 104 times in `strength` and EVERY draw is week 1 " + "of its block, so there is no later-week dose to read. That is a SELECTION fact and not " + "a guard fact: ruling 50 refuses a synthetic plan built to fill it, and pins the cell as " + "a known zero instead so it goes RED if selection ever changes." + ), +} + @dataclass(frozen=True, slots=True) class _Climber: @@ -163,6 +193,22 @@ def _doses_by_block(climber: _Climber) -> tuple[tuple[str, Phase, int, str, tupl return tuple(rows) +@cache +def _coverage() -> tuple[Mapping[tuple[str, Phase], int], Mapping[tuple[str, Phase], int]]: + """Per (rule, phase): loading-week cells the sweep drew, and how many are after week 1.""" + drawn: Counter[tuple[str, Phase]] = Counter() + later: Counter[tuple[str, Phase]] = Counter() + for climber in _SWEEP: + for _, phase, week_no, key, _ in _doses_by_block(climber): + rule = _rule_of(key, phase) + if rule is None or _week_of_block(week_no) > _LOADING_WEEKS: + continue + drawn[(rule, phase)] += 1 + if _week_of_block(week_no) > 1: + later[(rule, phase)] += 1 + return drawn, later + + def _rule_of(exercise_key: str, phase: Phase) -> str | None: """Ruling 46's key applied to one row: the pair, and `work_seconds` in the split cell.""" spec = _BY_KEY[exercise_key] @@ -182,6 +228,56 @@ def _authored(exercise_key: str, phase: Phase) -> PrescriptionSpec: return next(p for p in _BY_KEY[exercise_key].prescriptions if p.phase is phase) +def _week_of_block(week_no: int) -> int: + """This plan week's 1-based ordinal inside its own block: 1-3 loading, then the unload.""" + return (week_no - 1) % _WEEKS_PER_BLOCK + 1 + + +def _operative_rest(dose: tuple[int | None, ...]) -> int: + """The LONGER of a dose's two rest fields — ruling 43's reading, and ruling 44's.""" + return max(seconds or 0 for seconds in dose[2:]) + + +def _illegal_step( + rule: str, before: tuple[int | None, ...], after: tuple[int | None, ...] +) -> str | None: + """Why `after` is not a legal step from `before` under `rule`, or `None` if it is.""" + if after == before: + return f"the dose did not move at all, {before}; its {rule} rule names a direction." + if rule == _LONGER_WORK: + work_before: int | None = before[1] + work_after: int | None = after[1] + if work_before is None or work_after is None: + return f"no work period to lengthen, {before} -> {after}." + if work_after <= work_before: + return f"work {work_before} s -> {work_after} s, and An Cap progresses by LONGER work." + for index, field in ((2, "rest_seconds"), (3, "rest_between_sets_seconds")): + was: int | None = before[index] + now: int | None = after[index] + if was is None or now is None: + continue + if now < was: + return ( + f"{field} fell {was} s -> {now} s. An Cap progresses by harder or longer " + f"work and never by less rest — §5.2 names it." + ) + return None + if rule == _SHORTER_REST: + if _operative_rest(after) >= _operative_rest(before): + return f"the operative rest did not shorten, {before} -> {after}." + return None + if rule == _MORE_ROUNDS: + if (after[0] or 0) <= (before[0] or 0): + return f"rounds {before[0]} -> {after[0]}." + if after[1:] != before[1:]: + return ( + f"alactic work only gains ROUNDS. Longer work and shorter rest are both named " + f"counterproductive, and this moved {before} -> {after}." + ) + return None + return None + + def _week_pairs( climber: _Climber, ) -> list[tuple[str, Phase, str, int, tuple[int, ...], int, tuple[int, ...]]]: @@ -218,40 +314,108 @@ def test_a_PROGRESSING_PAIR_never_repeats_its_dose_inside_ONE_BLOCK(rule: str) - ) -def test_ANAEROBIC_CAPACITY_progresses_by_LONGER_WORK_and_NEVER_by_LESS_REST() -> None: - """⚠️ GUARD. Barrows §5.2 names a shorter rest as the one thing An Cap must not do.""" +def test_EVERY_PAIR_MOVES_THE_WAY_ITS_OWN_RULE_NAMES_and_no_two_rules_agree() -> None: + """⚠️ GUARD, the three rules two aspects share. An Cap must never lose rest and alactic + work gains ROUNDS only; the directions are `_illegal_step`'s, shared with the cell arm.""" for climber in _SWEEP: for block_id, phase, key, earlier, before, later, after in _week_pairs(climber): - if _rule_of(key, phase) != _LONGER_WORK: + rule = _rule_of(key, phase) + if rule is None: continue - where = f"{key} in {phase.value} ({block_id}), weeks {earlier} -> {later}" - assert after[1] is not None and before[1] is not None, f"{where}: no work to lengthen." - assert after[1] > before[1], f"{where}: work {before[1]} s -> {after[1]} s." - for index, field in ((2, "rest_seconds"), (3, "rest_between_sets_seconds")): - if before[index] is None or after[index] is None: - continue - assert after[index] >= before[index], ( - f"{where}: {field} fell {before[index]} s -> {after[index]} s. An Cap " - f"progresses by harder or longer work and never by less rest." - ) + reason = _illegal_step(rule, before, after) + assert reason is None, ( + f"{key} in {phase.value} ({block_id}), weeks {earlier} -> {later}: {reason}" + ) -def test_LACTIC_POWER_progresses_by_SHORTER_REST_and_ALACTIC_POWER_by_ROUNDS_ONLY() -> None: - """⚠️ GUARD, the two rules that share one aspect. Alactic rest and work must not move.""" +@pytest.mark.parametrize("rule", sorted(_LATER_WEEK_CELLS_INSPECTED)) +def test_a_DRAWN_CELL_AFTER_WEEK_ONE_LEFT_ITS_AUTHORED_DOSE_BEHIND(rule: str) -> None: + """⚠️ GUARD, ruling 50, per `(block, phase, exercise, week)`: the library's authored row + against `generate()`'s emitted one. A week-PAIR needs the same row drawn twice in one + block and `_pool_index` rotates the pool by week, so most rows never made a pair at all — + `broken_circuit_redpoint` made 0 from 136 drawn blocks, which is how F26's dose reached + production with no guard reading it. A drawn CELL needs one week, and week 1 is the + authored dose by construction (the arm below pins that), so weeks 2-3 are checkable + against the library with no second draw.""" + inspected = 0 for climber in _SWEEP: - for block_id, phase, key, earlier, before, later, after in _week_pairs(climber): - rule = _rule_of(key, phase) - where = f"{key} in {phase.value} ({block_id}), weeks {earlier} -> {later}" - if rule == _SHORTER_REST: - assert max(x or 0 for x in after[2:]) < max(x or 0 for x in before[2:]), ( - f"{where}: the operative rest did not shorten, {before} -> {after}." - ) - if rule == _MORE_ROUNDS: - assert after[0] > before[0], f"{where}: rounds {before[0]} -> {after[0]}." - assert after[1:] == before[1:], ( - f"{where}: alactic work only gains ROUNDS. Longer work and shorter rest are " - f"both named counterproductive, and this moved {before} -> {after}." - ) + for block_id, phase, week_no, key, dose in _doses_by_block(climber): + if _rule_of(key, phase) != rule or not 1 < _week_of_block(week_no) <= _LOADING_WEEKS: + continue + inspected += 1 + spec = _authored(key, phase) + before = ( + spec.sets, + spec.work_seconds, + spec.rest_seconds, + spec.rest_between_sets_seconds, + ) + reason = _illegal_step(rule, before, dose) + assert reason is None, ( + f"{key} in {phase.value} week {_week_of_block(week_no)} of its block " + f"({block_id}, plan week {week_no}) is dosed {dose} against the library's own " + f"{before}: {reason}" + ) + assert inspected >= _LATER_WEEK_CELLS_INSPECTED[rule], ( + f"only {inspected} {rule} cells inspected against the " + f"{_LATER_WEEK_CELLS_INSPECTED[rule]} floor. A cell arm that stops reading is worse " + f"than a thin pair arm, because it is the one that reaches every drawn row." + ) + + +def test_a_DRAWN_CELL_IN_WEEK_ONE_OF_ITS_BLOCK_IS_THE_LIBRARYS_OWN_DOSE() -> None: + """⚠️ GUARD, the arm above's premise. Week 1 progresses nothing, so it is the baseline + every later week is measured against.""" + inspected = 0 + for climber in _SWEEP: + for block_id, phase, week_no, key, dose in _doses_by_block(climber): + if _rule_of(key, phase) is None or _week_of_block(week_no) != 1: + continue + inspected += 1 + spec = _authored(key, phase) + assert dose == ( + spec.sets, + spec.work_seconds, + spec.rest_seconds, + spec.rest_between_sets_seconds, + ), ( + f"{key} in {phase.value} week 1 of {block_id} is dosed {dose} against the " + f"authored {spec}; a block's first loading week takes the row as written." + ) + assert inspected > 1000, f"only {inspected} first weeks inspected; the arm is not reading." + + +def test_the_PER_PHASE_COVERAGE_REGISTER_MATCHES_THE_SWEEP_BOTH_WAYS() -> None: + """⚠️ GUARD, ruling 50. A pooled floor is green while a phase falls to zero.""" + drawn, later = _coverage() + registered = set(_COVERAGE_FLOORS) + assert set(drawn) == registered, ( + f"drawn but unregistered: " + f"{sorted((r, p.value) for r, p in set(drawn) - registered)}; " + f"registered but no longer drawn: " + f"{sorted((r, p.value) for r, p in registered - set(drawn))}. Every (rule, " + f"phase) the sweep reaches needs a floor, or a phase can lose its coverage in silence." + ) + for cell, (drawn_floor, later_floor) in _COVERAGE_FLOORS.items(): + rule, phase = cell + assert drawn[cell] >= drawn_floor and later[cell] >= later_floor, ( + f"{rule} in {phase.value} is drawn {drawn[cell]} times ({later[cell]} of them after " + f"week 1) against a floor of {drawn_floor} ({later_floor}). Selection may drift, but " + f"not far enough to buy silence for a whole phase." + ) + + +def test_the_PHASES_WITH_NO_LATER_WEEK_DOSE_TO_READ_ARE_THE_DECLARED_ONES() -> None: + """⚠️ GUARD, ruling 50: the cell arm's own blind spot, pinned as an exact zero.""" + drawn, later = _coverage() + unreachable = {cell for cell in drawn if later[cell] == 0} + assert unreachable == set(_NO_LATER_WEEK_COVERAGE), ( + f"unreachable by the cell arm and undeclared: " + f"{sorted((r, p.value) for r, p in unreachable - set(_NO_LATER_WEEK_COVERAGE))}; " + f"declared unreachable but now readable: " + f"{sorted((r, p.value) for r, p in set(_NO_LATER_WEEK_COVERAGE) - unreachable)}. " + f"{' '.join(_NO_LATER_WEEK_COVERAGE.values())}" + ) def test_the_RULE_IS_KEYED_ON_THE_PAIR_and_could_NEVER_be_read_off_the_ASPECT() -> None: