feat(api): add concept temperature bands and expose in stats

This commit is contained in:
Prad Nukala
2026-08-31 18:56:40 -04:00
parent 64173857e7
commit 7bae7b2920
4 changed files with 224 additions and 37 deletions
+1 -4
View File
@@ -10,14 +10,11 @@
* so relabelling a problem is enough to move it and titles stay readable. * so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum. * The `problem` label is what marks a sub-issue as curriculum.
*/ */
import { workingDay } from "./srs.ts"; import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/; const TITLE_RE = /^LC (\d+) · (.+)$/;
const DIFFICULTIES = ["easy", "medium", "hard"] as const;
const SETS = ["core", "optional", "deferred"] as const;
/** Deferred Hards enter the queue from this date, two per working day. */ /** Deferred Hards enter the queue from this date, two per working day. */
const DEFER_FROM = "2026-09-28"; const DEFER_FROM = "2026-09-28";
+27
View File
@@ -19,6 +19,8 @@ import {
isoWeek, isoWeek,
rng, rng,
sample, sample,
TEMPERATURES,
temperatureOf,
weekdayOf, weekdayOf,
workingDay, workingDay,
} from "./srs.ts"; } from "./srs.ts";
@@ -132,6 +134,31 @@ describe("the ladder", () => {
}); });
}); });
describe("concept temperature", () => {
test("the bands are the ladder's own windows", () => {
// Boundaries are inclusive, and the bands tile the whole age axis with no
// gap: whatever the windows become, every age still lands in exactly one.
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
expect(temperatureOf(0)).toBe("hot");
expect(temperatureOf(WINDOWS[0])).toBe("hot");
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
});
test("never attempted is cold, not unknown", () => {
expect(temperatureOf(null)).toBe("cold");
});
test("every age of the campaign has exactly one band", () => {
for (let age = 0; age <= CALENDAR.length; age++) {
expect(TEMPERATURES).toContain(temperatureOf(age));
}
});
});
describe("deterministic sampling", () => { describe("deterministic sampling", () => {
test("same seed, same picks", () => { test("same seed, same picks", () => {
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+50 -5
View File
@@ -1,6 +1,6 @@
/** /**
* SRS domain: ET dates, the work week, the interval ladder, deterministic * SRS domain: ET dates, the work week, the interval ladder, concept
* sampling, and the one write path for attempts. * 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 * 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 * pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
@@ -128,14 +128,20 @@ export function prettyDate(date: string): string {
export const SCHEDULE: Record<string, number> = scheduleJson; export const SCHEDULE: Record<string, number> = scheduleJson;
/** Week in which a topic was (or will be) taught. */ /** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicWeek(topic: number): number | undefined { export function topicDate(topic: number): string | undefined {
for (const [date, t] of Object.entries(SCHEDULE)) { for (const [date, t] of Object.entries(SCHEDULE)) {
if (t === topic) return campaignWeek(date); if (t === topic) return date;
} }
return undefined; 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 ─────────────────────────────────────── // ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */ /** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
@@ -182,6 +188,45 @@ export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
export type Result = "pass" | "fail"; export type Result = "pass" | "fail";
export type Kind = "first" | "review" | "drill" | "gate"; 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 { export interface ProblemRow {
lc_number: number; lc_number: number;
issue: number; issue: number;
+143 -25
View File
@@ -1,19 +1,29 @@
/** /**
* GET /api/stats — the one JSON document behind the docs progress page. * GET /api/stats — the one JSON document behind the docs progress page and the
* Read-only aggregation over D1; shape is the page's contract, change both * `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
* together. A problem counts as "done" once it entered the ladder * is both readers' contract, so change all three together. A problem counts as
* (stage != 'new'). * "done" once it entered the ladder (stage != 'new').
*
* The temperature block is the one figure here that is not a count of rows:
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
* concepts have gone cold" has exactly one definition and the dashboard is
* free to colour it however it likes.
*/ */
import { import {
CAMPAIGN_DAYS, CAMPAIGN_DAYS,
CAMPAIGN_START, CAMPAIGN_START,
DIFFICULTIES,
DUE_WHERE, DUE_WHERE,
addDays, addDays,
campaignDay, campaignDay,
campaignWeek, campaignWeek,
daysBetween,
isoWeek, isoWeek,
STAGES, STAGES,
streak, streak,
temperatureOf,
topicDate,
type Temperature,
} from "./srs.ts"; } from "./srs.ts";
const PHASE_NAMES: Record<number, string> = { const PHASE_NAMES: Record<number, string> = {
@@ -24,6 +34,91 @@ const PHASE_NAMES: Record<number, string> = {
5: "V — Decision Space", 5: "V — Decision Space",
}; };
/** One concept: catalog coverage plus how long since it was last exercised. */
interface TopicStat {
issue: number;
name: string;
milestone: number | null;
/** Campaign week the schedule teaches it in; null when unscheduled. */
week: number | null;
/** False while the schedule has not reached it — temperature is moot. */
taught: boolean;
total: number;
done: number;
retired: number;
due: number;
/** ET date of the most recent attempt on any of its problems. */
last: string | null;
/** Days since `last`; null when never attempted. */
age: number | null;
temperature: Temperature;
}
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
// Three shapes, three statements: batch() carries one row type, and casting
// its results apart would cost more than the round trips it saves.
const { results: coverage } = await db
.prepare(
`SELECT t.issue AS issue, t.name AS name, t.milestone AS milestone,
COUNT(p.lc_number) AS total,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
SUM(CASE WHEN p.stage = 'retired' THEN 1 ELSE 0 END) AS retired
FROM topics t LEFT JOIN problems p ON p.topic_issue = t.issue
GROUP BY t.issue
ORDER BY t.issue`,
)
.all<{
issue: number;
name: string;
milestone: number | null;
total: number;
done: number | null;
retired: number | null;
}>();
const { results: activity } = await db
.prepare(
`SELECT p.topic_issue AS issue, MAX(a.date) AS last
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
GROUP BY p.topic_issue`,
)
.all<{ issue: number; last: string | null }>();
const lastByTopic = new Map<number, string>();
for (const row of activity) {
if (row.last) lastByTopic.set(row.issue, row.last);
}
const { results: dueRows } = await db
.prepare(
`SELECT topic_issue AS issue, COUNT(*) AS n
FROM problems WHERE ${DUE_WHERE} GROUP BY topic_issue`,
)
.bind(today)
.all<{ issue: number; n: number }>();
const dueByTopic = new Map<number, number>();
for (const row of dueRows) dueByTopic.set(row.issue, row.n);
return coverage.map((row) => {
const taughtOn = topicDate(row.issue);
const last = lastByTopic.get(row.issue) ?? null;
const age = last === null ? null : daysBetween(last, today);
return {
issue: row.issue,
name: row.name,
milestone: row.milestone,
week: taughtOn === undefined ? null : campaignWeek(taughtOn),
taught: taughtOn !== undefined && taughtOn <= today,
total: row.total,
done: row.done ?? 0,
retired: row.retired ?? 0,
due: dueByTopic.get(row.issue) ?? 0,
last,
age,
temperature: temperatureOf(age),
};
});
}
export async function buildStats(db: D1Database, today: string): Promise<object> { export async function buildStats(db: D1Database, today: string): Promise<object> {
const { results: phaseRows } = await db const { results: phaseRows } = await db
.prepare( .prepare(
@@ -65,6 +160,21 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0])); const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
for (const row of ladderRows) ladder[row.stage] = row.n; for (const row of ladderRows) ladder[row.stage] = row.n;
// Same shape as `ladder`: every band present, zeros included, in DIFFICULTIES
// order so a reader can index it without sorting.
const { results: diffRows } = await db
.prepare(
`SELECT difficulty,
COUNT(*) AS total,
SUM(CASE WHEN stage != 'new' THEN 1 ELSE 0 END) AS done
FROM problems GROUP BY difficulty`,
)
.all<{ difficulty: string; total: number; done: number }>();
const difficulty = DIFFICULTIES.map((name) => {
const row = diffRows.find((r) => r.difficulty === name);
return { difficulty: name, total: row?.total ?? 0, done: row?.done ?? 0 };
});
// gates rows are keyed by ISO week (that's the digest/gate contract), but // gates rows are keyed by ISO week (that's the digest/gate contract), but
// the page speaks campaign weeks. The campaign never crosses a year // the page speaks campaign weeks. The campaign never crosses a year
// boundary, so a plain offset converts safely. // boundary, so a plain offset converts safely.
@@ -74,39 +184,47 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>(); .all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset })); const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
// Review-queue depth for the next 14 days: everything due by that day. // Review-queue depth for the next 14 days: everything due by that day. One
const queue: { date: string; due: number }[] = []; // statement per day, but DUE_WHERE stays the only definition of "due" — the
for (let i = 0; i < 14; i++) { // batch is a single round trip, not fourteen.
const date = addDays(today, i); const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
const row = await db const queueRows = await db.batch<{ n: number }>(
.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`) dates.map((date) =>
.bind(date) db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
.first<{ n: number }>(); ),
queue.push({ date, due: row?.n ?? 0 }); );
} const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
const recent: { date: string; attempts: number; passes: number }[] = []; // Attempts per campaign day, every day present. This is the heatmap's data
for (let i = 6; i >= 0; i--) { // model — the docs page reads the tail of it for "last 7 days" rather than
const date = addDays(today, -i); // asking a second question that could answer differently.
const row = await db const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
const { results: heatRows } = await db
.prepare( .prepare(
`SELECT COUNT(*) AS attempts, `SELECT date,
COUNT(*) AS attempts,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date = ?`, FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
) )
.bind(date) .bind(CAMPAIGN_START, end)
.first<{ attempts: number; passes: number | null }>(); .all<{ date: string; attempts: number; passes: number | null }>();
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 }); const byDate = new Map(heatRows.map((r) => [r.date, r]));
} const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => {
const date = addDays(CAMPAIGN_START, i);
const row = byDate.get(date);
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
});
return { return {
generated: new Date().toISOString(), generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS }, campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()], phases: [...phases.values()],
ladder, ladder,
difficulty,
topics: await buildTopics(db, today),
gates, gates,
streak: await streak(db, today), streak: await streak(db, today),
queue, queue,
recent, heat,
}; };
} }