Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ An existing **chat-completions-compatible** server can instead be configured wit

Compatibility varies by provider. Set `MEME_JSON_MODE=false` only if your compatible endpoint rejects `response_format`; local validation still applies. A cloud endpoint requires HTTPS, the appropriate authorized API key, and `MEME_ALLOW_REMOTE=true`. The UI identifies a non-loopback endpoint. A loopback runtime can still invoke cloud models: use a genuinely local model for private conversations.

The production Worker classifier goes through the private Free AI `FleetGateway` service binding. It requests JSON with model `auto`, passes the existing fit and perspective instructions, and validates exact labels and numeric scores locally before ranking. Managed answers use compact `[label_index,score_0,score_1,...]` tuples, with scores requested to three decimal places, to reduce completion overhead within the existing 2,000-token limit; the adapter accepts earlier object-shaped answers and returns the same normalized classifier response. The ordinal fit label is derived from the maximum validated probability, with lower-fit tie breaking, so a contradictory generated label index cannot misstate fit confidence; the separate safety decision is preserved. Invalid JSON logs only an allowlisted finish reason and bounded completion-token count, never prompts or provider content. Its existing single retry stays within the original ranking deadline. The serious-input gate abstains when its classifier response is unavailable or malformed; regular ranking keeps the established deterministic/retrieval fallbacks. A general model's probability calibration is not equivalent to Jev's ordinal scoring. Free AI's automatic access-denial recovery is tracked in [#95](https://github.com/sass-maker/free-ai/issues/95); end-to-end picker qualification remains in [#30](https://github.com/Significant-Hobbies/meme-lab/issues/30).
The production Worker classifier goes through the private Free AI `FleetGateway` service binding. It requests JSON with model `auto`, passes the existing fit and perspective instructions, and validates exact labels and numeric scores locally before ranking. Managed answers use compact `[label_index,score_0,score_1,...]` tuples, with scores requested to three decimal places, to reduce completion overhead within the existing 2,000-token limit; the adapter accepts earlier object-shaped answers and returns the same normalized classifier response. The ordinal fit label is derived from the maximum validated probability, with lower-fit tie breaking, so a contradictory generated label index cannot misstate fit confidence; the separate safety decision is preserved. Invalid JSON logs only an allowlisted finish reason and bounded completion-token count, never prompts or provider content. Its existing single retry stays within one shared 25-second classifier deadline across safety, ranking and any fallback; the safety gate retains its shorter local timeout. The serious-input gate abstains when its classifier response is unavailable or malformed; regular ranking keeps the established deterministic/retrieval fallbacks. A general model's probability calibration is not equivalent to Jev's ordinal scoring. Free AI's automatic access-denial recovery is tracked in [#95](https://github.com/sass-maker/free-ai/issues/95); end-to-end picker qualification remains in [#30](https://github.com/Significant-Hobbies/meme-lab/issues/30).

The corrected 3,000-record index stores separate `meaning` and `example` vectors, for 6,000 vectors total. It contains 1,805 static meme templates and 1,195 usage-backed reaction GIFs; it contains no National Gallery artwork or empty editing canvases. The 41-item canonical coverage list includes owner-requested staples such as “My Name Is Jeff” and “I Love You 3000.” To reseed the index, confirm string metadata indexing for `view` plus boolean metadata indexing for `control` and `core`, wait for all mutations to finish, then call the tool's `/seed` endpoint in six bounded 500-record ranges (`start=0,500,…,2500&limit=500`). The `core` flag reserves ten shortlist positions for the retained baseline without preventing the broader catalogue from contributing the other twenty. Verify the corrected index and evaluation before switching production traffic.

Expand Down
23 changes: 23 additions & 0 deletions tests/classifier-deadline-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';

const require=createRequire(import.meta.url);
const tooling=createRequire(require.resolve('wrangler/package.json'));
const {Miniflare,convertV4MiniflareOptions}=tooling('miniflare');
const {buildSync}=tooling('esbuild');
const root=fileURLToPath(new URL('../',import.meta.url));
const script=buildSync({stdin:{contents:`import {withClassifierDeadline} from './worker/src/classifier-deadline.mjs';
export default {async fetch(){
let calls=0;const bounded=withClassifierDeadline(async()=>{calls++;return Response.json({ok:true});},1000);
const first=await bounded('https://classifier.test',{signal:AbortSignal.timeout(1000)});
let aborted;try{await bounded('https://classifier.test',{signal:AbortSignal.abort()});}catch(error){aborted=error.name;}
return Response.json({first:first.status,aborted,calls});
}};`,resolveDir:root},bundle:true,write:false,format:'esm',platform:'browser'}).outputFiles[0].text;

test('actual Workerd supports combined classifier deadlines and prevents cancelled dispatch',async()=>{
const runtime=new Miniflare(convertV4MiniflareOptions({name:'classifier-deadline-regression',modules:true,compatibilityDate:'2026-09-01',script}));
try{assert.deepEqual(await (await runtime.dispatchFetch('http://localhost/')).json(),{first:200,aborted:'AbortError',calls:1});}
finally{await runtime.dispose();}
});
33 changes: 33 additions & 0 deletions tests/classifier-deadline.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {withClassifierDeadline} from '../worker/src/classifier-deadline.mjs';

test('later classifier stages cannot restart an expired pipeline deadline',async()=>{
let calls=0;
const fetchImpl=withClassifierDeadline(async(_url,init)=>{
calls++;assert.equal(init.body,'same payload');assert.equal(init.headers['content-type'],'application/json');
return Response.json({ok:true});
},10);
const init={body:'same payload',headers:{'content-type':'application/json'},signal:AbortSignal.timeout(1000)};
assert.equal((await fetchImpl('https://classifier.test',init)).status,200);
await new Promise(resolve=>setTimeout(resolve,25));
await assert.rejects(fetchImpl('https://classifier.test',init),{name:'TimeoutError'});
assert.equal(calls,1);
});

test('an in-flight classifier aborts at the shared deadline despite a longer per-stage timeout',async()=>{
const fetchImpl=withClassifierDeadline(async(_url,{signal})=>new Promise((resolve,reject)=>{
const timer=setTimeout(()=>resolve(Response.json({ok:true})),1000);
signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason);},{once:true});
}),15);
await assert.rejects(fetchImpl('https://classifier.test',{signal:AbortSignal.timeout(1000)}),{name:'TimeoutError'});
});

test('caller cancellation remains terminal before and during managed inference',async()=>{
const controller=new AbortController();let calls=0;
const fetchImpl=withClassifierDeadline(async(_url,{signal})=>{calls++;return new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));},1000);
const pending=fetchImpl('https://classifier.test',{signal:controller.signal});
controller.abort();await assert.rejects(pending,{name:'AbortError'});
await assert.rejects(fetchImpl('https://classifier.test',{signal:controller.signal}),{name:'AbortError'});
assert.equal(calls,1);
});
9 changes: 9 additions & 0 deletions worker/src/classifier-deadline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// One deadline for the whole classifier pipeline, including fallback passes.
export function withClassifierDeadline(fetchImpl,timeoutMs) {
const deadline=AbortSignal.timeout(timeoutMs);
return async(url,init={})=>{
const signal=init.signal?AbortSignal.any([deadline,init.signal]):deadline;
signal.throwIfAborted();
return fetchImpl(url,{...init,signal});
};
}
8 changes: 5 additions & 3 deletions worker/src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {catalogue} from './catalogue.stage3000.generated.mjs';
import {createGatewayClassifierFetch,hasMultiplePerspectives,humourBelongs,needsSeriousHandling,rankCandidates,rankCandidatesByPerspective,requiresFactualAnswer} from './classification.mjs';
import {withClassifierDeadline} from './classifier-deadline.mjs';
import {MAX_RECOMMENDATIONS,presentSelection,selectionFromRanking} from './recommendation.mjs';
import {retrieveCandidates} from './retrieval.mjs';
import {classifierUnavailable,reportPickerHealth} from './recommendation-health.mjs';
Expand Down Expand Up @@ -291,7 +292,7 @@ async function recommend(request,env,ctx) {
try {
const shortlist=await retrieveCandidates(env,comment,30);
const classifierFetch=typeof env.CLASSIFIER_FETCH==='function'?env.CLASSIFIER_FETCH:createGatewayClassifierFetch(env.FREE_AI);
const classifierOptions={fetchImpl:classifierFetch};
const classifierOptions={fetchImpl:withClassifierDeadline(classifierFetch,25000)};
let classifier_gate='not_needed';
const factualRequest=requiresFactualAnswer(comment);
const seriousRequest=needsSeriousHandling(comment);
Expand Down Expand Up @@ -327,8 +328,9 @@ async function recommend(request,env,ctx) {
return json({...recommendation,feedback_enabled,degraded:true});
}
}
// Managed inference scores the full 30-candidate batch, within a bounded deadline.
const rankingOptions={...classifierOptions,timeoutMs:15000,ordinalPerspectives:typeof env.CLASSIFIER_FETCH!=='function'};
// Safety, ranking and any fallback share one clock. Slow managed batches
// can finish without granting each later stage a fresh timeout window.
const rankingOptions={...classifierOptions,timeoutMs:25000,ordinalPerspectives:typeof env.CLASSIFIER_FETCH!=='function'};
let ranked;
let ranking_mode='general';
let ranking_model=CLASSIFIER_MODEL;
Expand Down
Loading