From 8863dae186bd1cc41d742722778b7fc38d0d1a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:33:14 +0000 Subject: [PATCH] fix(drift): one flush-recovery path, and retries draw only deficient buckets (#106) Two refill defects that compounded. RETRIES REDREW EVERYTHING. Every retry drew all six buckets, against a destructive drawPool, so one permanently-empty bucket drained the five healthy ones on every attempt. The working set now keeps a per-bucket status (ok, failed, empty, pending) and exposes retry(), which draws only the buckets still owed something. prime() and topUp() still draw all six: the opening pass and a low-local-supply top-up want the whole spread. A bucket whose rows all lack coords now counts as empty, since it contributed nothing; a flush marks every bucket pending, since their rows went with it. A FLUSH HAD TWO REPAIRS. The flush handler nulled the range, backgroundFill then painted gauges against null, and maybeTopUp rebuilt the range on its own while the old axis labels stayed up. Now onFlush only marks the frame stale, both refills settle through settleRefill(), and a stale frame goes to one recover(): cancel the fade, refresh axes ordered by the pool's axisIds, clear current/seen, re-prime the deficient buckets, then frame() (freeze, reset position, repaint). enterStage uses the same frame(). A recovery that cannot finish stays stale and the background loop retries it on its backoff. Also fixes renderGauges, which cleared the horizontal container but not the vertical one, so any re-render stacked a second y gauge. Tests: four working-set tests (empty bucket never redrains healthy ones, failed bucket retried until it answers, coord-less rows count as empty, flush re-owes every bucket) and three controller tests that drive the same flush through the top-up path and the background-fill path and require identical end state. All seven fail on main. Closes #106 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AJidCEAViZHt3GXCmHrurf --- public/drift/drift.js | 147 ++++++++++++++++++++++++--------- public/drift/working-set.js | 66 +++++++++------ test/drift-controller.test.ts | 138 ++++++++++++++++++++++++++++++- test/drift-working-set.test.ts | 90 ++++++++++++++++++++ 4 files changed, 373 insertions(+), 68 deletions(-) diff --git a/public/drift/drift.js b/public/drift/drift.js index d80320d..c77cb1c 100644 --- a/public/drift/drift.js +++ b/public/drift/drift.js @@ -67,18 +67,7 @@ els.seedForm.addEventListener('submit', async (e) => { state.sessionId = session.id; state.axisClient = createAxisClient(session.id); state.set = createWorkingSet(session.id); - // A flush means every held coord was scored against a different axis set. - // The frozen range and the seen set are shaped for those coords too, so - // they go with it. - // A flush means every held coord was scored against a different axis set — - // so the CARD ON SCREEN is stale too. Clearing range and seen while leaving it - // painted left the user looking at a position that no longer exists. - state.set.onFlush(() => { - state.range = null; - state.seen = new Set(); - state.current = null; - if (els.card) els.card.textContent = ''; - }); + state.set.onFlush(onFlush); location.hash = session.id; els.seedForm.hidden = true; els.axisForm.hidden = false; @@ -335,7 +324,10 @@ async function enterStage() { let wait = PRIME_RETRY_MS; let attempts = 0; while (true) { - await state.set.prime(); + // The first pass draws everything; every later one draws only the buckets + // still owed something. drawPool is destructive, so redrawing a bucket that + // already answered would drain it to serve a retry aimed elsewhere. + await (attempts === 0 ? state.set.prime() : state.set.retry()); attempts++; const failed = state.set.failedBuckets(); const empty = state.set.emptyBuckets(); @@ -365,8 +357,6 @@ async function enterStage() { await new Promise((resolve) => setTimeout(resolve, wait)); wait = Math.min(wait * 1.5, 10_000); } - state.range = freezeRange(state.set.all(), state.axes.length); - state.position = initialPosition(state.range); // Stage 2 needs a pool to rank, so it can only run now. Warn and allow, same // as stage 1 — the surface stays usable either way. state.axes.forEach((axis, i) => { @@ -377,9 +367,8 @@ async function enterStage() { }); els.setup.hidden = true; els.stage.hidden = false; - renderGauges(); paintCondensate(); - advance(); + frame(); } // renderGauges, advance, onSwipe and the condensate handlers are Task 6 and 7. @@ -399,13 +388,13 @@ function backgroundFill(attempt = 0) { return; } fillTimer = setTimeout(() => { - state.set.topUp() + state.set.retry() + .then(settleRefill) .then(() => { - if (state.range) state.range = widenRange(state.range, state.set.all()); - paintGauges(); - if (state.current === null) advance(); + // A frame recover() could not rebuild yet counts as outstanding too: + // this loop is what retries it, on the same backoff and budget. const outstanding = state.set.failedBuckets().length + state.set.emptyBuckets().length; - if (outstanding > 0) backgroundFill(attempt + 1); + if (outstanding > 0 || stale) backgroundFill(attempt + 1); }) .catch((err) => { console.error('drift background refill failed', err); @@ -414,6 +403,93 @@ function backgroundFill(attempt = 0) { }, Math.min(2_000 * 2 ** attempt, 20_000)); } +// ── recovery ──────────────────────────────────────────────────────────────── +// +// ONE PATH. A flush (the axis set changed under us) used to be handled twice: +// the flush handler nulled the range, then backgroundFill painted gauges +// against that null range while maybeTopUp rebuilt the range on its own and +// left the old axis labels on screen. Which repair you got depended on which +// refill happened to see the flush. Now the flush only marks the frame stale, +// and every refill settles through settleRefill(), which hands a stale frame to +// recover(). Critic cycle 3. + +/** True from a flush until recover() has rebuilt the frame. The swipe path is + * inert meanwhile — onSwipe already refuses a null range. */ +let stale = false; +let recovering = null; + +function onFlush() { + // Synchronous and minimal: this runs INSIDE the working set's draw. Mark the + // frame unusable so nothing ranks against coords of the wrong shape; the + // rebuild happens in recover(), once the draw has settled. + stale = true; + state.range = null; +} + +/** What every refill does once its draw settles, whichever refill it was. */ +function settleRefill() { + if (stale) return recover(); + if (state.range === null) return undefined; + state.range = widenRange(state.range, state.set.all()); + paintGauges(); + if (state.current === null) advance(); + return undefined; +} + +/** Rebuilds the frame after a flush. Order matters and is the issue's: + * cancel the fade, refresh the axes, clear stale current/seen, re-prime, + * freeze a range, reset position, repaint. Serialized — two refills can both + * notice the same flush. */ +function recover() { + if (!recovering) recovering = doRecover().finally(() => { recovering = null; }); + return recovering; +} + +async function doRecover() { + // 1. Cancel the pending fade. Its write() would commit a card chosen from + // the discarded set — and repaint the stale word over a blank frame. + clearTimeout(fadeTimer); + delete els.card.dataset.leaving; + + // 2. Refresh the axes, and order them by the ids the coords are indexed by. + // Labels from the old set over coords from the new one is the bug. + const listed = await state.axisClient.list(); + const byId = new Map((Array.isArray(listed) ? listed : listed?.axes ?? []).map((a) => [a.id, a])); + const axes = state.set.axisIds().slice(0, 2).map((id) => byId.get(id)); + if (axes.length === 0 || axes.some((a) => !a)) { + // The axis list is behind the pool. Stay stale; the next refill retries. + console.warn('drift recovery: axis list does not cover the pool axis ids yet'); + return; + } + state.axes = axes; + + // 3. Nothing held before the flush is a position any more. + state.current = null; + state.seen = new Set(); + els.card.textContent = ''; + + // 4. Re-prime. Only the deficient buckets: the flush already marked every + // bucket owed, but any that refilled since are left alone. + await state.set.retry(); + if (state.set.size() === 0) return; // still nothing to frame; stays stale + + // 5–7. Freeze, reset, repaint. A second flush during the re-prime means the + // axes just fetched are already stale; leave the frame stale and let + // the next settle start over rather than framing the wrong shape. + if (state.set.axisIds().slice(0, 2).some((id, i) => id !== state.axes[i]?.id)) return; + frame(); +} + +/** Freeze a range over the resident set, put the position at its centre, and + * repaint gauges and card. */ +function frame() { + state.range = freezeRange(state.set.all(), state.axes.length); + state.position = initialPosition(state.range); + stale = false; + renderGauges(); + advance(); +} + // ── resume ────────────────────────────────────────────────────────────────── /** The URL hash IS the session, so a reload must resume rather than dump you on @@ -443,12 +519,7 @@ async function resumeFromHash() { console.error('drift could not restore pins', err); } state.set = createWorkingSet(id); - state.set.onFlush(() => { - state.range = null; - state.seen = new Set(); - state.current = null; - if (els.card) els.card.textContent = ''; - }); + state.set.onFlush(onFlush); els.seedForm.hidden = true; els.axisForm.hidden = true; await enterStage(); @@ -471,7 +542,10 @@ const AXIS_KEYS = [ ]; function renderGauges() { + // Both containers. Recovery re-renders after a flush, and clearing only the + // horizontal one stacked a second vertical gauge beside the first. els.gauges.textContent = ''; + els.gaugeY.textContent = ''; // With one axis named — or one of two failing to create — up/down have // nowhere to go. The spec's degradation rule is that they go inert and are // LABELLED inert, so the surface opens rather than refusing and the user is @@ -603,19 +677,10 @@ function maybeTopUp() { const supply = localSupply(state.set.all(), state.position, state.range, state.seen, SUPPLY_RADIUS); if (supply >= SUPPLY_FLOOR) return; state.set.topUp() - .then(() => { - if (state.range === null) { - // The set flushed under us — the axis set changed. Re-freeze from - // whatever arrived rather than reusing a range shaped for old coords. - if (state.set.size() === 0) return; - state.range = freezeRange(state.set.all(), state.axes.length); - state.position = initialPosition(state.range); - } else { - state.range = widenRange(state.range, state.set.all()); - } - paintGauges(); - if (state.current === null) advance(); - }) + .then(settleRefill) + // A recovery that could not finish here is handed to the background loop, + // which retries it with backoff — the swipe path is inert until it lands. + .then(() => { if (stale) backgroundFill(); }) .catch((err) => console.error('drift top-up failed', err)); } diff --git a/public/drift/working-set.js b/public/drift/working-set.js index dfd32ae..46d0c87 100644 --- a/public/drift/working-set.js +++ b/public/drift/working-set.js @@ -125,23 +125,21 @@ export function createWorkingSet(sessionId, opts = {}) { return { condensed: body.condensed ?? [], axisIds: body.axisIds ?? [] }; } - let lastFailedBuckets = []; - let lastEmptyBuckets = []; + /** Per-bucket outcome of the LAST draw that touched it: 'ok' (it contributed + * rankable candidates), 'failed' (the request errored), 'empty' (200 with + * nothing usable — generation has not caught up there), or 'pending' (never + * drawn, or its rows were discarded by a flush). Kept per bucket rather than + * per pass because a retry draws only some buckets, and a bucket it did not + * touch keeps whatever it last earned. */ + const status = new Map(BUCKETS.map((b) => [b, 'pending'])); - async function draw() { + async function draw(buckets) { const results = await Promise.all( - BUCKETS.map((b) => drawBucket(b).catch(() => null)), + buckets.map((b) => drawBucket(b).catch(() => null)), ); - // Which buckets contributed nothing, so the caller can keep trying them in - // the background instead of treating one bad pass as the final answer. - lastFailedBuckets = BUCKETS.filter((_, i) => results[i] === null); - // A 200 carrying zero candidates is NOT a success. It means generation has - // not caught up for that bucket, and treating it as done is how a sparse - // pool got mistaken for a full one. Tracked separately from a request - // failure because the two want different responses: retry vs wait. Cycle 2. - lastEmptyBuckets = BUCKETS.filter((_, i) => results[i] !== null && (results[i].condensed?.length ?? 0) === 0); - for (const result of results) { - if (!result) continue; + buckets.forEach((bucket, i) => { + const result = results[i]; + if (result === null) { status.set(bucket, 'failed'); return; } if (!sameAxisIds(currentAxisIds, result.axisIds)) { // ADOPTION IS NOT A FLUSH. On the first draw currentAxisIds is [] and // the server returns the real set, which is not a change — there is @@ -152,44 +150,66 @@ export function createWorkingSet(sessionId, opts = {}) { // Every held coord was scored against the old axis set and cannot be // reconciled. Losing the set is free — it is ephemeral by design — // and the caller must also reset its frozen range and seen set, - // which is what onFlush is for. + // which is what onFlush is for. Every bucket's contribution went with + // it, so every bucket is owed a draw again. items = []; + for (const b of BUCKETS) status.set(b, 'pending'); + currentAxisIds = result.axisIds; for (const cb of flushHandlers) cb(); + } else { + currentAxisIds = result.axisIds; } - currentAxisIds = result.axisIds; } + let kept = 0; for (const row of result.condensed) { // An empty coords array means the draw beat the axes to readiness. // Such a row has no position; ranking it would place it wherever the // maths happens to land. if (!Array.isArray(row.coords) || row.coords.length === 0) continue; items.push(ingest(row)); + kept++; } - } + // A 200 carrying zero usable candidates is NOT a success. It means + // generation has not caught up for that bucket, and treating it as done + // is how a sparse pool got mistaken for a full one. Tracked separately + // from a request failure because the two want different responses: + // retry vs wait. Cycle 2. + status.set(bucket, kept > 0 ? 'ok' : 'empty'); + }); } + const withStatus = (...wanted) => BUCKETS.filter((b) => wanted.includes(status.get(b))); + /** Serialized: draws are destructive, so two concurrent passes drain the DO * faster than its pump refills. */ - function once() { + function once(buckets) { // Refuse to draw at all while throttled. Six buckets x every retry is // exactly how a rate limit turns into a console full of 429s. if (Date.now() < throttledUntil) return Promise.resolve(); if (inflight) return inflight; - inflight = draw().finally(() => { inflight = null; }); + if (buckets.length === 0) return Promise.resolve(); + inflight = draw(buckets).finally(() => { inflight = null; }); return inflight; } return { - prime: once, - topUp: once, + /** Draws every bucket. The opening pass, and a demand-driven top-up when + * local supply runs low, both want the whole spread. */ + prime: () => once(BUCKETS), + topUp: () => once(BUCKETS), + /** Draws ONLY the buckets still owed something — failed, empty, or never + * drawn. drawPool is destructive, so redrawing a healthy bucket to serve a + * retry aimed at another one drains it for nothing, and a single + * permanently-empty bucket would do that on every attempt. */ + retry: () => once(withStatus('failed', 'empty', 'pending')), all: () => items, size: () => items.length, axisIds: () => currentAxisIds, /** Buckets whose REQUEST errored on the last pass. */ - failedBuckets: () => [...lastFailedBuckets], + failedBuckets: () => withStatus('failed'), /** Buckets that answered 200 with nothing in them — generation has not * caught up there yet. */ - emptyBuckets: () => [...lastEmptyBuckets], + emptyBuckets: () => withStatus('empty'), /** Milliseconds until the field will accept draws again, or 0. */ throttledFor: () => Math.max(0, throttledUntil - Date.now()), onFlush(cb) { flushHandlers.push(cb); }, diff --git a/test/drift-controller.test.ts b/test/drift-controller.test.ts index d5d5cb0..31b923e 100644 --- a/test/drift-controller.test.ts +++ b/test/drift-controller.test.ts @@ -58,10 +58,9 @@ function stubFetch(pool: unknown[]) { const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response; -async function boot(pool: unknown[]) { +async function boot(pool: unknown[], fetchMock: ReturnType = stubFetch(pool)) { document.body.innerHTML = MARKUP; document.body.className = "drift-surface"; - const fetchMock = stubFetch(pool); vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("matchMedia", (q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })); location.hash = ""; @@ -75,8 +74,8 @@ async function boot(pool: unknown[]) { } /** Runs the full setup flow so the stage is open with a card showing. */ -async function toCardStage(pool: unknown[]) { - const { mod, fetchMock } = await boot(pool); +async function toCardStage(pool: unknown[], fetchImpl?: ReturnType) { + const { mod, fetchMock } = await boot(pool, fetchImpl); (document.getElementById("drift-seed-input") as HTMLInputElement).value = "a seed"; document.getElementById("drift-seed-form")!.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); await vi.waitFor(() => expect(document.getElementById("drift-axis-form")!.hidden).toBe(false)); @@ -176,3 +175,134 @@ describe("a weakly-related pool still renders (the tether-floor retraction)", () expect(document.getElementById("drift-edge")!.hidden).toBe(true); }); }); + +describe("a flush recovers through one path, whichever refill saw it (#106)", () => { + // An axisIds change used to be repaired twice, differently: the flush handler + // nulled the range, backgroundFill then painted gauges against null, and + // maybeTopUp rebuilt the range on its own while the OLD axis labels stayed on + // screen. These drive the same flush through each refill and require the + // same end state from both. + type Drift = { + onSwipe(a: number, d: number): unknown; + state: { + axes: { id: string }[]; + range: unknown; + position: number[] | null; + seen: Set; + current: { text: string } | null; + }; + }; + const AXIS = { + a: { id: "a", neg: { term: "solemn", phrase: "a solemn thing here" }, pos: { term: "playful", phrase: "a playful thing here" }, ready: true, degraded: false }, + b: { id: "b", neg: { term: "concrete", phrase: "a concrete thing here" }, pos: { term: "abstract", phrase: "an abstract thing here" }, ready: true, degraded: false }, + c: { id: "c", neg: { term: "calm", phrase: "a calm thing here" }, pos: { term: "frantic", phrase: "a frantic thing here" }, ready: true, degraded: false }, + }; + + /** A field whose axis set can change under the client. Before the switch + * every bucket but `emptyBucket` holds one word scored against [a, b]; after + * it, every bucket holds three words scored against [b, c], and the axis list + * has caught up. */ + function switchableField(emptyBucket: string | null) { + const world = { switched: false }; + const fetchMock = vi.fn(async (url: string) => { + const u = String(url); + if (u === "/api/session") return json({ id: "11111111-1111-4111-8111-111111111111" }); + if (u.includes("/axes")) return json({ axes: world.switched ? [AXIS.a, AXIS.b, AXIS.c] : [AXIS.a, AXIS.b] }); + if (u.includes("/pool")) { + const bucket = new URL(u, "http://x").searchParams.get("bucket")!; + if (!world.switched) { + const i = ["w0a0", "w0a1", "w1a0", "w1a1", "w2a0", "w2a1"].indexOf(bucket); + return json({ + condensed: bucket === emptyBucket ? [] : [served(`old ${bucket}`, [i / 6 - 0.4, (i % 3) / 3 - 0.3])], + axisIds: ["a", "b"], + }); + } + return json({ + // Three per bucket across a span, so the centre the frame resets to + // always has something in reach. + condensed: [0, 1, 2].map((k) => served(`new ${bucket} ${k}`, [k * 0.1 - 0.1, (Number(bucket[1]) - 1) * 0.1])), + axisIds: ["b", "c"], + }); + } + return json({}); + }); + return { world, fetchMock }; + } + + async function stageWith(field: ReturnType) { + const { mod } = await toCardStage([], field.fetchMock); + return mod as Drift; + } + + function endState(drift: Drift) { + const labels = Array.from(document.querySelectorAll(".drift-gauge")).map((g) => + Array.from(g.querySelectorAll("span")).map((s) => s.textContent).join("|")); + const marks = Array.from(document.querySelectorAll(".drift-gauge-mark")).map((m) => m.style.left || m.style.top); + return { + axes: drift.state.axes.map((a) => a.id), + labels, + marks, + rangeSet: drift.state.range !== null, + position: drift.state.position, + seen: [...drift.state.seen], + current: drift.state.current?.text ?? null, + card: document.getElementById("drift-card")!.textContent, + leaving: document.getElementById("drift-card")!.dataset.leaving ?? null, + }; + } + + async function viaTopUp() { + // Every bucket answers, so no background fill is pending; the swipe's + // low-local-supply top-up is the refill that sees the flush. + const field = switchableField(null); + const drift = await stageWith(field); + expect(document.getElementById("drift-card")!.textContent).toMatch(/^old /); + field.world.switched = true; + drift.onSwipe(0, 1); + await vi.waitFor(() => expect(drift.state.axes.map((a) => a.id)).toEqual(["b", "c"]), { timeout: 3000 }); + await vi.waitFor(() => expect(document.getElementById("drift-card")!.textContent).toMatch(/^new /), { timeout: 3000 }); + return { drift, field }; + } + + async function viaBackgroundFill() { + // One bucket answered empty, so enterStage leaves a background fill + // pending; that retry is the refill that sees the flush. + const field = switchableField("w0a0"); + const drift = await stageWith(field); + expect(document.getElementById("drift-card")!.textContent).toMatch(/^old /); + field.world.switched = true; + await vi.waitFor(() => expect(drift.state.axes.map((a) => a.id)).toEqual(["b", "c"]), { timeout: 5000 }); + await vi.waitFor(() => expect(document.getElementById("drift-card")!.textContent).toMatch(/^new /), { timeout: 3000 }); + return { drift, field }; + } + + it("the top-up path relabels the gauges to the axes the coords were scored against", async () => { + const { drift } = await viaTopUp(); + const s = endState(drift); + expect(s.labels).toEqual(["concrete|abstract", "calm|frantic"]); + expect(s.rangeSet).toBe(true); + expect(s.marks.every((m) => /^\d+(\.\d+)?%$/.test(m))).toBe(true); + // Nothing from before the flush survives as seen or current. + expect(s.seen).toEqual([s.card]); + expect(s.current).toBe(s.card); + }); + + it("the background-fill path ends in the same state as the top-up path", async () => { + const a = endState((await viaTopUp()).drift); + const b = endState((await viaBackgroundFill()).drift); + expect(b).toEqual(a); + }); + + it("the background-fill path redraws only the buckets the flush emptied", async () => { + const { field } = await viaBackgroundFill(); + const after = field.fetchMock.mock.calls + .map(([u]) => String(u)) + .filter((u) => u.includes("/pool")) + .map((u) => new URL(u, "http://x").searchParams.get("bucket")); + // enterStage's prime: six. The background retry: only the empty w0a0, + // whose answer flushes. The recovery re-prime: the five the flush emptied. + expect(after.slice(0, 6).sort()).toEqual(["w0a0", "w0a1", "w1a0", "w1a1", "w2a0", "w2a1"]); + expect(after[6]).toBe("w0a0"); + expect(after.slice(7).sort()).toEqual(["w0a1", "w1a0", "w1a1", "w2a0", "w2a1"]); + }); +}); diff --git a/test/drift-working-set.test.ts b/test/drift-working-set.test.ts index 9de1061..dde81a1 100644 --- a/test/drift-working-set.test.ts +++ b/test/drift-working-set.test.ts @@ -6,6 +6,7 @@ import * as wsUntyped from "../public/drift/working-set.js"; interface Item { text: string; tier: number; alt: number; seedDist: number; coords: number[]; arrivedAt: number } interface WorkingSet { prime(): Promise; + retry(): Promise; all(): Item[]; topUp(): Promise; axisIds(): string[]; @@ -222,3 +223,92 @@ describe("throttling is reported on every call, not just pool draws", () => { await expect(ws.createSession("seed", {}, failing)).rejects.not.toMatchObject({ name: "ThrottledError" }); }); }); + +describe("retry draws only what is deficient (#106)", () => { + // drawPool DELETEs every row it returns. Redrawing all six buckets to serve a + // retry aimed at one drains five healthy ones for nothing — and with one + // bucket permanently empty, it does that on every attempt. + const drawsOf = (f: typeof fetch) => + (f as unknown as ReturnType).mock.calls.map(([u]) => new URL(String(u), "http://x").searchParams.get("bucket")); + + it("a permanently-empty bucket does not redrain healthy ones", async () => { + const fetchImpl = poolFetch((b) => ({ + condensed: b === "w0a0" ? [] : [served(`${b}-x`)], + axisIds: ["ax"], + })); + const set = ws.createWorkingSet("s1", { fetchImpl }); + await set.prime(); + expect(set.emptyBuckets()).toEqual(["w0a0"]); + const healthy = set.size(); + + for (let i = 0; i < 5; i++) await set.retry(); + + const draws = drawsOf(fetchImpl); + // One prime of all six, then five retries of the one empty bucket. + expect(draws.filter((b) => b === "w0a0")).toHaveLength(6); + for (const b of ws.BUCKETS.filter((x) => x !== "w0a0")) { + expect(draws.filter((d) => d === b), `${b} was redrawn by a retry`).toHaveLength(1); + } + expect(set.size()).toBe(healthy); + expect(set.emptyBuckets()).toEqual(["w0a0"]); + }); + + it("retries a failed bucket too, and stops once it answers", async () => { + let w1a1Up = false; + const fetchImpl = vi.fn(async (url: string) => { + const bucket = new URL(url, "http://x").searchParams.get("bucket")!; + if (bucket === "w1a1" && !w1a1Up) return { ok: false, status: 500, json: async () => ({}) } as unknown as Response; + return { ok: true, status: 200, json: async () => ({ condensed: [served(bucket)], axisIds: ["ax"] }) } as unknown as Response; + }) as unknown as typeof fetch; + const set = ws.createWorkingSet("s1", { fetchImpl }); + await set.prime(); + expect(set.failedBuckets()).toEqual(["w1a1"]); + + await set.retry(); + expect(drawsOf(fetchImpl).slice(ws.BUCKETS.length)).toEqual(["w1a1"]); + + w1a1Up = true; + await set.retry(); + expect(set.failedBuckets()).toEqual([]); + const before = drawsOf(fetchImpl).length; + await set.retry(); + expect(drawsOf(fetchImpl).length, "retried with nothing deficient").toBe(before); + }); + + it("counts a bucket whose rows all lack coords as empty, not healthy", async () => { + // A draw that beat the axes to readiness answers 200 with coords: []. The + // rows are dropped, so the bucket contributed nothing and is still owed. + const fetchImpl = poolFetch((b) => ({ + condensed: [b === "w2a0" ? { ...served(b), coords: [] } : served(b)], + axisIds: ["ax"], + })); + const set = ws.createWorkingSet("s1", { fetchImpl }); + await set.prime(); + expect(set.emptyBuckets()).toEqual(["w2a0"]); + }); + + it("a flush makes every bucket owed again, so the re-prime refills them all", async () => { + // The rows the healthy buckets contributed were scored against the old + // axis set and are gone; leaving them marked healthy would strand the + // surface on one bucket's worth of candidates. + let ids = ["ax"]; + const fetchImpl = poolFetch((b) => ({ + condensed: b === "w0a0" ? [] : [served(`${b}-${ids.join("")}`)], + axisIds: ids, + })); + const set = ws.createWorkingSet("s1", { fetchImpl }); + await set.prime(); + + ids = ["ax", "ay"]; + // Only w0a0 is deficient, so only w0a0 is drawn — and its answer carries + // the new axis set, which flushes. + await set.retry(); + expect(set.size()).toBe(0); + expect(set.axisIds()).toEqual(["ax", "ay"]); + + await set.retry(); + const tail = drawsOf(fetchImpl).slice(ws.BUCKETS.length + 1); + expect(tail.sort()).toEqual([...ws.BUCKETS].sort()); + expect(set.size()).toBe(ws.BUCKETS.length - 1); + }); +});