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

552 lines
20 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) ─────────────────────────────────────
export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
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 023 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 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;
}
/** 1-based campaign week (MonSun), 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) ───
export const SCHEDULE: Record<string, number> = scheduleJson;
/** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined {
for (const [date, t] of Object.entries(SCHEDULE)) {
if (t === 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);
}
// ── 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, FisherYates 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>();
}
/**
* 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)`;
/** 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. 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) => {
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,
};
}