Skip to content

fix(planner): make a plan that misreports its own length unconstructible - #129

Merged
kilianmc merged 1 commit into
devfrom
fix/plan-tiling-invariant
Sep 6, 2026
Merged

kilianmc merged 1 commit into
devfrom
fix/plan-tiling-invariant

Conversation

@kilianmc

@kilianmc kilianmc commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes the two structural traps the plan-length measurement report found, on today's code with today's behaviour unchanged, so the fixed-16-week change (ruling 49/51/52) lands where the failure is impossible. Split out of the length PR by ruling 53, which amends ruling 51's last bullet.

Invariant 1 — a plan must tile its own week_count

Split across two constructors because the claim has two independent halves:

  • MesocycleBlueprint: the microcycle week_nos, in order, are exactly range(start_week, end_week + 1).
  • PlanBlueprint: those numbers concatenated over all mesocycles are exactly range(1, week_count + 1).

The plan-level tuple equality alone forbids a gap, an overlap, a wrong total, a start past week 1 and a week past the end — but it needs the per-mesocycle half too, or every span could claim weeks 1-3 while the microcycles still ran 1..N.

⚠️ Tuple equality rather than a pairwise contiguity loop. A pairwise loop passes vacuously on an empty mesocycles tuple; equality against range(1, week_count + 1) cannot, because MIN_WEEK_COUNT is 1 so the expected tuple is never empty. microcycles=() is an explicit arm. Contiguity of the spans themselves was already guarded by test_spans_tile_the_plan_exactly_once_starting_at_week_one — re-asserting it here would have been an inert check. The load-bearing new claim is the linkage: spans ↔ microcycles ↔ week_count.

Invariant 2 — the block count is read once

generate() now takes spans = mesocycle_spans(block_count_for(gap)) and derives week_count = spans[-1].end_week. week_count_for is no longer imported by generate.py.

⚠️ This fixes nothing today, and the report overstated it. week_count_for(gap) is defined as block_count_for(gap) * WEEKS_PER_BLOCK, so both former call sites already routed through one formula and could not disagree — "nothing ties those two reads together" was true of the call sites but not of the values. The trap is real and it is the next PR's: it fires the moment plan length gets a second source of truth.

week_count_for is deliberately left in place as independent arithmetic, so tests/test_plans_api.py:136 (week_count == week_count_for(grade_gap)) stays a genuine cross-check of generate's span-derived answer instead of a tautology. Residual for the length PR: editing week_count_for alone now has zero effect on a generated plan, and that test is the arm that catches it.

No behaviour change

Plan digest over the 24-profile sweep (test_phase_guide's own _SWEEP), blake2b over week_no | phase | order_index | exercise_key | block_seconds:

digest
before (87b7b5e) b079fce09ee80126
after b079fce09ee80126

Byte-identical, so GENERATOR_VERSION stays at 8.0.0 per PR #127's precedent. ⚠️ The baseline was re-measured at 87b7b5e — the four-block report's f96268fb5edba7ed does not reproduce, because it predates PR #128's re-dose.

Sabotage A — a week_count that does not tile its mesocycles

BEFORE                                                  AFTER
real plan: week_count=20 weeks_of_microcycles=20
 week_count=0  ValueError: ck_plan_week_count_in_range | ValueError: ck_plan_week_count_in_range
 week_count=53 ValueError: ck_plan_week_count_in_range | ValueError: ck_plan_week_count_in_range
 week_count=7  CONSTRUCTED '20-week sport plan'        | ValueError: week_count is 7 but the
               mesocycles=10 weeks=20  <- NO ERROR     |   mesocycles carry 20 weeks
 week_count=16 CONSTRUCTED '20-week sport plan' NO ERROR| ValueError: ... carry 20 weeks
 week_count=19 CONSTRUCTED '20-week sport plan' NO ERROR| ValueError: ... carry 20 weeks
 week_count=21 CONSTRUCTED '20-week sport plan' NO ERROR| ValueError: ... carry 20 weeks

The control is live on both sides: 0 and 53 raise ck_plan_week_count_in_range before and after, and a dedicated arm pins that message so the new check cannot swallow the old one.

Sabotage B — the block count read twice

Patch honours its gap (block_count_for(gap) + 1). At gap 2 the plan is 4 blocks / 16 weeks, which is the length PR's exact target shape.

BEFORE
 [gap 2] patch periodisation only    : week_count=20 weeks=16 name='20-week sport plan'
 [gap 2] patch generate (sys.modules): week_count=16 weeks=20 name='16-week sport plan'
                                       mesocycles=10  <- NO EXCEPTION
     ^^ DIVERGED: reports one length, prescribes another
 [gap 3] patch generate (sys.modules): week_count=20 weeks=24  DIVERGED

AFTER
 [gap 2] patch periodisation only    : week_count=16 weeks=16   (no longer reaches the plan)
 [gap 2] patch generate (sys.modules): week_count=20 weeks=20   consistent: one read
     forcing week_count back: ValueError: week_count is 16 but the mesocycles carry 20 weeks
 [gap 3] patch generate (sys.modules): week_count=24 weeks=24   consistent
     forcing week_count back: ValueError: week_count is 20 but the mesocycles carry 24 weeks

Guards shown red with only the source fix stashed: 3 failed / 9 passed, headline AssertionError: the extra block did not reach the length it reports / assert 16 == 20.

⚠️ The report's stated probe mechanism was wrong and it is now a guard, not a comment. It warned that a patch ignoring its block_count argument hides the trap. The real mechanism: server/domain/planner/__init__.py:46 re-exports the generate function, which shadows the submodule of the same name — so import server.domain.planner.generate as gen binds a function and setattr on it is a silent no-op. The first probe did exactly that and reported the trap as absent. You have to reach the module through sys.modules. Pinned by test_the_package_reexport_shadows_the_generate_submodule so the next probe cannot repeat it.

Two prose overclaims deleted

Both told the reader the schema cannot check something it demonstrably can, and both were the stated reason a check lives in Python — the shape that misdirects anyone later asking whether it belongs in the database.

  1. MesocycleBlueprint's new error no longer ends "Nothing in the schema can check across rows". A CHECK cannot, but an EXCLUDE USING gist constraint or a trigger can. The precise version survives in the module docstring: "the one invariant no CHECK can express because it spans rows."
  2. Pre-existing, and a weaker position: SessionBlueprint said "planned_session stores both and nothing in the schema keeps them in agreement", repeated at tests/test_planner_periodisation.py:306. Verified against the test database — a plain CHECK expresses it, no EXCLUDE or trigger needed:
check (weekday = extract(isodow from scheduled_on) - 1)   -- accepted
insert (0, '2026-09-07')  -- Monday, weekday 0  -> INSERT 0 1
insert (3, '2026-09-07')  -- Monday as Thursday -> ERROR: violates check constraint

isodow - 1, not dow: Postgres dow is 0=Sunday against this app's 0=Monday. It is CHECK-legal because scheduled_on is Date, so it casts to timestamp (immutable) rather than timestamptz (stable). The reason now has one home, narrowed to a claim about what is present rather than what is possible, at the site that executes it.

No constraint added and no migration. migrations/versions/0004_domain_schema.py:431 carries only weekday BETWEEN 0 AND 6; the Python check fails earlier and names the actual weekday, which is the better failure to read.

Gate

npm run check green — 1282 web, 1327 server (1322 + five new arms). No allowlist row added, BASELINE_RATCHET untouched.

🤖 Generated with Claude Code

Closes the two structural traps the plan-length report found, on today's code
with today's behaviour unchanged, so the fixed-16-week change lands where the
failure is impossible.

Invariant 1 - a plan must tile its own `week_count`, split across two
constructors because the claim has two halves. `MesocycleBlueprint`: the
microcycle `week_no`s, in order, are exactly `range(start_week, end_week+1)`.
`PlanBlueprint`: those numbers concatenated over all mesocycles are exactly
`range(1, week_count+1)`. Tuple equality rather than a pairwise contiguity
loop, which would pass vacuously on an empty `mesocycles` tuple.

Invariant 2 - one read. `generate()` takes `mesocycle_spans(block_count_for(
gap))` and derives `week_count` from `spans[-1].end_week`. This fixes nothing
today: `week_count_for(gap)` is defined as `block_count_for(gap) *
WEEKS_PER_BLOCK`, so both former call sites already routed through one formula
and could not disagree. It fires the moment plan length gets a second source
of truth, which is the next PR. `week_count_for` is left in place so
`tests/test_plans_api.py:136` stays a cross-check rather than a tautology.

Digest byte-identical over the 24-profile sweep, `b079fce09ee80126` both
sides, so `GENERATOR_VERSION` stays at 8.0.0 per PR #127's precedent.

Also deletes two comments that told the reader the schema cannot check
something it can - `check (weekday = extract(isodow from scheduled_on) - 1)`
is accepted by Postgres and bites, verified against the test database. No
constraint added: the Python check fails earlier and names the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
climb-trainer Ready Ready Preview Sep 6, 2026 7:22pm UTC

@kilianmc
kilianmc merged commit 23d8442 into dev Sep 6, 2026
5 checks passed
@kilianmc
kilianmc deleted the fix/plan-tiling-invariant branch September 6, 2026 19:24

This branch was successfully deployed

1 active deployment
Preview — 286b1a37 Deployed Sep 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant