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.
68import { Effect , Schema } from 'effect' ;
79import type { Post , Trace , Verdict } from '../common/post' ;
810import {
@@ -17,11 +19,10 @@ import {
1719import { ProviderError , requestJson } from './providers' ;
1820
1921export { 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. */
2526let gate : { size : number ; semaphore : Effect . Semaphore } | null = null ;
2627function 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