Files
leetcode/scripts/srs.ts
T

301 lines
9.8 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.
/**
* Domain library for the spaced-repetition system (SRS).
*
* State model — `.github/srs/srs.json` is the single source of truth; the
* GitHub Project fields are a best-effort mirror (see srs-project.ts).
*
* The stage names the review a problem must pass NEXT: a problem at `+2` has
* a review due 2 days after its last clean solve. Passing advances
* new → +2 → +5 → +10 → retired; any failure resets to +2. `new` is reserved
* for deferred Hards that have never been solved — `defer_until` keeps them
* out of the queue until Sep 28.
*
* All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires
* at 10:00 UTC = 6 AM ET, and the campaign is lived in ET.
*/
import { join } from "node:path";
import type { GitHub } from "./github.ts";
const ROOT = join(import.meta.dir, "..");
// ── state ────────────────────────────────────────────────────────
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
export type Result = "pass" | "fail";
export interface Attempt {
date: string;
/** first = learning-day solve, drill = blind drill, gate = gate problem. */
kind: "first" | "review" | "drill" | "gate";
result: Result;
}
export interface ProblemState {
issue: number;
topic: number;
difficulty: string;
set: string;
solved_on?: string;
stage: Stage;
next_review?: string;
/** Deferred Hards stay out of the due queue until this date. */
defer_until?: string;
history: Attempt[];
}
export interface TopicState {
/** Blind-drill recognition misses since the last boost reset. */
misses: number;
/** Set at gate close; scheduler injects extra drills; next gate clears it. */
boost: boolean;
}
export interface GateState {
week: number;
issue: number;
date: string;
problems: number[];
/** Problems that fell back to the core set (optional pool exhausted). */
fallbacks: number[];
results: Record<string, Result>;
rate?: number;
}
export interface State {
problems: Record<string, ProblemState>;
topics: Record<string, TopicState>;
drill_pool_used: number[];
gates: GateState[];
}
export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json");
export const SCHEDULE_PATH =
process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json");
export async function loadState(): Promise<State> {
return JSON.parse(await Bun.file(STATE_PATH).text());
}
export async function saveState(state: State): Promise<void> {
await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
}
/** Day (YYYY-MM-DD) → topic issue number. Human-edited when life happens. */
export async function loadSchedule(): Promise<Record<string, number>> {
return JSON.parse(await Bun.file(SCHEDULE_PATH).text());
}
// ── dates (America/New_York) ─────────────────────────────────────
/** Campaign day 1 — Monday of Phase I week 1. Day 56 = Oct 11. */
export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */
export function todayET(): string {
if (process.env.SRS_TODAY) return process.env.SRS_TODAY;
return new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
dateStyle: "short",
}).format(new Date());
}
/** 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();
}
/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */
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;
}
export function isoWeek(date: string): number {
const d = atNoon(date);
// ISO 8601: week containing the year's first Thursday is week 1.
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 interval ladder ──────────────────────────────────────────
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" };
/** Advance on a pass; returns false when the problem was already retired. */
export function pass(p: ProblemState, today: string): boolean {
const next = NEXT_STAGE[p.stage];
if (!next) return false;
p.stage = next;
p.solved_on = today;
if (next === "retired") {
delete p.next_review;
} else {
p.next_review = addDays(today, INTERVAL[next]!);
}
return true;
}
/** Reset on a failure: back to the +2 stage, due again in 2 days. */
export function fail(p: ProblemState, today: string): void {
p.stage = "+2";
p.next_review = addDays(today, 2);
}
/** Reviews due today or earlier, oldest first — the overflow carry order. */
export function dueReviews(state: State, today: string): [string, ProblemState][] {
return Object.entries(state.problems)
.filter(
([, p]) =>
p.stage !== "retired" &&
p.next_review !== undefined &&
p.next_review <= today &&
(p.defer_until === undefined || p.defer_until <= today),
)
.sort(([a, pa], [b, pb]) =>
pa.next_review === pb.next_review
? Number(a) - Number(b)
: pa.next_review! < pb.next_review!
? -1
: 1,
);
}
// ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: a 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;
};
}
/** Take 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);
}
// ── the live catalog (topics + problem sub-issues) ───────────────
export interface CatalogProblem {
lc: number;
issue: number;
name: string;
difficulty: "Easy" | "Medium" | "Hard";
set: "core" | "optional" | "deferred";
topic: number;
open: boolean;
url: string;
}
export interface CatalogTopic {
issue: number;
/** e.g. "Topic 03 — Two Pointers". */
title: string;
/** Bare name, e.g. "Two Pointers". */
name: string;
milestone: number | undefined;
}
export interface Catalog {
topics: Map<number, CatalogTopic>;
problems: Map<number, CatalogProblem>;
byIssue: Map<number, CatalogProblem>;
}
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
/**
* Walk every `topic` issue and its sub-issues into one lookup structure.
* The sub-issue linkage is authoritative: every problem hangs off exactly
* one topic (verified against the full issue inventory).
*/
export async function fetchCatalog(gh: GitHub): Promise<Catalog> {
const topics = new Map<number, CatalogTopic>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
const t = raw as {
number: number;
title: string;
pull_request?: unknown;
milestone?: { number: number } | null;
};
if (t.pull_request) continue;
const name = t.title.replace(/^Topic \d+ — /, "");
topics.set(t.number, {
issue: t.number,
title: t.title,
name,
milestone: t.milestone?.number,
});
}
const problems = new Map<number, CatalogProblem>();
const byIssue = new Map<number, CatalogProblem>();
for (const topic of topics.keys()) {
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
const s = raw as { number: number; title: string; state: string; body?: string };
const m = s.title.match(TITLE_RE);
if (!m) continue; // non-curriculum sub-issue
const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0];
const p: CatalogProblem = {
lc: Number(m[1]),
issue: s.number,
name: m[2]!,
difficulty: m[3] as CatalogProblem["difficulty"],
set: m[4] as CatalogProblem["set"],
topic,
open: s.state === "open",
url: url ?? "",
};
problems.set(p.lc, p);
byIssue.set(p.issue, p);
}
}
return { topics, problems, byIssue };
}
// ── shared constants ─────────────────────────────────────────────
export const TODAY_TITLE = "📋 Today";