Skip to content

Commit d4940bc

Browse files
TMHSDigitalclaude
andcommitted
reverify: size each batch so no entry passes 180 days
Batches were a fixed tenth of the catalog (~42), which cannot absorb the 146 entries verified on 2026-08-24/25: by spring 2027 most of the catalog would read as aging. pickBatch now takes the smallest steady size that re-reads every live entry before its check turns 180 days old, looking ahead to each coming batch, so a bulge is spread over the months before it expires. A test runs the catalog's real dates through 24 monthly batches: nothing passes 180 days, and the steady state is about a fifth of the catalog a month. ROADMAP, CONTRIBUTING and the issue text say so. Closes #182 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 1374f35 commit d4940bc

5 files changed

Lines changed: 100 additions & 22 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ To add a starter stack (one pick per need for a kind of game), follow [`stacks/R
8080
- [ ] Tool entries distinguish **software license** from **exported asset** ownership
8181

8282
Verification ages. On the first of each month a workflow opens a "Re-verify batch" issue
83-
listing the live entries whose licence was read longest ago (over 150 days, about a tenth
84-
of the catalog). Each item is a self-contained re-read: open the source, confirm the
83+
listing the live entries whose licence was read longest ago: as many as keep every entry
84+
inside the 180-day fresh window until the next batch, about a fifth of the catalog a
85+
month. Each item is a self-contained re-read: open the source, confirm the
8586
terms, bump `verified` and add a dated Evidence quote, or set `needs-review` and say what
8687
changed. Those issues carry `good first issue`; pick one if you want a first contribution
8788
with no code.

‎ROADMAP.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ missing entry, so freshness and accuracy come before volume.
3636
(`feed.xml`) and one of re-verified licences (`reverified.xml`); see [docs/data.md](docs/data.md).
3737
- Monthly dataset releases tagged `vYYYY.MM.DD`, with the version written into `CITATION.cff`.
3838
- Re-verification in regular batches: since 2026-10 the `reverify.yml` workflow opens a
39-
monthly checklist of the entries checked longest ago (over 150 days, about a tenth of the
40-
catalog each month), so the July 2026 verifications do not all expire together in mid-2027.
39+
monthly checklist of the entries checked longest ago, sized so none passes 180 days before
40+
the next batch (about a fifth of the catalog a month, more while the July-September 2026
41+
verifications come due); a 24-month simulation in `site/lib/lib.test.mjs` holds it to that.
4142
- `camera_perspective` and `grid_dimensions` on 2D entries.

‎site/lib/lib.test.mjs‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -980,11 +980,46 @@ import { batchIssueMarkdown, pickBatch } from "./reverify.mjs";
980980
eq("batch: a fresh entry is left alone", picked.some((e) => e.id === "fresh"), false);
981981
eq("batch: deprecated entries are never picked", picked.some((e) => e.id === "gone"), false);
982982
eq("batch: 150 days is not yet due", picked.some((e) => e.id === "edge"), false);
983-
eq("batch: size defaults to a tenth of the live catalog, at least one", pickBatch(pool, { now: rvNow }).length, 1);
983+
eq("batch: by default, everything that would turn stale before the next batch", pickBatch(pool, { now: rvNow }).map((e) => e.id).join(","), "undated,june,due,edge");
984984
eq("batch: nothing due means an empty batch", pickBatch(pool, { now: Date.parse("2026-08-01T00:00:00Z"), size: 10 }).map((e) => e.id).join(","), "undated");
985985
eq("batch: explicit size caps the list", pickBatch(pool, { now: rvNow, size: 2 }).map((e) => e.id).join(","), "undated,june");
986-
const big = Array.from({ length: 100 }, (_, i) => rv(`e${String(i).padStart(3, "0")}`, "2026-01-01"));
987-
eq("batch: a tenth of 100 live entries is 10", pickBatch(big, { now: rvNow }).length, 10);
986+
// 100 entries read the same day, 109 days ago: they must all be re-read
987+
// within two more batches, so the load is spread over the three.
988+
const bulge = Array.from({ length: 100 }, (_, i) => rv(`e${String(i).padStart(3, "0")}`, "2026-07-15"));
989+
eq("batch: a bulge is spread over the batches before it expires", pickBatch(bulge, { now: rvNow }).length, 34);
990+
eq("batch: a bulge is picked oldest first even before it is due", pickBatch(bulge, { now: rvNow })[0].age, 109);
991+
992+
// The catalog's own verified dates, run through 24 monthly batches in
993+
// which every picked entry is re-read on the day: nothing may pass the
994+
// fresh window, and no batch may need more than the size it was given.
995+
const { listEntryFiles } = await import("./entry-files.mjs");
996+
const { parseFrontmatter } = await import("./frontmatter.mjs");
997+
const { VERIFIED_FRESH_DAYS } = await import("./shared.mjs");
998+
const catalogDir = new URL("../../catalog/", import.meta.url);
999+
const { fileURLToPath } = await import("node:url");
1000+
let sim = listEntryFiles(fileURLToPath(catalogDir))
1001+
.map((f) => parseFrontmatter(nodeFs.readFileSync(f, "utf8"))?.meta)
1002+
.filter((m) => m?.id)
1003+
.map((m) => ({ id: String(m.id), verified: m.verified ? String(m.verified) : null, status: String(m.status || "needs-review") }));
1004+
const first = new Date();
1005+
let t = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1, 8);
1006+
let worstAge = 0;
1007+
let biggest = 0;
1008+
for (let m = 0; m < 24; m += 1) {
1009+
for (const e of sim) {
1010+
if (e.status === "deprecated" || !e.verified) continue;
1011+
worstAge = Math.max(worstAge, Math.floor((t - Date.parse(`${e.verified}T00:00:00Z`)) / 86400000));
1012+
}
1013+
const picked = new Set(pickBatch(sim, { now: t }).map((e) => e.id));
1014+
biggest = Math.max(biggest, picked.size);
1015+
const day = new Date(t).toISOString().slice(0, 10);
1016+
sim = sim.map((e) => (picked.has(e.id) ? { ...e, verified: day } : e));
1017+
const d = new Date(t);
1018+
t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1, 8);
1019+
}
1020+
eq("batch simulation: runs on the real catalog", sim.length > 100, true);
1021+
eq("batch simulation: no live entry passes the fresh window in 24 months", worstAge <= VERIFIED_FRESH_DAYS, true);
1022+
eq("batch simulation: a batch stays under a third of the catalog", biggest <= Math.ceil(sim.length / 3), true);
9881023
const md = batchIssueMarkdown({ batch: pickBatch(pool, { now: rvNow, size: 2 }), repo: "https://github.com/o/r", date: "2026-11-01", total: 6 });
9891024
has("batch issue links the entry file", md, "[`june`](https://github.com/o/r/blob/main/catalog/2d/june.md)");
9901025
has("batch issue says what was recorded", md, "recorded as `CC0`, needs-review, verified 2026-06-01 (153 days ago)");

‎site/lib/reverify.mjs‎

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import { VERIFIED_FRESH_DAYS } from "./shared.mjs";
1212
* fresh to aging.
1313
*/
1414
export const DUE_DAYS = VERIFIED_FRESH_DAYS - 30;
15-
/** Months the whole catalog should take to cycle through the batches. */
16-
export const CYCLE_MONTHS = 10;
1715

1816
/** Days since `verified` at `now`, or null when the date is unusable. */
1917
function ageDays(verified, now) {
@@ -22,27 +20,70 @@ function ageDays(verified, now) {
2220
}
2321

2422
/**
25-
* The entries due for a re-read: active or needs-review, last checked more
26-
* than `dueDays` ago or never, oldest first. `size` defaults to a tenth of
27-
* the live catalog so every entry comes round in about CYCLE_MONTHS.
23+
* Days between two batches, at most: the workflow runs on the 1st of each
24+
* month, and the longest month has 31 days.
25+
*/
26+
export const BATCH_GAP_DAYS = 31;
27+
28+
/**
29+
* Batch `k` (0 = this one) is the last that can re-read an entry before
30+
* its licence check turns VERIFIED_FRESH_DAYS old; undated entries are due
31+
* now. Negative (already late) counts as 0.
32+
*/
33+
function deadlineBatch(age) {
34+
if (age === null) return 0;
35+
return Math.max(0, Math.floor((VERIFIED_FRESH_DAYS - age) / BATCH_GAP_DAYS));
36+
}
37+
38+
/**
39+
* The smallest steady batch size that re-reads every live entry before it
40+
* turns VERIFIED_FRESH_DAYS old: for each coming batch k, the entries whose
41+
* deadline is batch k or sooner, spread over the k + 1 batches left. A bulge
42+
* of entries read in the same week is spread over the months before it
43+
* expires instead of landing in one batch. The look-ahead stops where an
44+
* entry re-read now would itself fall due.
45+
*/
46+
export function batchSizeNeeded(ages) {
47+
const horizon = Math.floor(VERIFIED_FRESH_DAYS / BATCH_GAP_DAYS);
48+
const byBatch = new Array(horizon + 1).fill(0);
49+
for (const age of ages) {
50+
const k = deadlineBatch(age);
51+
if (k <= horizon) byBatch[k] += 1;
52+
}
53+
let need = 0;
54+
let cumulative = 0;
55+
for (let k = 0; k <= horizon; k += 1) {
56+
cumulative += byBatch[k];
57+
need = Math.max(need, Math.ceil(cumulative / (k + 1)));
58+
}
59+
return need;
60+
}
61+
62+
/**
63+
* This month's batch, oldest first (undated first of all), sized by
64+
* batchSizeNeeded so no live entry passes VERIFIED_FRESH_DAYS before a batch
65+
* reaches it. Every entry more than `dueDays` old is in it, since it would
66+
* turn stale before the next one. An explicit `size` takes that many of the
67+
* entries more than `dueDays` old instead.
2868
*/
2969
export function pickBatch(entries, { now = Date.now(), dueDays = DUE_DAYS, size } = {}) {
30-
const live = entries.filter((e) => e.status !== "deprecated");
31-
const limit = size ?? Math.ceil(live.length / CYCLE_MONTHS);
32-
return live
70+
const live = entries
71+
.filter((e) => e.status !== "deprecated")
3372
.map((entry) => ({ entry, age: entry.verified ? ageDays(entry.verified, now) : null }))
34-
.filter((r) => r.age === null || r.age > dueDays)
35-
.sort((a, b) => (b.age ?? Infinity) - (a.age ?? Infinity) || a.entry.id.localeCompare(b.entry.id))
36-
.slice(0, limit)
37-
.map((r) => ({ ...r.entry, age: r.age }));
73+
.sort((a, b) => (b.age ?? Infinity) - (a.age ?? Infinity) || a.entry.id.localeCompare(b.entry.id));
74+
const picked =
75+
size === undefined
76+
? live.slice(0, batchSizeNeeded(live.map((r) => r.age)))
77+
: live.filter((r) => r.age === null || r.age > dueDays).slice(0, size);
78+
return picked.map((r) => ({ ...r.entry, age: r.age }));
3879
}
3980

4081
const code = (s) => `\`${String(s).replaceAll("`", "%60")}\``;
4182

4283
/** The issue body: how to do one, then a checklist with what to re-read. */
4384
export function batchIssueMarkdown({ batch, repo, date, total, dueDays = DUE_DAYS }) {
4485
const lines = [
45-
`Monthly re-verification batch, ${date}: the ${batch.length} entries whose licence was read longest ago (more than ${dueDays} days, out of ${total} live entries). Each one is a small, self-contained check, and a good first contribution.`,
86+
`Monthly re-verification batch, ${date}: the ${batch.length} entries whose licence was read longest ago, out of ${total} live entries: enough that none passes ${VERIFIED_FRESH_DAYS} days before the next batch. Each one is a small, self-contained check, and a good first contribution.`,
4687
"",
4788
"**How to do one**",
4889
"1. Open the entry and its source page (the `url` in the frontmatter).",

‎site/reverify-batch.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/usr/bin/env node
22
/**
33
* Writes the monthly "re-verify batch" issue: the live entries whose licence
4-
* was read longest ago (more than 150 days), oldest first, sized so the
5-
* catalog cycles in about ten months.
4+
* was read longest ago, oldest first, as many as keep every entry inside the
5+
* 180-day fresh window (lib/reverify.mjs batchSizeNeeded).
66
* node site/reverify-batch.mjs [--report <file.md>] [--size <n>]
77
* Under GitHub Actions it also writes `count=<n>` to $GITHUB_OUTPUT.
88
*/

0 commit comments

Comments
 (0)