/** * 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; rate?: number; } export interface State { problems: Record; topics: Record; 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 { return JSON.parse(await Bun.file(STATE_PATH).text()); } export async function saveState(state: State): Promise { 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> { 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 (Mon–Sun), 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 = { "+2": 2, "+5": 5, "+10": 10 }; const NEXT_STAGE: Record = { 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, Fisher–Yates order driven by the seeded PRNG. */ export function sample(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; problems: Map; byIssue: Map; } 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 { const topics = new Map(); 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(); const byIssue = new Map(); 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";