Files
leetcode/apps/api/src/srs.ts
T

640 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SRS domain: ET dates, the work week, the interval ladder, concept
* temperature, 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 (`+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.
*/
import scheduleJson from "../data/schedule.json";
// ── dates (America/New_York) ─────────────────────────────────────
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
export const CAMPAIGN_START = "2026-08-17";
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
dateStyle: "short",
});
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
hour: "numeric",
hourCycle: "h23",
});
/** ET calendar date of an instant. */
export function etDate(now: Date): string {
return ET_DATE.format(now);
}
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
export function etHour(now: Date): number {
return Number(ET_HOUR.format(now));
}
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
function atNoon(date: string): Date {
return new Date(`${date}T12:00:00Z`);
}
export function addDays(date: string, days: number): string {
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
}
export function daysBetween(from: string, to: string): number {
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
}
/** 0 = Sunday … 6 = Saturday. */
export function weekdayOf(date: string): number {
return atNoon(date).getUTCDay();
}
// ── the work week ────────────────────────────────────────────────
/**
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
* (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;
}
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
export function campaignWeek(date: string): number {
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
}
/** Monday of the date's campaign week. */
export function weekMonday(date: string): string {
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
}
export function isoWeek(date: string): number {
const d = atNoon(date);
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
}
export function prettyDate(date: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "long",
month: "long",
day: "numeric",
}).format(atNoon(date));
}
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
/**
* One calendar day of the plan. Most days teach topics (one, or two when the
* plan doubles up); the rest are named by what they assign instead: a fixed
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
* Absent dates are unplanned: the ladder still runs, the digest just has
* nothing new to say.
*/
export interface ScheduledDay {
/** Topic issues taught that day, in the order the digest presents them. */
topics?: number[];
/** Problems assigned by number instead of by topic. */
problems?: number[];
/**
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
* and its problems in solve order. The issue's sub-issues are the truth; this
* copy is what the digest and RESERVED read without calling GitHub, and the
* catalog reconcile reports any drift between the two.
*/
review?: { issue: number; title: string; problems: number[] };
/**
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
* list skips that week's checkpoint — no issue is created.
*/
gate?: number[];
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
sweep?: boolean;
}
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
/**
* The last day the plan books anything — the campaign ends there. Derived,
* not declared: booking a later day (the review phase ran the plan past its
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
* the heatmap charts with it, and nothing can disagree about when it ends.
*/
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
/** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined {
for (const [date, day] of Object.entries(SCHEDULE)) {
if (day.topics?.includes(topic)) return date;
}
return undefined;
}
/** Week in which a topic was (or will be) taught. */
export function topicWeek(topic: number): number | undefined {
const date = topicDate(topic);
return date === undefined ? undefined : campaignWeek(date);
}
/**
* Every problem the plan names outright — a gate's blind set, a set day's
* list, or a review day's. The plan owns these: the samplers (daily drills,
* a sampled gate) never draw them, so a gate problem stays unseen until its
* Saturday and a set or review day's problems wait for their day.
*/
export const RESERVED: ReadonlySet<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [
...(day.gate ?? []),
...(day.problems ?? []),
...(day.review?.problems ?? []),
]),
);
// ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
export function rng(seed: string): () => number {
let h = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return () => {
h = Math.imul(h ^ (h >>> 15), h | 1);
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
};
}
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
const copy = [...pool];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
}
return copy.slice(0, n);
}
// ── rows ─────────────────────────────────────────────────────────
/**
* 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";
/**
* Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and
* stored verbatim in `problems`. Listed low to high / most to least required,
* which is the order every chart, table and digest section prints them in.
*/
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export const SETS = ["core", "optional", "deferred"] as const;
export type SetLabel = (typeof SETS)[number];
/**
* How warm a concept is: the age of a topic's most recent attempt, bucketed by
* the ladder's own windows so the two cannot drift. Touched inside the first
* window it is `hot`, inside the second `fresh`, inside one more full second
* window `fading`; older than that — or never attempted — it is `cold`.
*
* A read-side label only: nothing is scheduled off it. It answers the question
* the ladder cannot, because the ladder tracks problems and a topic can go
* quiet for a fortnight while not one of its problems comes due.
*/
export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const;
export type Temperature = (typeof TEMPERATURES)[number];
/** Warmest band first, with the age it tolerates. Past the last one is cold. */
const TEMPERATURE_MAX_AGE: [Temperature, number][] = [
["hot", WINDOWS[0]],
["fresh", WINDOWS[1]],
["fading", WINDOWS[1] * 2],
];
/** Days since a topic's last attempt → its band; null (never) is cold. */
export function temperatureOf(age: number | null): Temperature {
if (age === null) return "cold";
for (const [temperature, max] of TEMPERATURE_MAX_AGE) {
if (age <= max) return temperature;
}
return "cold";
}
export interface ProblemRow {
lc_number: number;
issue: number;
topic_issue: number;
title: string;
difficulty: string;
set_label: string;
stage: Stage;
next_review: string | null;
defer_until: string | null;
}
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
// ── queries ──────────────────────────────────────────────────────
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
}
/**
* Catalog rows for a list of numbers the plan names, in the plan's order,
* plus the numbers the catalog does not know — a planned problem without an
* issue can never be logged, so callers decide whether that is fatal.
*/
export async function lookupProblems(
db: D1Database,
lcs: number[],
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
if (lcs.length === 0) return { rows: [], missing: [] };
const { results } = await db
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
.bind(...lcs)
.all<ProblemRow>();
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
const rows: ProblemRow[] = [];
const missing: number[] = [];
for (const lc of lcs) {
const row = byNumber.get(lc);
if (row) rows.push(row);
else missing.push(lc);
}
return { rows, missing };
}
/**
* What "due" means, in one place: not retired, scheduled on/before ?1, and
* past its deferral if it has one. Every caller that asks "what is open on
* this day" — the digest, the review issue, the docs queue chart — shares
* this clause so they cannot answer differently.
*/
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)`;
/**
* What an open miss means, in one place: a problem `p` whose most recent
* attempt (highest id) failed and that has not retired. The sweep day works
* these and the stats' "attempting" count is how many there are, so the two
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
*/
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
AND a.result = 'fail' AND p.stage != 'retired'`;
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
.bind(date)
.all<ProblemRow>();
return results;
}
/** One problem the ladder wants re-solved today. */
export interface DueRow {
lc: number;
issue: number;
title: string;
difficulty: string;
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
stage: Stage;
bucket: number;
/** Scheduled day; earlier than `date` when a day was missed. */
due: string;
/** Days late (0 = due today), so a renderer can say so. */
late: number;
/** Last logged attempt, and how long ago — the "last seen" line. */
last_seen: string | null;
days_since: number | null;
}
/**
* Everything the ladder wants re-solved on `date`, oldest first.
*
* The read side of "which problems are open today": the same rows the digest
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
* Nothing here writes, so it is safe to call repeatedly, and there is no
* second schedule to drift — D1's ladder is the schedule.
*/
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
const { results } = await db
.prepare(
`SELECT lc_number, issue, title, difficulty, stage, next_review,
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
)
.bind(date)
.all<{
lc_number: number;
issue: number;
title: string;
difficulty: string;
stage: Stage;
next_review: string;
last_seen: string | null;
}>();
return {
date,
due: results.map((r) => ({
lc: r.lc_number,
issue: r.issue,
title: r.title,
difficulty: r.difficulty,
stage: r.stage,
// "+3" -> 3: the rung's number IS its work/ bucket.
bucket: Number(r.stage.slice(1)),
due: r.next_review,
late: daysBetween(r.next_review, date),
last_seen: r.last_seen,
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
})),
};
}
/** Reviews surfaced per day; the digest levels everything past this forward. */
export const REVIEW_CAP = 3;
/**
* Load-level an overloaded review queue: everything due beyond today's
* 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. 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);
if (overflow.length === 0) return 0;
await db.batch(
overflow.map((p, i) =>
db
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
),
);
return overflow.length;
}
/**
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
* scheduled in an earlier week (the current week's optional pool is reserved
* for Saturday's gate) AND showing learning evidence: at least one of the
* topic's core problems has entered the ladder. A skipped learning day never
* feeds drills just because its calendar week lapsed. Problems the plan names
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
* Boosted topics contribute up to 2 extra.
*/
export async function pickDrills(
db: D1Database,
date: string,
budget: number,
): Promise<ProblemRow[]> {
if (budget <= 0) return [];
const week = campaignWeek(date);
const { results } = await db
.prepare(
`SELECT p.*, t.boost AS boost FROM problems p
JOIN topics t ON t.issue = p.topic_issue
WHERE p.set_label = 'optional' AND p.stage = 'new'
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
AND EXISTS (SELECT 1 FROM problems c
WHERE c.topic_issue = p.topic_issue
AND c.set_label = 'core' AND c.stage != 'new')
ORDER BY p.lc_number`,
)
.all<ProblemRow & { boost: number }>();
const pool = results.filter((p) => {
if (RESERVED.has(p.lc_number)) return false;
const w = topicWeek(p.topic_issue);
return w !== undefined && w < week;
});
const boosted = pool.filter((p) => p.boost === 1);
const regular = pool.filter((p) => p.boost !== 1);
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
const regularPicks = sample(
regular,
Math.min(2, Math.max(0, budget - boostPicks.length)),
rng(`drill-${date}`),
);
return [...boostPicks, ...regularPicks].slice(0, budget);
}
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
export async function streak(db: D1Database, today: string): Promise<number> {
const { results } = await db
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
.all<{ date: string }>();
const days = new Set(results.map((r) => r.date));
let cursor = days.has(today) ? today : addDays(today, -1);
let n = 0;
while (days.has(cursor)) {
n++;
cursor = addDays(cursor, -1);
}
return n;
}
// ── the one write path ───────────────────────────────────────────
export interface LogOutcome {
lc: number;
title: string;
kind: Kind;
result: Result;
stage: Stage;
next_review: string | null;
/** First-ever attempt — caller closes the sub-issue on pass. */
first: boolean;
issue: number;
/** Email one-tap replay: nothing changed. */
duplicate: boolean;
error?: string;
}
/**
* Record an attempt and move the ladder. Ladder semantics live here and only
* here — email one-taps, webhook /done lines, gate scoring, and solutions
* landing in work/ all converge.
*
* Email idempotency comes from the partial unique index on
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
* nothing and must not touch the ladder. Webhook corrections (pass then fail
* on the same day) remain legal — every webhook attempt appends.
*
* Commit idempotency needs no index either. A commit names the rung the file
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
* `work/7` settles `+7` — and the stage check below writes only when the
* ladder is actually standing on that rung. So re-pushing a solution, or the
* reconciler re-sending its whole implemented set on every push, moves
* nothing: the rung it names has already been left behind.
*/
export async function logAttempt(
db: D1Database,
opts: {
lc: number;
date: string;
result: Result;
source: "email" | "webhook" | "commit";
/** Commit only: the stage this file settles. Default `new` = first solve. */
rung?: Stage;
gate?: boolean;
},
): Promise<LogOutcome> {
const p = await getProblem(db, opts.lc);
const nothing: LogOutcome = {
lc: opts.lc,
title: "",
kind: "review",
result: opts.result,
stage: "new",
next_review: null,
first: false,
issue: 0,
duplicate: false,
};
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
// A commit reports a file, not an event: it may only settle the rung that
// file's bucket IS. Any other stage means the push is old news (the ladder
// already moved past it) or premature — no write either way, and a retired
// problem can never be woken. Fresh reviews otherwise come from the digest
// tap, a /done comment, or a gate.
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
return {
...nothing,
title: p.title,
stage: p.stage,
next_review: p.next_review,
issue: p.issue,
duplicate: true,
};
}
if (p.stage === "retired") {
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
}
const attempted = await db
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
.bind(opts.lc)
.first();
const first = !attempted && p.stage === "new";
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
const inserted = await db
.prepare(
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`,
)
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
.run();
if (opts.source === "email" && inserted.meta.changes === 0) {
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 +3 for pass AND fail.
let stage: Stage;
if (first) {
stage = "+3";
} else if (opts.result === "pass") {
stage = NEXT_STAGE[p.stage] ?? "retired";
} else {
stage = "+3";
}
// 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(
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
).bind(stage, next, opts.lc),
];
if (first && kind === "drill") {
writes.push(
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
);
if (opts.result === "fail") {
writes.push(
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
);
}
}
await db.batch(writes);
return {
lc: opts.lc,
title: p.title,
kind,
result: opts.result,
stage,
next_review: next,
first,
issue: p.issue,
duplicate: false,
};
}