diff --git a/README.md b/README.md index 715d6fb..c914e06 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/classifier-deadline-runtime.test.mjs b/tests/classifier-deadline-runtime.test.mjs new file mode 100644 index 0000000..76e707c --- /dev/null +++ b/tests/classifier-deadline-runtime.test.mjs @@ -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();} +}); diff --git a/tests/classifier-deadline.test.mjs b/tests/classifier-deadline.test.mjs new file mode 100644 index 0000000..b2d7aa3 --- /dev/null +++ b/tests/classifier-deadline.test.mjs @@ -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); +}); diff --git a/worker/src/classifier-deadline.mjs b/worker/src/classifier-deadline.mjs new file mode 100644 index 0000000..01afb3b --- /dev/null +++ b/worker/src/classifier-deadline.mjs @@ -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}); + }; +} diff --git a/worker/src/index.mjs b/worker/src/index.mjs index a95ceda..fed3851 100644 --- a/worker/src/index.mjs +++ b/worker/src/index.mjs @@ -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'; @@ -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); @@ -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;