Skip to content

Commit a771f86

Browse files
Programcodeclaude
andcommitted
Write R2 cases in parallel parts, and give each argument field a word target
R2 cases came within 6 s of the 130 s request budget (124 s for a Hungarian case at medium effort). Both Supabase projects are on the Free plan, which stops a worker at 150 s, so the budget cannot grow. An R2 case is now planned at low effort (our two arguments and the claims to answer), then written in four parts at the same time at the student's model and effort. Part 1 writes the speech and streams to the browser. Each part has its own smaller cap, which keeps its reasoning budget at 8,000 tokens at medium. Measured: 68 s, 58 s and 53 s, down from 124, 109 and 105. A failed part stops the others and every call is billed. R1 speeches ran to 752 and 823 words against a 600-word budget. The Speech Budget line now gives each argument field a word target derived from the speaker's time and the number of arguments. Measured: 533 and 531. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fd29987 commit a771f86

8 files changed

Lines changed: 346 additions & 9 deletions

File tree

‎.claude/skills/verify-debatelab/features/case-generation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Case generation
22

3-
A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each thing the other side is likely to say; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content.
3+
A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each thing the other side is likely to say; the Conclusor has 2-3 "Why we win" points instead of new arguments. An R2 case makes five provider calls, all `generate` in `providerCalls()`: a plan, then four parts written at the same time; the last one in the list still carries `Role: r2`. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content.
44

55
## Sub-features
66

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Every structured answer is shown while the model writes it. A page passes a `liv
8282

8383
A streamed run keeps its worker alive with `EdgeRuntime.waitUntil` ([`_shared/background.ts`](../supabase/functions/_shared/background.ts)) from the first event until the HTTP layer has read the last one. Supabase otherwise counts the response as sent once its headers are out and may retire the worker mid-run (`EarlyDrop`). Streamed Ask (`ai-stream`) is held the same way until the browser has read its final event. A run still unanswered about 142 s after its request arrived is aborted and gets a retryable timeout `result` instead of a connection cut at the 150 s wall clock; when the aborted run answers within 2 s, that answer is sent instead, so it says `charged: false` when the units went back. Storing the result and waiting for the browser to read it are cut short to finish before the wall clock. The request sends its id as `resultKey`. The function then writes a row to `ai_run_results` when the run starts, and stores the `result` there before sending it ([`ai/run-results.ts`](../supabase/functions/ai/run-results.ts); one row per user and feature, readable only by its owner). When the stream drops, stalls (no keepalive for 45 s), or delivers a result it cannot read, [`src/lib/ai/run-recovery.ts`](../src/lib/ai/run-recovery.ts) polls that row until the run's result appears, then resolves or fails exactly as if the event had arrived. A row still without a status long after it started is a run its worker never finished. A browser Stop, reload, or closed tab still cancels the run. The server cannot tell those from a lost connection, so it stops spending as soon as the connection goes.
8484

85-
A reply that hits its token cap is written again once with twice the cap, after a `reset`. The Generate cap (12,000 tokens) leaves room for a full comprehensive case on Claude Haiku, so a long case finishes on its first attempt. Claude rejects an assistant prefill, so a cut-off reply cannot be continued where it stopped. Sparring calls send a strict `json_schema` (`sparringOutputSchema` in `ai/sparring-persistence.ts`). Anthropic's structured outputs take no numeric or array-size limits, so the 0-25 range, the four categories, and the 12-item lists are enforced by the parser. When a reply parses but has the wrong shape, the log records only the failing field path, never the model's text. The call is then retried once with a repair prompt that names the field. A second failure answers `invalid_output` (502, logged as `invalid_output`), which the browser shows as a malformed answer. The judge also retries once when Claude is briefly unavailable or its stream breaks. The round has already paid, so retries cost the student nothing.
85+
A reply that hits its token cap is written again once with twice the cap, after a `reset`. The Generate cap (12,000 tokens) leaves room for a full comprehensive case on Claude Haiku, so a long case finishes on its first attempt. Claude rejects an assistant prefill, so a cut-off reply cannot be continued where it stopped. An R2 case is the longest reply (the speech and 10-12 full answers), and one call wrote it in up to 124 s of the 130 s budget, which cannot grow because both Supabase projects are on the Free plan and stop a worker at 150 s. So [`ai/r2-case.ts`](../supabase/functions/ai/r2-case.ts) asks for a short plan first (our two arguments and the claims to answer, at low effort), then writes four parts at the same time at the student's model and effort: part 1 the speech and its share of the answers, streamed to the browser, the others only answers. Each part has its own smaller cap (`caseWriterPart`), which also keeps its reasoning budget small enough to finish well inside the budget. One failed part fails the case, stops the others, and bills every call. Sparring calls send a strict `json_schema` (`sparringOutputSchema` in `ai/sparring-persistence.ts`). Anthropic's structured outputs take no numeric or array-size limits, so the 0-25 range, the four categories, and the 12-item lists are enforced by the parser. When a reply parses but has the wrong shape, the log records only the failing field path, never the model's text. The call is then retried once with a repair prompt that names the field. A second failure answers `invalid_output` (502, logged as `invalid_output`), which the browser shows as a malformed answer. The judge also retries once when Claude is briefly unavailable or its stream breaks. The round has already paid, so retries cost the student nothing.
8686

8787
For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional.
8888

‎e2e/full-stack/fake-openrouter.mjs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,33 @@ const r2Answer = (index) => ({
9191
// theirs, and the Conclusor brings reasons why we win. Neither R2 nor the
9292
// Conclusor brings new arguments.
9393
const generate = (user) => {
94+
// R2 is planned, then written in parts (supabase/functions/ai/r2-case.ts).
95+
const planCount = /PLANNING STEP[\s\S]*?theirClaims: exactly (\d+)/.exec(user)?.[1];
96+
if (planCount) {
97+
return {
98+
ourArguments: [
99+
{ title: "Fares shut people out", claim: "Fares keep the riders who need transit most off the bus." },
100+
{ title: "More people ride", claim: "Free fares bring more passengers onto public transport." },
101+
],
102+
theirClaims: Array.from({ length: Number(planCount) }, (_, index) => `Free transit claim ${index + 1}: it costs the city too much.`),
103+
};
104+
}
105+
const part = /=== WRITING PART (\d+) OF \d+: claims (\d+)-(\d+) ===/.exec(user);
106+
if (part && part[1] !== "1") {
107+
const [first, last] = [Number(part[2]), Number(part[3])];
108+
return {
109+
counters: Array.from({ length: last - first + 1 }, (_, index) => r2Answer(first - 1 + index)),
110+
sources: [{ org: "UITP", year: "2024", claim: "Ridership rose after fares were removed", url: "https://www.uitp.org/free-fares-2024" }],
111+
};
112+
}
94113
const length = /^Length: (\w+)/m.exec(user)?.[1] ?? "standard";
95114
const role = /^Role: (\w+)/m.exec(user)?.[1] ?? "r1";
96115
const count = { brief: 2, standard: 3, comprehensive: 4 }[length] ?? 3;
97116
const conc = role === "conc";
98117
const r2 = role === "r2";
99-
// R2: one full answer for each thing they are likely to say.
100-
const answers = length === "brief" ? 10 : 12;
118+
// R2: one full answer for each thing they are likely to say; part 1 of a
119+
// split R2 case writes only its own share of them.
120+
const answers = part ? Number(part[3]) - Number(part[2]) + 1 : length === "brief" ? 10 : 12;
101121
return {
102122
opening: {
103123
hook: `${MARK} opening hook: picture a nurse who skips the night bus because the fare is a tenth of her shift's pay.`,

‎supabase/functions/_shared/output-caps.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ export const REPLY_TOKEN_CAPS = {
2626
* limit. The cap only bounds the reply, so room costs nothing unless used.
2727
*/
2828
generate: 12_000,
29+
/**
30+
* The R2 plan: our two arguments and the claims the other side will make,
31+
* one line each. Written at low effort before the parts below.
32+
*/
33+
casePlan: 1_500,
34+
/**
35+
* One of the parts an R2 case is written in at the same time: the speech
36+
* and three answers, or three answers. A part is a quarter of the case, so
37+
* its smaller cap also keeps its reasoning budget (a share of max_tokens)
38+
* small enough that the slowest part finishes well inside the 130 s budget.
39+
*/
40+
caseWriterPart: 6_000,
2941
attack: 4_000,
3042
brainstorm: 4_000,
3143
factoids: 4_000,
@@ -66,6 +78,7 @@ const REASONING_HEADROOM: Record<ReasoningEffort, number> = { low: 2_048, medium
6678
*/
6779
export const REASONING_FLOOR_TOKENS: Partial<Record<ReplyCap, number>> = {
6880
generate: 10_000,
81+
caseWriterPart: 10_000,
6982
};
7083

7184
/**

‎supabase/functions/_shared/prompts/generate.ts‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,38 @@ export const SPEECH_MINUTES: Record<string, number> = { intro: 4, r1: 6, r2: 6,
151151
/** A student speaks about this many words a minute in a round. */
152152
export const SPOKEN_WORDS_PER_MINUTE = 130;
153153

154+
/** Arguments per length setting, as the Length Instruction asks for them. */
155+
const CONTENTION_COUNT: Record<string, number> = { brief: 2, standard: 3, comprehensive: 4 };
156+
/** Words the opening hook, the thesis line and the closing line take together. */
157+
const FRAME_WORDS = 90;
158+
const roundTo5 = (words: number) => Math.max(5, Math.round(words / 5) * 5);
159+
160+
/**
161+
* Word targets for each spoken field of one argument, so the arguments add up
162+
* to the speech's budget. A single total is not enough: a model asked for
163+
* "about 600 words" wrote 750-820, while per-field targets it can follow.
164+
*/
165+
function argumentWordTargets(spokenWords: number, length: string): string {
166+
const count = CONTENTION_COUNT[length] ?? 3;
167+
const perArgument = Math.floor((spokenWords - FRAME_WORDS) / count);
168+
const claim = Math.min(20, roundTo5(perArgument * 0.12));
169+
const reasoning = roundTo5(perArgument * 0.45);
170+
const evidence = roundTo5(perArgument * 0.28);
171+
const whyItMatters = roundTo5(perArgument * 0.15);
172+
return `With ${count} arguments, each argument is about ${perArgument} spoken words: claim one sentence of at most ${claim} words, reasoning about ${reasoning} words, evidence about ${evidence} words, whyItMatters about ${whyItMatters} words. opening.hook and caseStructure.thesis at most 35 words each, closingLine one sentence of at most 25 words. Aim for these numbers, not under them: a shorter field leaves the speaker's time unused, a longer one runs past it.`;
173+
}
174+
154175
/** The word budget the speaker reads out, stated in the request so speeches fit their time. */
155-
export function speechBudgetInstruction(role: string): string | undefined {
176+
export function speechBudgetInstruction(role: string, length = "standard"): string | undefined {
156177
const minutes = SPEECH_MINUTES[role];
157178
if (!minutes) return undefined;
158179
const words = minutes * SPOKEN_WORDS_PER_MINUTE;
159180
const head = `${minutes} minutes, about ${words} spoken words at ${SPOKEN_WORDS_PER_MINUTE} words a minute.`;
160181
switch (role) {
161182
case "intro":
162-
return `${head} The opening, the "we will show" line, the arguments in short form and the closing line together stay within ${words} words. The "If they say..." answers are a separate short prep list.`;
183+
return `${head} The opening, the "we will show" line, the arguments in short form and the closing line together stay within ${words} words. ${argumentWordTargets(words, length)} The "If they say..." answers are a separate short prep list.`;
163184
case "r1":
164-
return `${head} The opening, the arguments and the closing line together stay within about ${words - 180} words, leaving time for the "If they say..." answers the speaker picks.`;
185+
return `${head} The opening, the arguments and the closing line together stay within about ${words - 180} words, leaving time for the "If they say..." answers the speaker picks. ${argumentWordTargets(words - 180, length)}`;
165186
case "r2":
166187
return `${head} The opening, the backing-up paragraphs and the closing line together stay within about 300 words (about 2 minutes); the rest of the time is for the "If they say..." blocks the other side actually used.`;
167188
default:
@@ -202,7 +223,7 @@ export function buildGenerateUserPrompt(input: Record<string, unknown>, searchCo
202223
conversational: "Use natural spoken-round phrasing without slang or filler.",
203224
aggressive: "Use sharper strategic phrasing and clearer attack language while staying professional.",
204225
}[tone] || "Use polished, judge-facing language with controlled phrasing.";
205-
const speechBudget = speechBudgetInstruction(role);
226+
const speechBudget = speechBudgetInstruction(role, length);
206227
return `Motion: ${asString(input.motion)}
207228
Side: ${asString(input.side, "pro")}
208229
Format: ${asString(input.format, "custom")}
@@ -217,3 +238,53 @@ ${outputLanguageInstruction(language)}
217238
218239
${searchContext}`;
219240
}
241+
242+
// === R2 written in parts ===
243+
// One R2 case is about 12 full answers plus the speech, which one call wrote
244+
// in up to 124 s of the 130 s budget. A short plan call picks our arguments
245+
// and the claims to answer; the parts then write at the same time from it.
246+
247+
/** "If they say..." answers in an R2 case, by length. */
248+
export function r2CounterCount(length: string): number {
249+
return length === "brief" ? 10 : 12;
250+
}
251+
252+
/** Words per R2 answer, by length (the Length Instruction's range). */
253+
export function r2AnswerWords(length: string): string {
254+
return ({ brief: "100-130", standard: "120-150", comprehensive: "150-180" } as Record<string, string>)[length] ?? "120-150";
255+
}
256+
257+
export interface R2Plan {
258+
ourArguments: Array<{ title: string; claim: string }>;
259+
theirClaims: string[];
260+
}
261+
262+
export function r2PlanInstruction(counterCount: number): string {
263+
return `=== PLANNING STEP: do not write the case yet ===
264+
This R2 case is written in parts at the same time, all from the plan you write now. This step overrides the Length Instruction and the output format: return ONLY this JSON object, in the output language:
265+
{"ourArguments": [{"title": "a few words", "claim": "one sentence"}, {"title": "a few words", "claim": "one sentence"}], "theirClaims": ["one sentence", "..."]}
266+
- ourArguments: exactly 2. The team's earlier arguments when CURRENT DEBATE CONTEXT has them, by their own names; otherwise the two strongest arguments for our side that the research backs.
267+
- theirClaims: exactly ${counterCount} different things the other side is likely to say, each one sentence the way they would put it: first their 2-3 main arguments, then the supporting points they will use to back each of them up, then the answers they will give to each of our two arguments. No two claims make the same point. Prefer claims the attached research lets us answer with facts.`;
268+
}
269+
270+
/**
271+
* The instruction for one part. Part 1 writes the speech (opening, backing up
272+
* our case, closing) and its share of the answers; every other part writes
273+
* only answers. Answers are numbered across the whole case.
274+
*/
275+
export function r2PartInstruction(plan: R2Plan, claims: string[], firstNumber: number, part: number, parts: number, length: string): string {
276+
const ours = plan.ourArguments.map((argument, index) => `${index + 1}. ${argument.title}: ${argument.claim}`).join("\n");
277+
const list = claims.map((claim, index) => `${firstNumber + index}) ${claim}`).join("\n");
278+
const answers = `exactly ${claims.length} counters, one for each of these claims, in this order, each using the claim as its targetArgument:\n${list}\nEach answer about ${r2AnswerWords(length)} words, built as the R2 rules ask, and tied to our arguments by name.`;
279+
const head = `=== WRITING PART ${part} OF ${parts}: claims ${firstNumber}-${firstNumber + claims.length - 1} ===
280+
This R2 case is written in ${parts} parts at the same time, from one plan. This part overrides the Length Instruction's counts.
281+
Our arguments, in this order (call them "our first argument" and "our second argument"):
282+
${ours}`;
283+
return part === 1
284+
? `${head}
285+
Write the opening, caseStructure, exactly ${plan.ourArguments.length} reinforce items (one for each of our arguments, in order), the closingLine, and ${answers}
286+
contentions stays an empty array. sources lists every source this part names.`
287+
: `${head}
288+
Write only "If they say..." answers: return a JSON object with only "counters" and "sources". Write ${answers}
289+
No opening, reinforce, whyWeWin or closingLine: another part writes them. sources lists every source this part names.`;
290+
}

‎supabase/functions/ai/features/generate.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { logUsage } from "../../_shared/entitlements.ts";
22
import { mergeNormalizedUsage } from "../../_shared/cost.ts";
33
import { callAI } from "../provider.ts";
4+
import { writeR2Case } from "../r2-case.ts";
45
import { isRecord } from "../request.ts";
56
import { cleanGeneratedText } from "../text.ts";
67
import { buildUserPrompt } from "../prompt-builder.ts";
@@ -87,7 +88,11 @@ export async function handleGenerate(ctx: FeatureContext): Promise<Response> {
8788
const systemPrompt = await buildSystemPromptFor(feature, research);
8889
const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext);
8990
const messages = [{ role: "user", content: userPrompt }];
90-
const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, "generate", workSignal, ctx.stream));
91+
// R2 is the longest reply by far, so it is planned and written in parts at
92+
// the same time to finish well inside the request budget (see r2-case.ts).
93+
const aiResult = recordMainResult(role === "r2"
94+
? await writeR2Case({ callAI, provider, model, reasoningEffort, systemPrompt, userPrompt, length, signal: workSignal, stream: ctx.stream })
95+
: await callAI(provider, model, systemPrompt, messages, reasoningEffort, "generate", workSignal, ctx.stream));
9196
const output = cleanGeneratedText(aiResult.output);
9297

9398
// Preserve model evidence warnings when research exists. If research

0 commit comments

Comments
 (0)