Skip to content

fix(learning-engine): league rollover catch-up, local-day streak nudge, FSRS lapsed backfill, interleaving gate, store races (#549) - #559

Merged
guillermoscript merged 1 commit into
masterfrom
fix/learning-engine-correctness-549
Jul 26, 2026
Merged

fix(learning-engine): league rollover catch-up, local-day streak nudge, FSRS lapsed backfill, interleaving gate, store races (#549)#559
guillermoscript merged 1 commit into
masterfrom
fix/learning-engine-correctness-549

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

What

Fixes the six correctness defects in issue #549 across the shipped learning-engine work (#389 FSRS, #393 interleaving, #394 leagues + store, #397 digest), and stands up the Vitest runner in mcp-server/ that four of them needed but did not have.

Closes #549

Why

Each of the five features landed with a correct happy path and an unexamined boundary. None had test coverage, and mcp-server/ had no test runner at all, so step 0 was standing one up and characterizing the affected pure functions against current behaviour before changing anything.

§1 — one skipped league rollover wiped every student's tier. The previous week was hardcoded as v_week - 7. Miss a single Monday 00:05 tick and that join matches zero rows, COALESCE(…, 1) resets the whole tenant to Bronze, and the genuinely-previous week keeps final_rank/movement NULL forever. The previous week is now resolved from the data (greatest week_start below the target that actually has rows), and every unfinalized older week is finalized, each over its own [w, w + 7) XP window. That window is the load-bearing half: carrying one window across a multi-week gap would rank a stale cohort over weeks of unrelated activity and produce confidently wrong ranks.

This also makes the ordinary weekly run its own catch-up path, so the cron route needs no _week_start argument — only a doc comment, which it got.

§2 — promotion/relegation carried no information at the minimum cohort size. All five tiers seed promote_count = demote_count = 5 against a cold-start floor of 10, so at exactly 10 the bands tile the cohort and 'stayed' is unreachable — the tier a student holds says nothing. Bands now scale: LEAST(tier's count, GREATEST(1, active_size / 5), (active_size - 1) / 2). The third term guarantees at least one 'stayed' for any cohort of 3+. Zero-XP members are excluded from the size the bands are computed against and parked as 'stayed', so an inactive student can no longer promote over an active one on a membership-UUID tiebreak.

§3 — the streak nudge was inverted for the LATAM market. isStreakAtRisk compared last_activity_date against UTC yesterday while the send decision (localHour) and the idempotency key (localDateStr) are tenant-local. At UTC−5 the 20:00 nudge runs at 01:00 UTC the next UTC day, so a student who practised that same local afternoon was told their streak was ending hours after they had secured it, while the students who actually skipped the local day failed the equality and got nothing. Fires whenever nudgeHour + |utc_offset| >= 24. Now compared against the tenant-local day boundary, computed calendar-first so it is correct across DST. The candidate RPC's matching = CURRENT_DATE - 1 pre-filter widened to >= CURRENT_DATE - 2 so it is a genuine superset for every offset and the precise call happens where the timezone is known.

§4 — the FSRS backfill skipped the hardest cards. The predicate was repetitions > 0, but the SM-2 code it replaced reset repetitions to 0 on a lapse. So it selected exactly the cards that were not struggling and skipped every card whose last rating was "Again", which cardFromRow then silently downgraded to createEmptyCard(). Widened to repetitions > 0 OR last_reviewed_at IS NOT NULL, with difficulty derived from the retained ease column. The stability IS NULL guard keeps it re-runnable and leaves already-seeded rows untouched.

§5 — the interleaving gate was advisory, and mixed sessions multiplied XP. lms_record_practice_attempt accepted mode: 'mixed' with arbitrary topics, no gate and no kill-switch check — and its own description instructs the model to call it directly for free-text and chat/voice drills, so the bypass was the documented path. Worse, the rows it wrote are the rows fetchInterleavingPool reads back, so an ungated session certified its own topics. Both tools now route through one shared blockedInterleavingTopics() decision, evaluated before the insert. Separately, XP moved from once-per-row to once-per-session, so a 3-topic session no longer pays 45 XP and burns 3 of the 10 daily slots against 15 XP for the same work done focused.

§6 — spend-points double-spent coins and over-granted freezes. Three unsynchronized read-modify-writes against a derived balance (floor(total_xp / 10) − total_coins_spent), so a lost update meant the student kept the item and the coins. Replaced with a single redeem_store_item() RPC holding SELECT … FOR UPDATE on the profile row for the balance check, cap check, redemption insert, coin debit and reward grant. It also now refuses at the 5-freeze ceiling instead of charging for a grant that cannot land.

Two deliberate deviations from the issue's proposed fixes

  1. No partial unique index for the monthly cap (§6). streak_freeze is seeded with max_per_user = 5, not 1 (20260216000001_seed_gamification_data.sql:63), and a unique index cannot express "at most 5 per calendar month" — adding one would silently cap the item at 1/month and change product behaviour. The profile row lock already serializes every redemption for a given user, which is what makes the surviving count-then-insert safe. That is the mechanism relied on here.
  2. award_xp's day boundary is unchanged (§3), as the issue directs. That leaves one residual: a student whose only activity fell in the local evening after the UTC day rolled over is still measured against the UTC day. It is documented at the call site rather than left looking fixed.

How to QA

Backend-only — nothing user-visible changed, so there are no screenshots. Everything below runs on a fresh npm run db:reset.

Automated:

  1. npm run test:unit → 467 passing. The LATAM regression is tests/unit/daily-digest.test.ts"LATAM regression (issue Learning engine correctness: leagues, streak nudge timezone, FSRS backfill, interleaving gate, store races #549 §3)", which asserts a Bogotá student who practised that local day is not nudged and one who skipped is. The old code got both backwards.
  2. cd mcp-server && npm test → 35 passing. This runner is new; there was none before.
  3. npm run typecheck and npm run build → clean.

Seeded-DB assertions (the parts that live in SQL and no Vitest suite can reach) are committed under tests/sql/issue-549/ with a runbook. Each script rolls itself back and raises an exception on failure rather than printing rows to eyeball:

docker exec -i supabase_db_lms-front psql -U postgres -d postgres -P pager=off \
  < tests/sql/issue-549/test-leagues-549.sql
  • test-leagues-549.sql — seeds 12 students, skips a week, then rolls over. Asserts tiers are carried (2 Bronze — the demoted pair — not 12), the skipped week is finalized, and at least one 'stayed' exists. It also plants a 999999-XP award outside the ranked week for the last-placed student: if the XP window ever leaks across weeks again, that student ranks first and the assertion fails.
  • test-fsrs-549.sql — a lapsed card gets non-NULL stability/difficulty derived from its ease; never-reviewed and already-seeded cards are untouched; the original predicate is shown matching zero rows against the same lapsed card.
  • test-practice-xp-549.sql — a 3-topic mixed session produces exactly one 15-XP transaction; adding a focused session takes it to two / 30 XP.
  • test-store-race-549.sql — sequential correctness, plus the ceiling refusing without changing total_coins_spent.
  • The real concurrency race (5 committed concurrent psql processes) has its own setup/verify/cleanup trio and a copy-pasteable driver in tests/sql/issue-549/README.md. Observed: all 5 return distinct coins_remaining (4000 / 3000 / 2000 / 1000 / 0 — they serialized), total_coins_spent exactly 5000, 5 redemptions, no lost update.

Reproducing the §1 bug, if you want to see it fail first: restore the pre-fix rollover_leagues body (lines 235–400 of 20260717120000_create_weekly_leagues.sql) inside a transaction and run the same league scenario. All 12 students collapse to Bronze and week W stays entirely unfinalized. That is how each of these was confirmed before being fixed.

Screenshots / GIF

N/A — no user-visible surface changed. The changes are five migrations, one edge function, one MCP tool, and one notification helper.

Checklist

  • npm run typecheck and npm run test:unit pass
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_id (or the table genuinely has no such column)
  • Tested with every relevant role (student / teacher / admin) — see note below
  • Loading and error states handled — the spend-points response shape is preserved, including the available/required fields on insufficient_coins, so existing clients keep working; new refusals carry a machine-readable code
  • New UI strings added to both messages/en.json and messages/es.json — N/A, no UI strings
  • Migration applies cleanly, has RLS policies, and lib/database.types.ts was regenerated

On roles: these paths are not role-branched — the rollover and digest run as service_role from cron, redeem_store_item is service_role-only with the edge function authenticating the caller, and the practice tools are self-scoped under the caller's own RLS client. The role-relevant assertion is that none of the new SQL is reachable by anon/authenticated; all three new/changed functions are explicitly REVOKEd from both.

On lib/database.types.ts: updated surgically (the practice_attempts.session_id column and the redeem_store_item signature) rather than by regenerating the whole file. A full regeneration also sweeps in unrelated drift from other recently merged migrations (platform_payment_requests.expires_at, tenants.plan_override_*, can_read_exam) and drops the __InternalSupabase version block, which would bury this diff in churn that isn't mine. Those additions were verified byte-identical to what supabase gen types --local produces.

Risk

  • §1 and §2 change how ranks are computed, which is player-visible progression. Any cohort currently sitting unfinalized will be ranked on the first run after deploy — that is the intended repair, but it is a one-time visible movement for affected tenants.
  • §5 lowers an accrual rate students can see: a 3-topic session drops from 45 XP to 15. That is the correct rate — it matches focused practice for the same work — but it is a nerf, and worth knowing before someone reports it as a bug.
  • §4 rewrites scheduler state on live cards, bounded by stability IS NULL, so it can only touch cards that are currently being mistreated.
  • §6 moves logic into the database. The RPC must be deployed before (or with) the edge function that calls it.
  • The migrations are local-only so far — not yet applied to cloud.

…e, FSRS lapsed backfill, interleaving gate, store races (#549)

Six correctness defects across the shipped learning-engine work (#389 FSRS,
#393 interleaving, #394 leagues + store, #397 digest), none of which had test
coverage, plus the test runner four of them needed.

Stand up Vitest in mcp-server/ (no runner existed at all) and characterize
gradeCard, cardFromRow, computeInterleavingPool, eloExpected and eloJitter
against current behaviour before changing anything.

§1 A single missed weekly rollover wiped every student's tier. The previous
week was hardcoded as v_week - 7, so one skipped Monday tick left that join
matching nothing and COALESCE(..., 1) reset the whole tenant to Bronze, while
the genuinely-previous week kept final_rank NULL forever. The previous week is
now resolved from the data and every unfinalized week is finalized, each over
its own [w, w+7) XP window — carrying one window across a multi-week gap would
rank a stale cohort over unrelated activity.

§2 Promotion/relegation carried no information at the minimum cohort size:
promote_count = demote_count = 5 against a cold-start floor of 10 meant the
bands exactly tiled the cohort and 'stayed' was unreachable. Bands now scale
with active cohort size and always leave at least one 'stayed'. Zero-XP members
are excluded from that size and parked, so an inactive student can no longer
promote over an active one on a UUID tiebreak.

§3 The streak nudge was inverted for the LATAM market. isStreakAtRisk compared
against the UTC day while the send decision and idempotency key are
tenant-local, so at UTC-5 a student who practised that local afternoon was told
their streak was ending hours after securing it, and the students who actually
skipped got nothing. Comparison is now against the tenant-local day boundary,
computed calendar-first so it survives DST. award_xp's own day boundary is
deliberately unchanged — moving it would shift existing streaks — and the
residual is documented at the call site.

§4 The FSRS backfill skipped the hardest cards. Its predicate was
repetitions > 0, but the SM-2 code it replaced reset repetitions to 0 on a
lapse, so every card whose last rating was "Again" was excluded and then
silently downgraded to createEmptyCard(). Widened to include lapsed rows, with
difficulty derived from the retained ease column and state 3 (Relearning).

§5 The interleaving gate was advisory. lms_record_practice_attempt accepted
mode='mixed' with no gate and no kill-switch check, and its own description
told the model to call it directly, so the bypass was the documented path —
and the rows it wrote were the rows the gate later read back, letting a session
certify its own topics. Both tools now share one gate decision. Separately,
XP is awarded once per session rather than once per stored row, so a 3-topic
mixed session no longer pays 45 XP and burns 3 of the 10 daily slots against
15 XP for the same work done focused.

§6 spend-points double-spent coins and over-granted freezes via three
unsynchronized read-modify-writes against a derived balance. Replaced with a
single redeem_store_item() RPC holding SELECT ... FOR UPDATE on the profile
row, which also refuses at the 5-freeze ceiling instead of charging for a grant
that cannot land.

Verified: 467/467 root unit tests, 35 mcp-server tests, typecheck and build
clean, and seeded-DB assertions for §1/§2/§4/§5/§6 committed under
tests/sql/issue-549/ — including a real 5-way concurrency race proving the
store redemptions serialize.

Closes #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RGCEptT7pgWwRiMdB3TUCM
@guillermoscript guillermoscript added bug Something isn't working Sub-task mcp-server MCP server (mcp-server/) tools, widgets, auth ai-tutor AI tutor over MCP: practice loop, prompts, tutor widgets labels Jul 26, 2026
@guillermoscript guillermoscript self-assigned this Jul 26, 2026
@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 17:50
@guillermoscript
guillermoscript merged commit d6b7c2e into master Jul 26, 2026
2 checks passed
@guillermoscript
guillermoscript deleted the fix/learning-engine-correctness-549 branch July 26, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-tutor AI tutor over MCP: practice loop, prompts, tutor widgets bug Something isn't working mcp-server MCP server (mcp-server/) tools, widgets, auth Sub-task

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Learning engine correctness: leagues, streak nudge timezone, FSRS backfill, interleaving gate, store races

1 participant