mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
Add SRS automation: scheduler, logger, weekly gates
This commit is contained in:
+300
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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 (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<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, 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);
|
||||
}
|
||||
|
||||
// ── 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";
|
||||
Reference in New Issue
Block a user