Skip to content

Commit de49198

Browse files
authored
Merge pull request #8 from tshmieldev/tshmieldev/classifier-batching
Judge posts in groups: one classifier request per group
2 parents 230b8ca + f180dca commit de49198

4 files changed

Lines changed: 347 additions & 141 deletions

File tree

‎src/background/classifier.ts‎

Lines changed: 193 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
// Decide with a classifier: a decision-only model reached through OpenRouter's
2-
// Decisions endpoint. It answers typed questions with calibrated probabilities
3-
// and never writes text, so it cannot read images or give a reason. Images are
4-
// turned into text first by a fast vision model. The probability travels with
5-
// the verdict; how sure counts as sure is the reader's call, made at display.
1+
// Decide with a classifier: a decision-only model that answers typed questions
2+
// with calibrated probabilities and never writes text, so it cannot read images
3+
// or give a reason. It answers every question in a request in parallel, so
4+
// posts go to it in groups: about one round trip and half the tokens of asking
5+
// one by one. Images are turned into text first by a fast vision model. The
6+
// probability travels with the verdict; how sure counts as sure is the reader's
7+
// call, made at display.
68
import { Effect, Schema } from 'effect';
79
import type { Post, Trace, Verdict } from '../common/post';
810
import {
@@ -17,11 +19,10 @@ import {
1719
import { ProviderError, requestJson } from './providers';
1820

1921
export { CLASSIFIER_REASON };
20-
/** One gate for the whole worker, sized to the reader's setting: every post
21-
* being judged holds a slot until its images and its decision are done, across
22-
* every group and every tab. Each post is its own request, so one post's
23-
* context can never leak into another's decision. A resize takes a fresh gate;
24-
* posts already inside the old one finish there. */
22+
/** One gate for the whole worker, sized to the reader's setting: every group
23+
* being judged holds a slot until its images and its decisions are done,
24+
* across every tab. A resize takes a fresh gate; groups already inside the old
25+
* one finish there. */
2526
let gate: { size: number; semaphore: Effect.Semaphore } | null = null;
2627
function gateFor(size: number) {
2728
if (gate?.size !== size) gate = { size, semaphore: Effect.runSync(Effect.makeSemaphore(size)) };
@@ -147,18 +148,47 @@ function describe(settings: Settings, url: string) {
147148
);
148149
}
149150

150-
export function decisionBody(settings: Settings, post: Post, images: readonly string[]) {
151+
type Entry = { post: Post; images: readonly string[] };
152+
/** A lone post answers to `hide`; a group is keyed p1..pN, one question each. */
153+
const idOf = (index: number, alone: boolean) => (alone ? 'hide' : `p${index + 1}`);
154+
155+
/** One request for one post or for several. The classifier answers every
156+
* question in a request in parallel, so a group costs about one round trip and
157+
* far fewer tokens than the same posts asked one by one. Each question names
158+
* its own post, so the others are context and never the subject. */
159+
export function decisionBody(settings: Settings, entries: readonly Entry[]) {
151160
const provider = settings.classifierProvider;
161+
const alone = entries.length === 1;
162+
const shape = ({ post, images }: Entry) => ({
163+
author: `@${post.handle}`,
164+
text: post.text || '(no text)',
165+
...(post.context ? { replying_to: post.context } : {}),
166+
...(images.length ? { images } : {}),
167+
});
168+
const question = (id: string) => ({
169+
// Vercel's gateway calls the type `boolean`.
170+
type: provider === 'vercel' ? 'boolean' : 'noul',
171+
instructions:
172+
`A reader filters their X timeline with this instruction, in their own words: ` +
173+
`"${settings.criteria}". Follow it literally: where it names what to hide, hide ` +
174+
`those posts; where it names what to keep, hide every post it does not cover. ` +
175+
`Judge a reply on its own merits, not on what it replies to. Where the reader ` +
176+
`decided similar posts themselves, judge the same way.` +
177+
(alone
178+
? ''
179+
: ` Judge only the post with id "${id}", on its own merits, whatever the other posts say.`),
180+
criteria: {
181+
true: 'Hide this post: the instruction says the reader does not want it.',
182+
false: 'Show this post: the instruction does not rule it out.',
183+
},
184+
});
152185
return {
153-
// Vercel's gateway names the model in a header and calls the type `boolean`.
186+
// Vercel's gateway names the model in a header.
154187
...(provider === 'vercel' ? {} : { model: classifierProviders[provider].model }),
155188
state: {
156-
post: {
157-
author: `@${post.handle}`,
158-
text: post.text || '(no text)',
159-
...(post.context ? { replying_to: post.context } : {}),
160-
...(images.length ? { images } : {}),
161-
},
189+
...(alone
190+
? { post: shape(entries[0]!) }
191+
: { posts: Object.fromEntries(entries.map((entry, i) => [idOf(i, false), shape(entry)])) }),
162192
...(settings.corrections.length
163193
? {
164194
reader_decisions_on_similar_posts: settings.corrections.map((entry) => ({
@@ -169,21 +199,9 @@ export function decisionBody(settings: Settings, post: Post, images: readonly st
169199
}
170200
: {}),
171201
},
172-
questions: {
173-
hide: {
174-
type: provider === 'vercel' ? 'boolean' : 'noul',
175-
instructions:
176-
`A reader filters their X timeline with this instruction, in their own words: ` +
177-
`"${settings.criteria}". Follow it literally: where it names what to hide, hide ` +
178-
`those posts; where it names what to keep, hide every post it does not cover. ` +
179-
`Judge a reply on its own merits, not on what it replies to. Where the reader ` +
180-
`decided similar posts themselves, judge the same way.`,
181-
criteria: {
182-
true: 'Hide this post: the instruction says the reader does not want it.',
183-
false: 'Show this post: the instruction does not rule it out.',
184-
},
185-
},
186-
},
202+
questions: Object.fromEntries(
203+
entries.map((_, i) => [idOf(i, alone), question(idOf(i, alone))]),
204+
),
187205
};
188206
}
189207

@@ -198,11 +216,11 @@ export function verdictFor(
198216
return readVerdict(lines, { key, hide: false, reason: '', score });
199217
}
200218

201-
/** One question to the classifier: the probability, its cost, and what went
202-
* over the wire. */
203-
function decide(settings: Settings, post: Post, images: readonly string[]) {
219+
/** One request: a probability per post (none where the classifier skipped
220+
* one), what it cost, and what went over the wire. */
221+
function decide(settings: Settings, entries: readonly Entry[]) {
204222
return Effect.gen(function* () {
205-
const body = decisionBody(settings, post, images);
223+
const body = decisionBody(settings, entries);
206224
const started = performance.now();
207225
const raw = yield* requestJson(
208226
settings,
@@ -220,76 +238,138 @@ function decide(settings: Settings, post: Post, images: readonly string[]) {
220238
const tokens =
221239
(usage?.input_tokens ?? usage?.inputTokens ?? 0) +
222240
(usage?.output_tokens ?? usage?.outputTokens ?? 0);
223-
const probability = data.answers.hide?.noul ?? data.answers.hide?.probability;
224-
if (probability === undefined) {
225-
return yield* new ProviderError({ message: 'The classifier did not answer.', tokens });
226-
}
227-
return { probability, tokens, ms, body, raw };
241+
const alone = entries.length === 1;
242+
const probabilities = entries.map((_, i) => {
243+
const answer = data.answers[idOf(i, alone)];
244+
return answer?.noul ?? answer?.probability;
245+
});
246+
return { probabilities, tokens, ms, body, raw, size: entries.length };
228247
});
229248
}
249+
type Pass = Effect.Effect.Success<ReturnType<typeof decide>>;
250+
type Answer = { probability: number; pass: Pass };
230251

231-
/** Text first; images only where the reader wants a second look. A post whose
232-
* text-only score lands outside the reader's range stands on it and never pays
233-
* for a description. One inside it is asked again with its images described,
234-
* and that second answer is the verdict. A range covering every score skips
235-
* the text-only pass, which could settle nothing. A post with no text has nothing to
236-
* judge without its images, so it goes straight to them. */
237-
function judge(settings: Settings, post: Post) {
252+
/** A group of posts, judged together. Text first; images only where the reader
253+
* wants a second look. A post whose text-only score lands outside the reader's
254+
* range stands on it and never pays for a description. The ones inside it have
255+
* their images described and are asked again, together, and that second answer
256+
* is the verdict. A range covering every score skips the text-only pass, which
257+
* could settle nothing, and a post with no text has nothing to judge without
258+
* its images, so both go straight to the second request. */
259+
function judge(settings: Settings, posts: readonly Post[]) {
238260
return Effect.gen(function* () {
239261
const started = performance.now();
240-
const wanted = settings.analyzeImages ? post.images : [];
241262
const always = settings.imageCheckFrom <= 0 && settings.imageCheckBelow >= 1;
242-
const textOnly =
243-
wanted.length > 0 && post.text.trim() && !always ? yield* decide(settings, post, []) : null;
244-
const needsImages =
245-
wanted.length > 0 && (!textOnly || imagesWanted(settings, textOnly.probability));
246-
const described = needsImages
247-
? yield* Effect.forEach(wanted, (url) => describe(settings, url), {
248-
concurrency: 'unbounded',
249-
})
250-
: [];
251-
const seen = described.map((image) => image.text).filter(Boolean);
263+
const wanted = posts.map((post) => (settings.analyzeImages ? post.images : []));
264+
const textFirst = posts.map(
265+
(post, i) => wanted[i]!.length === 0 || (Boolean(post.text.trim()) && !always),
266+
);
267+
let tokens = 0;
268+
let failure: ProviderError | undefined;
269+
const ask = (indexes: readonly number[], images: (i: number) => readonly string[]) =>
270+
Effect.gen(function* () {
271+
const answers = new Map<number, Answer>();
272+
if (!indexes.length) return answers;
273+
const result = yield* Effect.either(
274+
decide(
275+
settings,
276+
indexes.map((i) => ({ post: posts[i]!, images: images(i) })),
277+
),
278+
);
279+
if (result._tag === 'Left') {
280+
failure ??=
281+
result.left instanceof ProviderError
282+
? result.left
283+
: new ProviderError({ message: 'Classifier request failed.' });
284+
return answers;
285+
}
286+
tokens += result.right.tokens;
287+
indexes.forEach((i, at) => {
288+
const probability = result.right.probabilities[at];
289+
if (probability !== undefined) answers.set(i, { probability, pass: result.right });
290+
});
291+
return answers;
292+
});
293+
294+
const first = yield* ask(
295+
posts.flatMap((_, i) => (textFirst[i] ? [i] : [])),
296+
() => [],
297+
);
298+
const looking = posts.flatMap((_, i) => {
299+
if (!wanted[i]!.length) return [];
300+
const text = first.get(i);
301+
// A failed text-only request is not a reason to pay for images too.
302+
if (textFirst[i]) return text && imagesWanted(settings, text.probability) ? [i] : [];
303+
return [i];
304+
});
305+
const described = new Map(
306+
yield* Effect.forEach(
307+
looking,
308+
(i) =>
309+
Effect.forEach(wanted[i]!, (url) => describe(settings, url), {
310+
concurrency: 'unbounded',
311+
}).pipe(Effect.map((images) => [i, images] as const)),
312+
{ concurrency: 'unbounded' },
313+
),
314+
);
315+
for (const images of described.values()) {
316+
tokens += images.reduce((sum, image) => sum + image.tokens, 0);
317+
}
318+
const seen = (i: number) => (described.get(i) ?? []).map((image) => image.text).filter(Boolean);
252319
// No description came back: asking again would only repeat the text's answer.
253-
const final = textOnly && seen.length === 0 ? textOnly : yield* decide(settings, post, seen);
254-
const tokens =
255-
final.tokens +
256-
(textOnly && textOnly !== final ? textOnly.tokens : 0) +
257-
described.reduce((sum, image) => sum + image.tokens, 0);
258-
const verdict = verdictFor(post.key, final.probability, settings);
259-
if (!settings.debug) return { verdict, tokens };
260-
const trace: Trace = {
261-
source: 'classifier',
262-
model: classifierProviders[settings.classifierProvider].model,
263-
at: Date.now(),
264-
totalMs: performance.now() - started,
265-
requestMs: final.ms,
266-
tokens,
267-
images: wanted.map((url, index) => {
268-
const image = described[index];
269-
if (!image) return { url, skipped: true };
270-
return {
271-
url,
272-
model: settings.visionModel,
273-
description: image.text,
274-
ms: image.ms,
275-
cached: image.cached,
276-
...(image.failed ? { failed: true } : {}),
277-
};
278-
}),
279-
...(textOnly && textOnly !== final
280-
? {
281-
textPass: {
282-
score: textOnly.probability,
283-
ms: textOnly.ms,
284-
request: textOnly.body,
285-
response: textOnly.raw,
286-
},
287-
}
288-
: {}),
289-
request: final.body,
290-
response: final.raw,
291-
};
292-
return { verdict: { ...verdict, trace }, tokens };
320+
const second = yield* ask(
321+
looking.filter((i) => !first.has(i) || seen(i).length > 0),
322+
seen,
323+
);
324+
325+
const totalMs = performance.now() - started;
326+
const verdicts = posts.map((post, i): Verdict => {
327+
const final = second.get(i) ?? first.get(i);
328+
if (!final) return { key: post.key, hide: false, reason: '', failed: true };
329+
const verdict = verdictFor(post.key, final.probability, settings);
330+
if (!settings.debug) return verdict;
331+
const text = second.has(i) ? first.get(i) : undefined;
332+
const trace: Trace = {
333+
source: 'classifier',
334+
model: classifierProviders[settings.classifierProvider].model,
335+
at: Date.now(),
336+
totalMs,
337+
requestMs: final.pass.ms,
338+
batch: final.pass.size,
339+
// What this post's requests cost. A group's request is shared, so its
340+
// whole cost shows on every post that was in it.
341+
tokens:
342+
final.pass.tokens +
343+
(text?.pass.tokens ?? 0) +
344+
(described.get(i) ?? []).reduce((sum, image) => sum + image.tokens, 0),
345+
images: wanted[i]!.map((url, at) => {
346+
const image = described.get(i)?.[at];
347+
if (!image) return { url, skipped: true };
348+
return {
349+
url,
350+
model: settings.visionModel,
351+
description: image.text,
352+
ms: image.ms,
353+
cached: image.cached,
354+
...(image.failed ? { failed: true } : {}),
355+
};
356+
}),
357+
...(text
358+
? {
359+
textPass: {
360+
score: text.probability,
361+
ms: text.pass.ms,
362+
request: text.pass.body,
363+
response: text.pass.raw,
364+
},
365+
}
366+
: {}),
367+
request: final.pass.body,
368+
response: final.pass.raw,
369+
};
370+
return { ...verdict, trace };
371+
});
372+
return { verdicts, tokens, failure };
293373
});
294374
}
295375

@@ -300,31 +380,27 @@ export function classifyWithClassifier(settings: Settings, items: readonly Post[
300380
message: `Set a ${classifierProviders[settings.classifierProvider].label} key and filter criteria first.`,
301381
});
302382
}
383+
// The page already sends groups of this size; a larger call is split here.
384+
const groups: Post[][] = [];
385+
for (let i = 0; i < items.length; i += settings.batchSize) {
386+
groups.push(items.slice(i, i + settings.batchSize));
387+
}
303388
const semaphore = gateFor(settings.classifierConcurrency);
304389
const results = yield* Effect.forEach(
305-
items,
306-
(post) => Effect.either(semaphore.withPermits(1)(judge(settings, post))),
390+
groups,
391+
(group) => semaphore.withPermits(1)(judge(settings, group)),
307392
{ concurrency: 'unbounded' },
308393
);
309-
const tokens = results.reduce(
310-
(sum, result) =>
311-
sum + (result._tag === 'Right' ? result.right.tokens : (result.left.tokens ?? 0)),
312-
0,
313-
);
314-
// One bad post is retried later; every post failing is a real problem.
315-
const failures = results.filter((result) => result._tag === 'Left');
316-
if (failures.length === results.length && failures[0]?._tag === 'Left') {
317-
const error = failures[0].left;
394+
const verdicts = results.flatMap((result) => result.verdicts);
395+
const tokens = results.reduce((sum, result) => sum + result.tokens, 0);
396+
// A few bad posts are retried later; every post failing is a real problem.
397+
if (verdicts.length && verdicts.every((verdict) => verdict.failed)) {
398+
const failure = results.find((result) => result.failure)?.failure;
318399
return yield* new ProviderError({
319-
message: error instanceof ProviderError ? error.message : 'Classifier request failed.',
400+
message: failure?.message ?? 'The classifier did not answer.',
320401
tokens,
321402
});
322403
}
323-
const verdicts: Verdict[] = results.map((result, index) =>
324-
result._tag === 'Right'
325-
? result.right.verdict
326-
: { key: items[index]!.key, hide: false, reason: '', failed: true },
327-
);
328404
return { verdicts, tokens };
329405
});
330406
}

0 commit comments

Comments
 (0)