From 5553cb0c96f7736dee1651bbc14095f4cd1eaab2 Mon Sep 17 00:00:00 2001 From: Hulian Felipe Muller Buligon Date: Wed, 19 Aug 2026 09:48:37 -0300 Subject: [PATCH 1/2] fix(warmup): warm paid-to-free transitions --- app/core/usage/refresh_scheduler.py | 4 + app/modules/limit_warmup/service.py | 37 +++++- .../warm-free-plan-transition/design.md | 76 +++++++++++ .../warm-free-plan-transition/proposal.md | 35 +++++ .../specs/usage-refresh-policy/spec.md | 54 ++++++++ .../warm-free-plan-transition/tasks.md | 19 +++ .../test_usage_refresh_scheduler_scope.py | 86 ++++++++++++ tests/unit/test_limit_warmup.py | 122 ++++++++++++++++++ 8 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/warm-free-plan-transition/design.md create mode 100644 openspec/changes/warm-free-plan-transition/proposal.md create mode 100644 openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md create mode 100644 openspec/changes/warm-free-plan-transition/tasks.md diff --git a/app/core/usage/refresh_scheduler.py b/app/core/usage/refresh_scheduler.py index 0bbd27c14a..a6133d836f 100644 --- a/app/core/usage/refresh_scheduler.py +++ b/app/core/usage/refresh_scheduler.py @@ -197,6 +197,9 @@ async def _refresh_as_leader(self) -> float: selected_account, cycle_complete = self._select_next_account(accounts) if selected_account is not None: selected_account_ids = [selected_account.id] + previous_plan_types = { + selected_account.id: normalize_account_plan_type(selected_account.plan_type) + } before_primary = await usage_repo.latest_by_account( window="primary", account_ids=selected_account_ids, @@ -283,6 +286,7 @@ async def _refresh_as_leader(self) -> float: monthly_entries=warmup_after_monthly, secondary_entries=after_secondary, ), + previous_plan_types=previous_plan_types, refresh_started_at=refresh_started_at, usage_refresh_interval_seconds=self.interval_seconds, ) diff --git a/app/modules/limit_warmup/service.py b/app/modules/limit_warmup/service.py index 71cf52dfd4..b6db652f29 100644 --- a/app/modules/limit_warmup/service.py +++ b/app/modules/limit_warmup/service.py @@ -16,7 +16,7 @@ from app.core.openai.models import OpenAIError, ResponseUsage from app.core.openai.parsing import parse_sse_event from app.core.openai.requests import ResponsesRequest -from app.core.plan_types import account_plan_matches_allowed +from app.core.plan_types import account_plan_matches_allowed, normalize_account_plan_type from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route from app.core.usage.pricing import get_pricing_for_model from app.core.utils.time import naive_utc_to_epoch, utcnow @@ -342,6 +342,7 @@ async def run_after_usage_refresh( before_secondary: dict[str, UsageHistory], after_primary: dict[str, UsageHistory], after_secondary: dict[str, UsageHistory], + previous_plan_types: dict[str, str | None] | None = None, refresh_started_at: datetime | None = None, usage_refresh_interval_seconds: int = _STAGGER_SLOT_GRACE_SECONDS, ) -> None: @@ -388,6 +389,14 @@ async def run_after_usage_refresh( after_secondary=after_secondary, min_available_percent=settings.limit_warmup_min_available_percent, ) + if candidate is None and window == "secondary": + candidate = _build_paid_to_free_transition_candidate( + account=account, + previous_plan_type=(previous_plan_types or {}).get(account.id), + after_secondary=after_secondary, + refresh_started_at=refresh_started_at, + min_available_percent=settings.limit_warmup_min_available_percent, + ) if ( candidate is None and _account_is_safe_candidate(account) @@ -745,6 +754,32 @@ def usage_reset_confirmed(*, before: UsageHistory | None, after: UsageHistory | return True +def _build_paid_to_free_transition_candidate( + *, + account: Account, + previous_plan_type: str | None, + after_secondary: dict[str, UsageHistory], + refresh_started_at: datetime | None, + min_available_percent: float, +) -> _WarmupCandidate | None: + normalized_previous_plan = normalize_account_plan_type(previous_plan_type) + if normalized_previous_plan is None or normalized_previous_plan == "free": + return None + if normalize_account_plan_type(account.plan_type) != "free": + return None + if refresh_started_at is None: + return None + after = after_secondary.get(account.id) + if after is None or after.window != "monthly" or after.reset_at is None: + return None + if after.recorded_at < refresh_started_at: + return None + available_percent = 100.0 - after.used_percent + if min_available_percent < 100.0 and available_percent < min_available_percent: + return None + return _WarmupCandidate(reset_at=after.reset_at, window="monthly") + + def _build_staggered_idle_candidate( *, account: Account, diff --git a/openspec/changes/warm-free-plan-transition/design.md b/openspec/changes/warm-free-plan-transition/design.md new file mode 100644 index 0000000000..77c51f1cb2 --- /dev/null +++ b/openspec/changes/warm-free-plan-transition/design.md @@ -0,0 +1,76 @@ +## Context + +See `proposal.md` for motivation. The usage updater mutates and synchronizes the +selected account only after its existing paid-to-Free confirmation policy is +satisfied. The warm-up service currently sees only the post-refresh account and +requires matching canonical before/after windows, so it cannot distinguish a +confirmed plan transition from an account that was already Free. + +The existing `usage_reset_confirmed` guard protects ordinary reset detection +from cross-window comparisons and timestamp drift. The transition path must not +weaken that guard. + +## Goals / Non-Goals + +**Goals:** + +- Carry enough refresh-scoped evidence to identify a confirmed paid-to-Free + transition without introducing new persistent state. +- Require the monthly candidate to have been written by the same refresh and + to pass the existing availability, account, and global opt-in gates. +- Reuse the existing monthly warm-up identity and atomic claim. + +**Non-Goals:** + +- Changing paid-to-Free confirmation or ordinary same-window reset detection. +- Adding settings, schema, migrations, retry queues, or periodic backfill. +- Sending warm-up traffic to inactive or non-opted-in accounts. + +## Decisions + +### Snapshot the selected account plan before refresh + +The scheduler will preserve the selected account's normalized pre-refresh plan +and pass it to warm-up evaluation after reloading the account. A transition is +eligible only when the snapshot is a recognized paid plan and the persisted +post-refresh plan is `free`. + +Alternative considered: infer a transition from `secondary` to `monthly` usage +rows. That would incorrectly classify already-Free accounts whose first monthly +sample arrives after stale secondary history. + +### Require a monthly sample written during the same refresh + +The fallback candidate will accept only the selected long-window row when its +canonical window is `monthly`, it has a reset deadline, and its `recorded_at` is +at or after the refresh start. It will apply the existing minimum-availability +gate before returning a candidate. + +Alternative considered: use the latest persisted monthly row regardless of +age. That could warm stale quota after an unrelated plan metadata update. + +### Keep the transition as a fallback to normal reset detection + +The service will first evaluate the existing same-window reset candidate. Only +when that returns no candidate for the configured long window will it evaluate +the paid-to-Free transition. The resulting candidate uses `window="monthly"` +and the monthly `reset_at`, so the existing atomic attempt claim provides +deduplication. + +Alternative considered: alter `usage_reset_confirmed` to allow cross-window +transitions. That would weaken a safety guard used by status recovery and +ordinary warm-up paths. + +## Risks / Trade-offs + +- [A process exits after persisting plan and usage but before warm-up] → The + transition can be missed, matching the current event-triggered reset path; + avoid new persistence until stronger delivery semantics are required. +- [A future updater mutates plan before confirmation] → Keep regression coverage + at scheduler/service boundaries and rely on the updater's existing durable + two-observation confirmation contract. + +## Migration Plan + +No data migration is required. Deploy the code normally; rollback restores the +previous behavior without changing stored warm-up attempts or usage history. diff --git a/openspec/changes/warm-free-plan-transition/proposal.md b/openspec/changes/warm-free-plan-transition/proposal.md new file mode 100644 index 0000000000..807336756f --- /dev/null +++ b/openspec/changes/warm-free-plan-transition/proposal.md @@ -0,0 +1,35 @@ +## Why + +A confirmed paid-to-Free plan change can replace the account's prior paid quota +window with a newly available monthly window. The existing same-window safety +guard correctly rejects arbitrary cross-window comparisons, but it also skips +the opted-in warm-up for this confirmed plan transition. + +## What Changes + +- Preserve the selected account's plan type across one background refresh. +- Treat a confirmed paid-to-Free transition that writes a fresh available + monthly sample as a long-window warm-up candidate. +- Keep ordinary reset detection restricted to matching canonical windows and + keep single, unconfirmed Free observations ineligible. +- Add regressions for the consumer-visible warm-up attempt and the safety + boundaries around unchanged plans and stale monthly history. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `usage-refresh-policy`: Allow an opted-in long-window warm-up after a + confirmed paid-to-Free transition opens a fresh monthly quota window. + +## Impact + +- Affected code: `app/core/usage/refresh_scheduler.py` and + `app/modules/limit_warmup/service.py`. +- Affected tests: focused scheduler and limit warm-up tests. +- No API, schema, migration, setting, dependency, dashboard, or deployment + change. diff --git a/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md b/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md new file mode 100644 index 0000000000..1a521ab86f --- /dev/null +++ b/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Confirmed paid-to-Free transitions warm the new monthly window + +When background usage refresh confirms that an opted-in active account changed +from a recognized paid plan to `free`, and that confirming refresh writes a +fresh monthly usage sample with a reset deadline and enough available quota for +the configured warm-up threshold, the system SHALL attempt one long-window +warm-up for that monthly quota window. Eligibility MUST NOT depend on the usage +percentage reported before the plan change. + +The plan-transition exception SHALL apply only to an actual paid-to-Free change +confirmed by the refresh that wrote the monthly sample. It MUST NOT apply to a +single unconfirmed Free observation, an account that was already Free, or a +monthly sample left over from an earlier refresh. Ordinary same-window reset +detection MUST remain unchanged. The durable warm-up identity SHALL remain the +account, canonical `monthly` window, and monthly reset deadline. + +#### Scenario: Confirmed paid-to-Free transition warms fresh monthly quota + +- **GIVEN** an active opted-in account whose stored plan is a recognized paid plan +- **WHEN** background usage refresh confirms its transition to `free` +- **AND** that confirming refresh writes a monthly sample with a reset deadline and enough available quota +- **THEN** the system attempts one warm-up identified by the account, `monthly` window, and monthly reset deadline + +#### Scenario: Previous usage percentage does not gate plan-transition warm-up + +- **GIVEN** an active opted-in paid account whose previous selected quota sample was not exhausted +- **WHEN** background usage refresh confirms its transition to `free` and writes an eligible fresh monthly sample +- **THEN** the system attempts the monthly warm-up regardless of the previous usage percentage + +#### Scenario: One unconfirmed Free observation does not warm + +- **GIVEN** an active opted-in account whose stored plan is a recognized paid plan +- **WHEN** one background usage refresh reports `free` without satisfying downgrade confirmation +- **THEN** no plan-transition warm-up is attempted + +#### Scenario: Already-Free account does not use the plan-transition exception + +- **GIVEN** an active opted-in account whose stored plan was already `free` +- **WHEN** background usage refresh writes its first monthly sample without confirming a plan change +- **THEN** no plan-transition warm-up is attempted + +#### Scenario: Stale monthly history does not warm after a plan change + +- **GIVEN** an active opted-in account whose transition from a paid plan to `free` is confirmed +- **WHEN** the latest monthly sample predates the confirming refresh +- **THEN** no plan-transition warm-up is attempted + +#### Scenario: Existing durable identity deduplicates the transition warm-up + +- **GIVEN** a warm-up attempt already exists for an account, `monthly` window, and monthly reset deadline +- **WHEN** the same confirmed paid-to-Free transition is evaluated again +- **THEN** no second warm-up request is sent for that durable identity diff --git a/openspec/changes/warm-free-plan-transition/tasks.md b/openspec/changes/warm-free-plan-transition/tasks.md new file mode 100644 index 0000000000..6aac835ede --- /dev/null +++ b/openspec/changes/warm-free-plan-transition/tasks.md @@ -0,0 +1,19 @@ +## 1. Refresh-scoped transition evidence + +- [x] 1.1 Snapshot the selected account's plan before background usage refresh. +- [x] 1.2 Pass the pre-refresh plan map and refresh timestamp into long-window warm-up evaluation. + +## 2. Monthly transition candidate + +- [x] 2.1 Add a paid-to-Free fallback candidate that requires a fresh available monthly sample. +- [x] 2.2 Preserve ordinary same-window reset detection and the existing durable monthly claim. + +## 3. Regression coverage + +- [x] 3.1 Prove a confirmed paid-to-Free scheduler refresh sends one monthly warm-up regardless of prior usage. +- [x] 3.2 Cover unconfirmed or unchanged Free plans, stale monthly history, availability gating, and deduplication. + +## 4. Validation + +- [x] 4.1 Run focused scheduler and limit warm-up tests. +- [x] 4.2 Run Ruff format/check, Ty, strict OpenSpec validation, and diff hygiene checks. diff --git a/tests/integration/test_usage_refresh_scheduler_scope.py b/tests/integration/test_usage_refresh_scheduler_scope.py index cb734c0724..8b9ae51a4a 100644 --- a/tests/integration/test_usage_refresh_scheduler_scope.py +++ b/tests/integration/test_usage_refresh_scheduler_scope.py @@ -170,6 +170,7 @@ async def run_after_usage_refresh(self, **kwargs: object) -> None: selected.id, unrelated.id, } + assert warmup_calls[0]["previous_plan_types"] == {selected.id: "plus"} for snapshot_name in ("before_primary", "before_secondary", "after_primary", "after_secondary"): assert set(cast("dict[str, UsageHistory]", warmup_calls[0][snapshot_name])) <= {selected.id} @@ -298,6 +299,91 @@ async def send( assert (attempt.window, attempt.reset_at, attempt.status) == ("monthly", after_reset_at, "succeeded") +@pytest.mark.asyncio +async def test_scheduler_warms_confirmed_paid_to_free_plan_transition( + db_setup, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del db_setup + account = _account("acc_paid_to_free", status=AccountStatus.ACTIVE) + prior_reset_at = int(time.time()) + 7 * 24 * 60 * 60 + monthly_reset_at = int(time.time()) + 30 * 24 * 60 * 60 + + async with SessionLocal() as session: + await AccountsRepository(session).upsert(account) + await UsageRepository(session).add_entry( + account.id, + 100.0, + window="secondary", + recorded_at=utcnow(), + reset_at=prior_reset_at, + window_minutes=10_080, + ) + await SettingsRepository(session).update( + limit_warmup_enabled=True, + limit_warmup_windows="secondary", + limit_warmup_model="gpt-5.1-codex-mini", + ) + + class _Leader: + async def run_if_leader(self, fn: Callable[[], Awaitable[object]]) -> object: + return await fn() + + class _Updater: + async def refresh_accounts( + self, + accounts: list[Account], + latest_usage: dict[str, UsageHistory], + ) -> bool: + assert [candidate.id for candidate in accounts] == [account.id] + assert accounts[0].plan_type == "plus" + accounts[0].plan_type = "free" + async with SessionLocal() as session: + persisted = await AccountsRepository(session).get_by_id(account.id) + assert persisted is not None + persisted.plan_type = "free" + await session.commit() + await UsageRepository(session).add_entry( + account.id, + 0.0, + window="monthly", + recorded_at=utcnow(), + reset_at=monthly_reset_at, + window_minutes=43_200, + ) + return True + + class _Sender: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + async def send( + self, + target: Account, + *, + model: str, + prompt: str, + ) -> LimitWarmupSendResult: + self.calls.append((target.id, model)) + return LimitWarmupSendResult(request_id="warmup-plan-transition", success=True, latency_ms=12) + + sender = _Sender() + monkeypatch.setattr(refresh_scheduler_module, "_get_leader_election", lambda: _Leader()) + monkeypatch.setattr(refresh_scheduler_module, "build_background_usage_updater", lambda: _Updater()) + monkeypatch.setattr(refresh_scheduler_module, "StreamingLimitWarmupSender", lambda *_args, **_kwargs: sender) + + scheduler = refresh_scheduler_module.UsageRefreshScheduler(interval_seconds=60, enabled=True) + + assert await scheduler._refresh_once() == 60.0 + assert sender.calls == [(account.id, "gpt-5.1-codex-mini")] + async with SessionLocal() as session: + persisted_account = await AccountsRepository(session).get_by_id(account.id) + attempt = (await LimitWarmupRepository(session).latest_by_account([account.id]))[account.id] + assert persisted_account is not None + assert persisted_account.plan_type == "free" + assert (attempt.window, attempt.reset_at, attempt.status) == ("monthly", monthly_reset_at, "succeeded") + + @pytest.mark.asyncio @pytest.mark.parametrize("existing_attempt", [False, True], ids=["warmup-new", "warmup-deduped"]) @pytest.mark.parametrize( diff --git a/tests/unit/test_limit_warmup.py b/tests/unit/test_limit_warmup.py index cc1e3bd8b0..bd00bc95ea 100644 --- a/tests/unit/test_limit_warmup.py +++ b/tests/unit/test_limit_warmup.py @@ -1110,6 +1110,128 @@ async def test_monthly_free_quota_reset_warms_and_records_monthly_window() -> No assert [(row.window, row.reset_at, row.status) for row in repo.rows] == [("monthly", 2000, "succeeded")] +@pytest.mark.asyncio +async def test_confirmed_paid_to_free_transition_warms_fresh_monthly_window() -> None: + repo = FakeWarmupRepo() + sender = FakeSender() + service = LimitWarmupService(repo, FakeRequestLogsRepo(), sender=sender) + account = _account() + account.plan_type = "free" + refresh_started_at = datetime(2026, 8, 18, 18, 8, tzinfo=timezone.utc).replace(tzinfo=None) + monthly_reset_at = int(refresh_started_at.replace(tzinfo=timezone.utc).timestamp()) + 43_200 * 60 + + await service.run_after_usage_refresh( + accounts=[account], + settings=_settings(limit_warmup_windows="secondary"), + before_primary={}, + before_secondary={account.id: _usage(account.id, used_percent=37, reset_at=10_000, window="secondary")}, + after_primary={}, + after_secondary={ + account.id: _usage( + account.id, + used_percent=0, + reset_at=monthly_reset_at, + window="monthly", + recorded_at=refresh_started_at, + ) + }, + previous_plan_types={account.id: "plus"}, + refresh_started_at=refresh_started_at, + ) + + assert sender.calls == [(account.id, "gpt-5.1-codex-mini")] + assert [(row.window, row.reset_at, row.status) for row in repo.rows] == [("monthly", monthly_reset_at, "succeeded")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("previous_plan_type", "current_plan_type", "sample_age_seconds", "used_percent", "minimum_available"), + [ + ("free", "free", 0, 0.0, 100.0), + ("plus", "plus", 0, 0.0, 100.0), + ("plus", "free", -1, 0.0, 100.0), + ("plus", "free", 0, 2.0, 99.0), + ], + ids=["already-free", "unconfirmed", "stale-monthly", "below-availability-gate"], +) +async def test_paid_to_free_transition_candidate_rejects_unsafe_evidence( + previous_plan_type: str, + current_plan_type: str, + sample_age_seconds: int, + used_percent: float, + minimum_available: float, +) -> None: + repo = FakeWarmupRepo() + sender = FakeSender() + service = LimitWarmupService(repo, FakeRequestLogsRepo(), sender=sender) + account = _account() + account.plan_type = current_plan_type + refresh_started_at = datetime(2026, 8, 18, 18, 8, tzinfo=timezone.utc).replace(tzinfo=None) + recorded_at = refresh_started_at + timedelta(seconds=sample_age_seconds) + + await service.run_after_usage_refresh( + accounts=[account], + settings=_settings( + limit_warmup_windows="secondary", + limit_warmup_min_available_percent=minimum_available, + ), + before_primary={}, + before_secondary={}, + after_primary={}, + after_secondary={ + account.id: _usage( + account.id, + used_percent=used_percent, + reset_at=2_000_000_000, + window="monthly", + recorded_at=recorded_at, + ) + }, + previous_plan_types={account.id: previous_plan_type}, + refresh_started_at=refresh_started_at, + ) + + assert sender.calls == [] + assert repo.rows == [] + + +@pytest.mark.asyncio +async def test_paid_to_free_transition_warmup_is_deduplicated_by_monthly_reset() -> None: + repo = FakeWarmupRepo() + sender = FakeSender() + service = LimitWarmupService(repo, FakeRequestLogsRepo(), sender=sender) + account = _account() + account.plan_type = "free" + refresh_started_at = datetime(2026, 8, 18, 18, 8, tzinfo=timezone.utc).replace(tzinfo=None) + after_secondary = { + account.id: _usage( + account.id, + used_percent=0, + reset_at=2_000_000_000, + window="monthly", + recorded_at=refresh_started_at, + ) + } + + async def run_once() -> None: + await service.run_after_usage_refresh( + accounts=[account], + settings=_settings(limit_warmup_windows="secondary"), + before_primary={}, + before_secondary={}, + after_primary={}, + after_secondary=after_secondary, + previous_plan_types={account.id: "pro"}, + refresh_started_at=refresh_started_at, + ) + + await run_once() + await run_once() + + assert sender.calls == [(account.id, "gpt-5.1-codex-mini")] + assert [(row.window, row.reset_at) for row in repo.rows] == [("monthly", 2_000_000_000)] + + @pytest.mark.asyncio async def test_long_window_warmup_ignores_cross_window_transition() -> None: repo = FakeWarmupRepo() From b14dd29212bf5781fa9af8df7f0721e761133f22 Mon Sep 17 00:00:00 2001 From: Hulian Felipe Muller Buligon Date: Wed, 19 Aug 2026 10:40:59 -0300 Subject: [PATCH 2/2] fix(warmup): reject exhausted transition samples --- app/modules/limit_warmup/service.py | 2 ++ .../specs/usage-refresh-policy/spec.md | 4 +++- tests/unit/test_limit_warmup.py | 9 ++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/modules/limit_warmup/service.py b/app/modules/limit_warmup/service.py index b6db652f29..f490f96b3b 100644 --- a/app/modules/limit_warmup/service.py +++ b/app/modules/limit_warmup/service.py @@ -774,6 +774,8 @@ def _build_paid_to_free_transition_candidate( return None if after.recorded_at < refresh_started_at: return None + if after.used_percent >= 100.0: + return None available_percent = 100.0 - after.used_percent if min_available_percent < 100.0 and available_percent < min_available_percent: return None diff --git a/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md b/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md index 1a521ab86f..73167cfcd8 100644 --- a/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md +++ b/openspec/changes/warm-free-plan-transition/specs/usage-refresh-policy/spec.md @@ -14,7 +14,9 @@ confirmed by the refresh that wrote the monthly sample. It MUST NOT apply to a single unconfirmed Free observation, an account that was already Free, or a monthly sample left over from an earlier refresh. Ordinary same-window reset detection MUST remain unchanged. The durable warm-up identity SHALL remain the -account, canonical `monthly` window, and monthly reset deadline. +account, canonical `monthly` window, and monthly reset deadline. The confirming +monthly sample MUST report `used_percent < 100`; the configured minimum- +available threshold MAY impose a stricter lower usage limit. #### Scenario: Confirmed paid-to-Free transition warms fresh monthly quota diff --git a/tests/unit/test_limit_warmup.py b/tests/unit/test_limit_warmup.py index bd00bc95ea..0f45c0afa4 100644 --- a/tests/unit/test_limit_warmup.py +++ b/tests/unit/test_limit_warmup.py @@ -1151,8 +1151,15 @@ async def test_confirmed_paid_to_free_transition_warms_fresh_monthly_window() -> ("plus", "plus", 0, 0.0, 100.0), ("plus", "free", -1, 0.0, 100.0), ("plus", "free", 0, 2.0, 99.0), + ("plus", "free", 0, 100.0, 100.0), + ], + ids=[ + "already-free", + "unconfirmed", + "stale-monthly", + "below-availability-gate", + "exhausted-monthly-at-default-gate", ], - ids=["already-free", "unconfirmed", "stale-monthly", "below-availability-gate"], ) async def test_paid_to_free_transition_candidate_rejects_unsafe_evidence( previous_plan_type: str,