Add SRS automation: scheduler, logger, weekly gates

This commit is contained in:
Prad Nukala
2026-08-24 17:21:47 -04:00
parent 4c7a366bfd
commit e5dbef9ee9
15 changed files with 2113 additions and 0 deletions
+300
View File
@@ -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 (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";