feat(srs): switch ladder to +3/+7 and enforce rest‑day logic

This commit is contained in:
Prad Nukala
2026-08-31 10:41:45 -04:00
parent 7e46fceb1c
commit 38fbb18f32
12 changed files with 140 additions and 161 deletions
+23 -9
View File
@@ -26,10 +26,23 @@ Live at `https://srs-api.prdlk.workers.dev`.
## The ladder
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
A problem's first-ever log enters at `+2` regardless of result. Stage names
the NEXT review's interval. All dates are ET calendar strings anchored at
noon UTC (`src/srs.ts`) — never raw `Date` math.
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
first-ever log enters at `+3` regardless of result. Stage names the NEXT
review's interval, and the two rungs are the same numbers as the `work/3` and
`work/7` buckets `bun run pick` scaffolds re-solves into.
**Sunday is never booked.** Topics run MonFri, the gate is Saturday, and 3/7
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
Monday. Every scheduled date in the Worker — ladder reviews, levelled
overflow, deferred-Hard release dates — is minted by that one function, so the
rest day cannot be booked and then swallowed by the digest's Sunday branch.
Reviews may slip later, never earlier: pulling one back to Saturday would
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
campaign calendar to prove it.
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
raw `Date` math.
Blind drills draw only from topics **already learned**: scheduled in an
earlier week (the current week's optional pool is reserved for Saturday's
@@ -83,13 +96,14 @@ bunx wrangler d1 migrations apply srs --local
bun run dev # wrangler dev on :8787, local D1
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
bun test src # DST guard + date math
bun test src # DST guards, date math, the rest-day sweep
```
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
migration from the retired `.github/srs/srs.json` lives at
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
import-sourced attempts are wiped and re-inserted.
`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
The one-shot importer for the retired `.github/srs/srs.json` is gone — that
state no longer exists, and git history is its record.
## Deploy
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bun
/**
* One-shot migration: .github/srs/srs.json → D1.
*
* Run AFTER the catalog reconcile has filled `problems` (the import only
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
* attempts/topic counters). Re-runnable without duplicates: import-sourced
* attempts are wiped and re-inserted, everything else upserts.
*
* bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state)
* bun apps/api/scripts/import-srs.ts --remote # production D1
*
* Prints row counts; verify them against srs.json before deleting anything.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const API = join(ROOT, "api");
const REMOTE = process.argv.includes("--remote");
interface Attempt {
date: string;
kind: string;
result: string;
}
interface Problem {
issue: number;
topic: number;
difficulty: string;
set: string;
solved_on?: string;
stage: string;
next_review?: string;
defer_until?: string;
history: Attempt[];
}
interface State {
problems: Record<string, Problem>;
topics: Record<string, { misses: number; boost: boolean }>;
drill_pool_used: number[];
}
const state: State = JSON.parse(
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
);
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
let attempts = 0;
for (const [lc, p] of Object.entries(state.problems)) {
lines.push(
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
);
for (const a of p.history) {
lines.push(
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
);
attempts++;
}
}
for (const [topic, t] of Object.entries(state.topics)) {
lines.push(
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
);
}
for (const lc of state.drill_pool_used) {
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
}
const sqlPath = join(API, "migrations", ".import.sql");
await Bun.write(sqlPath, lines.join("\n") + "\n");
const flag = REMOTE ? "--remote" : "--local";
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
await $`rm ${sqlPath}`;
const counts =
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
.cwd(API)
.json();
const expected = {
json_problems: Object.keys(state.problems).length,
json_attempts: attempts,
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
json_topics: Object.keys(state.topics).length,
json_drills_used: state.drill_pool_used.length,
};
console.log("expected from srs.json:", JSON.stringify(expected));
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
+6 -5
View File
@@ -10,7 +10,7 @@
* so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum.
*/
import { addDays } from "./srs.ts";
import { workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/;
@@ -18,7 +18,7 @@ const TITLE_RE = /^LC (\d+) · (.+)$/;
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
const SETS = ["core", "optional", "deferred"] as const;
/** Deferred Hards enter the queue from this date, two per day. */
/** Deferred Hards enter the queue from this date, two per working day. */
const DEFER_FROM = "2026-09-28";
export interface ReconcileReport {
@@ -96,9 +96,10 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
continue;
}
const lc = Number(m[1]);
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
// applied only when the row is first created (SRS owns it afterwards).
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
// order — applied only when the row is first created (SRS owns it
// afterwards). Sunday is skipped like every other scheduled date.
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
if (set === "deferred") deferredSeen++;
statements.push(
db
+4 -2
View File
@@ -14,6 +14,7 @@
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
* green ramp. Every function renders on a zero-row DB.
*/
import { STAGES } from "./srs.ts";
import { Canvas, textWidth } from "./png.ts";
// D1Database comes from the generated worker-configuration.d.ts runtime types.
@@ -43,8 +44,6 @@ const PHASES = [
{ milestone: 5, name: "V — Decision Space" },
];
const STAGES = ["new", "+2", "+5", "+10", "retired"];
// ── svg helpers ──────────────────────────────────────────────────
/** Opening tag plus the rounded dark card every chart starts with. */
@@ -159,6 +158,9 @@ export async function progressChart(db: D1Database): Promise<string> {
// ── ladder: bar per stage ────────────────────────────────────────
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
// ladder can never leave a stale bar here: the slot width is derived from the
// rung count, only the bar width inside a slot is fixed.
export async function ladderChart(db: D1Database): Promise<string> {
const { results } = await db
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
+6 -6
View File
@@ -62,12 +62,12 @@ const DIFFICULTY: Record<string, { label: string; color: string }> = {
hard: { label: "Hard", color: RED },
};
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
// The SRS ladder in plain English — stage "+3" means the last clean look was
// three days back, so that is what the reader is told.
const LAST_SEEN: Record<string, string> = {
new: "first look",
"+2": "2 days ago",
"+5": "5 days ago",
"+10": "10 days ago",
"+3": "3 days ago",
"+7": "7 days ago",
retired: "retired",
};
@@ -302,7 +302,7 @@ function DigestEmail({ data }: { data: DigestData }) {
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
{data.progress}
{data.rest
? " · nothing is due today, and anything overdue waits for Monday."
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
@@ -403,7 +403,7 @@ function plainDigest(data: DigestData): string {
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
data.progress +
(data.rest
? " · nothing is due today, and anything overdue waits for Monday."
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
+1 -1
View File
@@ -5,7 +5,7 @@
* The gate half is a topic-blind quiz — one unsolved optional problem per
* topic scheduled this week (which is why daily drills only draw from
* earlier weeks). The recap half is generated data, deliberately NOT a task
* list: the week's solved problems are already scheduled by the +2 ladder,
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
* and re-assigning them on Saturday would be massed practice.
*
* Label is `review` (the retired Actions system owned `gate`). Creation is
+3 -3
View File
@@ -44,7 +44,7 @@ async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Pro
try {
await gh.closeIssue(
outcome.issue,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
);
} catch (err) {
console.warn(`close #${outcome.issue}: ${err}`);
@@ -96,7 +96,7 @@ async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Pro
}
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
return page(
result === "pass" ? "Logged ✅" : "Logged — back to +2",
result === "pass" ? "Logged ✅" : "Logged — back to +3",
outcomeLine(outcome),
);
}
@@ -232,7 +232,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise<R
const p = await getProblem(env.DB, lc);
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`);
else logged.push(`LC ${lc}: would enter the ladder at +2`);
else logged.push(`LC ${lc}: would enter the ladder at +3`);
continue;
}
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" });
+73 -18
View File
@@ -1,12 +1,19 @@
/**
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
* SRS domain: ET dates, the work week, the interval ladder, deterministic
* sampling, and the one write path for attempts.
*
* D1 is the single source of truth. The stage names the review a problem must
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
* a +2 review, a fail must be re-solved just as soon.
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
* scaffolded into, and as the review-issue windows in
* apps/cli/spaced-repetition.ts.
*
* Sunday is off, structurally: every scheduled date comes out of
* workingDay(), which never returns one. Nothing "falls on" the rest day and
* gets swallowed or carried — it is simply never booked there.
*
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
@@ -56,6 +63,37 @@ export function weekdayOf(date: string): number {
return atNoon(date).getUTCDay();
}
// ── the work week ────────────────────────────────────────────────
/**
* Sunday, the one day the campaign never schedules: topics run MonFri
* (data/schedule.json), the gate is Saturday, and the review windows below are
* picked so a solve comes back on a working day.
*/
const REST_DAY = 0;
export function isRestDay(date: string): boolean {
return weekdayOf(date) === REST_DAY;
}
/**
* The `offset`-th working day counting from `from` (0 = `from` itself), with
* Sundays skipped — the ONE place a scheduled date is minted.
*
* Work may slide later, never earlier: pulling a review back to Saturday would
* shorten the very interval it exists to test, so a Sunday landing becomes
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
*/
export function workingDay(from: string, offset = 0): string {
let cursor = isRestDay(from) ? addDays(from, 1) : from;
for (let i = 0; i < offset; i++) {
cursor = addDays(cursor, 1);
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
}
return cursor;
}
export function campaignDay(date: string): number {
return daysBetween(CAMPAIGN_START, date) + 1;
}
@@ -126,7 +164,21 @@ export function sample<T>(pool: T[], n: number, random: () => number): T[] {
// ── rows ─────────────────────────────────────────────────────────
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
/**
* Review windows in days. One vocabulary for the whole campaign: these are the
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
* windows the `Spaced Repetition — <date>` issues are built from.
*
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
* workingDay() slides to Monday.
*/
export const WINDOWS = [3, 7] as const;
export type Window = (typeof WINDOWS)[number];
export type Stage = "new" | `+${Window}` | "retired";
/** Chart / stats order, low to high. */
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
export type Result = "pass" | "fail";
export type Kind = "first" | "review" | "drill" | "gate";
@@ -142,8 +194,8 @@ export interface ProblemRow {
defer_until: string | null;
}
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
// ── queries ──────────────────────────────────────────────────────
@@ -170,12 +222,13 @@ export const REVIEW_CAP = 3;
/**
* Load-level an overloaded review queue: everything due beyond today's
* REVIEW_CAP is pushed to a concrete future date — at most REVIEW_CAP per
* day, oldest first — instead of piling up as "due today". Runs every digest
* morning, so a future day that grows past the cap (spill plus newly
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
* per day, oldest first — instead of piling up as "due today". Runs every
* digest morning, so a future day that grows past the cap (spill plus newly
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
* second pass moves nothing.
* second pass moves nothing. The spill starts tomorrow, or Monday when
* tomorrow is the rest day.
*/
export async function levelReviews(db: D1Database, date: string): Promise<number> {
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
@@ -184,7 +237,7 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
overflow.map((p, i) =>
db
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
.bind(addDays(date, 1 + Math.floor(i / REVIEW_CAP)), p.lc_number),
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
),
);
return overflow.length;
@@ -336,16 +389,18 @@ export async function logAttempt(
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
}
// Ladder move. First-ever logs enter at +2 for pass AND fail.
// Ladder move. First-ever logs enter at +3 for pass AND fail.
let stage: Stage;
if (first) {
stage = "+2";
stage = "+3";
} else if (opts.result === "pass") {
stage = NEXT_STAGE[p.stage] ?? "retired";
} else {
stage = "+2";
stage = "+3";
}
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
// day, and it takes Monday instead.
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
const writes = [
db.prepare(
+3 -1
View File
@@ -11,6 +11,7 @@ import {
campaignDay,
campaignWeek,
isoWeek,
STAGES,
streak,
} from "./srs.ts";
@@ -59,7 +60,8 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
const { results: ladderRows } = await db
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
.all<{ stage: string; n: number }>();
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
// Every rung is present even at zero: the page's bar list is this object.
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
for (const row of ladderRows) ladder[row.stage] = row.n;
// gates rows are keyed by ISO week (that's the digest/gate contract), but