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
+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(