sync: 166 file da Baida98/AI@a6ac2424e11e5c320c5ff688e1ce7addac64cdab (local-fallback deploy-all)

#47
by Baida07 - opened
agents/unified_loop.py CHANGED
@@ -2231,6 +2231,58 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
2231
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2232
  except Exception as _exc:
2233
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2234
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2235
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2236
  if _fr == 'length' and on_step:
 
2231
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2232
  except Exception as _exc:
2233
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
2234
+ # BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
2235
+ # modifica answer, retry, provider routing o scoring.
2236
+ try:
2237
+ from benchmarks.shadow_telemetry import validate_and_record_shadow
2238
+ validate_and_record_shadow(
2239
+ goal=state.goal,
2240
+ answer=answer,
2241
+ metadata={
2242
+ "provider": getattr(_active_llm, "provider", None),
2243
+ "model": getattr(_active_llm, "model", None),
2244
+ "profile": getattr(_active_llm, "profile", None),
2245
+ "attempt": _llm_try,
2246
+ "latency_ms": round(_llm_elapsed, 2),
2247
+ "source": "unified_loop",
2248
+ },
2249
+ )
2250
+ except Exception as _exc:
2251
+ _logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
2252
+
2253
+ # BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
2254
+ # non estraibile/non conforme. Non aggiunge tentativi oltre il budget
2255
+ # esistente e non scatta su goal non-coding.
2256
+ if not _is_last:
2257
+ try:
2258
+ from benchmarks.validators import validate_coding_retry
2259
+ _code_validation = validate_coding_retry(
2260
+ state.goal,
2261
+ answer,
2262
+ is_last_attempt=_is_last,
2263
+ )
2264
+ if _code_validation is not None:
2265
+ state.steps.append({
2266
+ "action": f"typescript_contract_retry_{_llm_try}",
2267
+ "failure_code": _code_validation.failure_code,
2268
+ })
2269
+ _code_repair = (
2270
+ "CONTRATTO TYPESCRIPT FALLITO: "
2271
+ f"{_code_validation.failure_code}.\n"
2272
+ "Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
2273
+ "```typescript ... ``` non vuoto, completo e compilabile. "
2274
+ "Mantieni la firma e tutti i simboli richiesti dal task. "
2275
+ "Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
2276
+ )
2277
+ messages = [
2278
+ messages[0],
2279
+ {"role": "system", "content": _code_repair},
2280
+ *messages[1:],
2281
+ ]
2282
+ _error_severity = "syntax"
2283
+ continue
2284
+ except Exception as _exc:
2285
+ _logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
2286
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2287
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2288
  if _fr == 'length' and on_step:
benchmark-extended.mjs CHANGED
@@ -314,23 +314,33 @@ FSH.autonomy =
314
  // e ritenta 1 volta se la risposta è troppo corta (cold start / timeout transitorio)
315
  async function callAgentWithRetry(task, timeoutMs) {
316
  const hint = FSH[task.category];
317
- // Append FSH hint so the agent knows the expected output format
318
  const goal = hint
319
- ? `${task.prompt}
320
-
321
- ---
322
- 📌 FORMATO RISPOSTA ATTESO:
323
- ${hint}`
324
  : task.prompt;
325
  const a = await callAgent(goal, timeoutMs);
326
  // Infrastructure failures are not model answers and must not be retried/scored.
327
  if (a.failed) return a;
328
- // Retry once if response is too short (transient failure / cold start)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  if ((a.output||"").length < 40 && !(a.output||"").includes("TIMEOUT")) {
330
  if(!F_JSON) process.stdout.write(" ⟳ retry (risposta vuota)... ");
331
  await new Promise(r => setTimeout(r, 4000));
332
  const a2 = await callAgent(goal, timeoutMs);
333
- if (!F_JSON) process.stdout.write("ok\n");
334
  return (a2.output||"").length > (a.output||"").length ? a2 : a;
335
  }
336
  return a;
@@ -535,13 +545,25 @@ async function runCmd(cmd,args=[],cwd="/tmp",ms=20000){
535
  });
536
  }
537
  function extractCode(out,langs){
538
- const re=/```(\w*)\r?\n([\s\S]*?)```/g; let best={code:"",len:0};
 
 
539
  for(const m of out.matchAll(re)){
540
  const l=(m[1]||"").toLowerCase(),c=m[2].trimEnd();
541
  if((langs.includes(l)||langs.includes("*"))&&c.length>best.len) best={code:c,len:c.length};
542
  }
543
  return best.code;
544
  }
 
 
 
 
 
 
 
 
 
 
545
  // ── LLM-as-Judge (GROQ/Cerebras semantic scoring) ────────────────────────────
546
  const _JUDGE_CACHE = new Map();
547
 
@@ -633,6 +655,27 @@ async function fetchGSM8K(seed){
633
  return{question:row.question,expectedAnswer:parseInt(m[1].replace(/,/g,"")),
634
  offset:hfOffset(seed,HF_GSM8K),source:"openai/gsm8k"};
635
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  async function fetchBBH(seed){
637
  const row=await hfRow("lukaemon/bbh","logical_deduction_three_objects","test",hfOffset(seed,HF_BBH,3571));
638
  if(!row?.input)return null;
@@ -672,35 +715,51 @@ async function callAgent(goal,timeoutMs=90000){
672
  if(!res.body)return{ok:false,output:"",engine,ttfa,toolCalls,durationMs:Date.now()-t0,failed:true,failureReason:"stream body mancante"};
673
  const dec=new TextDecoder(); let buf="";
674
  const rd=res.body.getReader();
675
- loop:while(true){
676
- const{done:d,value}=await rd.read(); if(d)break;
677
- buf+=dec.decode(value,{stream:true});
678
- const lines=buf.split("\n"); buf=lines.pop()??"";
679
- for(const ln of lines){
680
- if(!ln.startsWith("data:"))continue;
681
- const raw=ln.slice(5).trim();
682
- if(raw==="[DONE]")break loop;
683
- try{
684
- const ev=JSON.parse(raw),now=Date.now()-t0;
685
- if(ev.type==="tool_use"){toolCalls++;ttfa=ttfa??now;}
686
- else if(ev.type==="step_done"){ttfa=ttfa??now;}
687
- else if(ev.type==="text_chunk"&&(ev.token||ev.content)&&!done){
688
- const chunk=String(ev.token||ev.content); const trimmed=chunk.trim();
689
  if(!(trimmed.startsWith("{")&&trimmed.endsWith("}")))out+=chunk;
690
  ttfa=ttfa??now;
691
- } else if(ev.type==="task_error"){
692
- failed=true;failureReason=String(ev.error||"task_error");done=true;break loop;
693
- } else if(ev.type==="task_done"){
694
- const result=typeof ev.result==="string"?ev.result:"";
695
- if(ev.success===false||result.startsWith("[LLM_UNAVAILABLE]")||result.includes("tutti i provider configurati sono falliti")){
696
- failed=true;failureReason=result||"provider_unavailable";engine=ev.engine??engine;done=true;break loop;
697
- }
698
- if(result&&result.length>(out||"").length)out=result;
699
- engine=ev.engine??engine;done=true;break loop;
700
  }
701
- }catch{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  }
703
  }
 
 
704
  rd.cancel?.();
705
  }catch(e){
706
  if(taskId){
@@ -1357,8 +1416,7 @@ async function makeMMLU(rng,seed){
1357
  hfSource:"cais/mmlu/"+subject,hfOffset:offset,targetMs:30000,ref:REF.mmlu,
1358
  prompt:"Domanda di informatica a scelta multipla:\n\n"+row.question+"\n\nA) "+row.choices[0]+"\nB) "+row.choices[1]+"\nC) "+row.choices[2]+"\nD) "+row.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
1359
  verify:async function(o){
1360
- const m=o.match(/\*\*\(?([A-D])\)?\*\*|\bRisposta[:\s]+([A-D])\b|\b([A-D])\)/)||o.match(/\b([A-D])\b/);
1361
- const got=m?(m[1]||m[2]||m[3]||m[4]):null;
1362
  const correct=got===ansLetter;
1363
  const hasExp=o.length>60&&/perch|quindi|perche|because|quindi|questo|in quanto/i.test(o);
1364
  const judge=await judgeWithLLM(row.question,o,{
@@ -1378,8 +1436,7 @@ async function makeMMLU(rng,seed){
1378
  hfSource:"local-cs-fundamentals",targetMs:30000,ref:REF.mmlu,
1379
  prompt:"Domanda di informatica a scelta multipla:\n\n"+f.q+"\n\nA) "+f.choices[0]+"\nB) "+f.choices[1]+"\nC) "+f.choices[2]+"\nD) "+f.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
1380
  verify:async function(o){
1381
- const m=o.match(/\*\*\(?([A-D])\)?\*\*|\bRisposta[:\s]+([A-D])\b/)||o.match(/\b([A-D])\b/);
1382
- const got=m?(m[1]||m[2]||m[3]):null;
1383
  const correct=got===ansLetter;
1384
  const hasExp=o.length>60;
1385
  const judge=await judgeWithLLM(f.q,o,{
@@ -1402,7 +1459,7 @@ async function makeReasoning(rng,seed){
1402
  const gsm=await fetchGSM8K(seed);
1403
  if(gsm){
1404
  return{id:"RS",category:"reasoning",label:`GSM8K #${gsm.offset}: ${gsm.question.slice(0,50)}…`,
1405
- hfSource:gsm.source,hfOffset:gsm.offset,targetMs:50000,ref:REF.reasoning,
1406
  prompt:`Risolvi il problema matematico passo per passo.\n\n${gsm.question}\n\nFormato risposta: **#### N** (N = numero intero).`,
1407
  verify:async(o)=>{
1408
  const m=o.match(/####\s*(-?\d[\d,]*)/)||o.match(/(?:answer|risposta|risultato|total|totale|result)[:\s=]+(-?\d[\d,]+)/i)||o.match(/\*\*(-?\d[\d,]+)\*\*/);
 
314
  // e ritenta 1 volta se la risposta è troppo corta (cold start / timeout transitorio)
315
  async function callAgentWithRetry(task, timeoutMs) {
316
  const hint = FSH[task.category];
317
+ // Append FSH hint so the agent knows the expected output format.
318
  const goal = hint
319
+ ? `${task.prompt}\n\n---\n📌 FORMATO RISPOSTA ATTESO:\n${hint}`
 
 
 
 
320
  : task.prompt;
321
  const a = await callAgent(goal, timeoutMs);
322
  // Infrastructure failures are not model answers and must not be retried/scored.
323
  if (a.failed) return a;
324
+
325
+ // Reasoning retry: one hidden-contract recalculation before the final attempt.
326
+ const reasoningFailure = reasoningRetryFailure(task, a.output);
327
+ if (reasoningFailure) {
328
+ const repairGoal = `${goal}\n\n---\n🔁 CONTROLLO DI CALCOLO:\nLa risposta numerica finale non è stata accettata dal controllo deterministico (${reasoningFailure}). Ricalcola il problema indipendentemente, verifica ogni passaggio e restituisci una sola risposta finale nel formato #### N. Non assumere il risultato precedente.`;
329
+ if(!F_JSON) process.stdout.write(` ⟳ retry reasoning (${reasoningFailure})... `);
330
+ await new Promise(r => setTimeout(r, 1000));
331
+ const a2 = await callAgent(repairGoal, timeoutMs);
332
+ if (!F_JSON) process.stdout.write("ok\\n");
333
+ const secondFailure = reasoningRetryFailure(task, a2.output);
334
+ if (!a2.failed && !secondFailure) return a2;
335
+ return (a2.output||"").length > (a.output||"").length ? a2 : a;
336
+ }
337
+
338
+ // Retry once if response is too short (transient failure / cold start).
339
  if ((a.output||"").length < 40 && !(a.output||"").includes("TIMEOUT")) {
340
  if(!F_JSON) process.stdout.write(" ⟳ retry (risposta vuota)... ");
341
  await new Promise(r => setTimeout(r, 4000));
342
  const a2 = await callAgent(goal, timeoutMs);
343
+ if (!F_JSON) process.stdout.write("ok\\n");
344
  return (a2.output||"").length > (a.output||"").length ? a2 : a;
345
  }
346
  return a;
 
545
  });
546
  }
547
  function extractCode(out,langs){
548
+ // Accetta fence canonicali su nuova riga e fence compatti con contenuto sulla
549
+ // stessa riga; il backend validator usa la stessa tolleranza.
550
+ const re=/```([A-Za-z0-9_+#.-]*)[ \t]*(?:\r?\n|[ \t]+)([\s\S]*?)```/g; let best={code:"",len:0};
551
  for(const m of out.matchAll(re)){
552
  const l=(m[1]||"").toLowerCase(),c=m[2].trimEnd();
553
  if((langs.includes(l)||langs.includes("*"))&&c.length>best.len) best={code:c,len:c.length};
554
  }
555
  return best.code;
556
  }
557
+
558
+ // MMLU parser canonico — mantenere sincronizzato con backend/benchmarks/validators.py.
559
+ // La priorità replica il contratto v5, esteso con ANSWER/FINAL per risposte
560
+ // strutturate; il fallback isolato resta compatibile con il runner storico.
561
+ function extractMMLUChoice(out){
562
+ const text=String(out||"");
563
+ const m=text.match(/\*\*\(?([A-D])\)?\*\*|\b(?:ANSWER|FINAL(?:\s+ANSWER)?|RISPOSTA|CHOICE|SCELTA)\b\s*(?:IS|=|:)\s*[*`_\[(]*([A-D])[*`_\])]*|\bRisposta[:\s]+([A-D])\b|\b([A-D])\)/i)
564
+ || text.match(/\b([A-D])\b/i);
565
+ return m ? String(m[1]||m[2]||m[3]||m[4]||"").toUpperCase() || null : null;
566
+ }
567
  // ── LLM-as-Judge (GROQ/Cerebras semantic scoring) ────────────────────────────
568
  const _JUDGE_CACHE = new Map();
569
 
 
655
  return{question:row.question,expectedAnswer:parseInt(m[1].replace(/,/g,"")),
656
  offset:hfOffset(seed,HF_GSM8K),source:"openai/gsm8k"};
657
  }
658
+ function extractReasoningNumbers(text){
659
+ const source=String(text||"");
660
+ const explicit=[...source.matchAll(/(?:####|final\s+answer|answer|risposta|risultato|result|total|totale)\s*[:=]?\s*(-?\d[\d,]*(?:\.\d+)?)/gi)]
661
+ .map(m=>Number(m[1].replace(/,/g,"")));
662
+ if(explicit.length)return explicit;
663
+ const bold=[...source.matchAll(/\*\*\s*(-?\d[\d,]*(?:\.\d+)?)\s*\*\*/g)]
664
+ .map(m=>Number(m[1].replace(/,/g,"")));
665
+ if(bold.length)return bold;
666
+ const lines=[...source.matchAll(/^\s*(-?\d[\d,]*(?:\.\d+)?)\s*$/gm)]
667
+ .map(m=>Number(m[1].replace(/,/g,"")));
668
+ return lines.length?lines.slice(-1):[];
669
+ }
670
+ function reasoningRetryFailure(task,output){
671
+ if(task.category!=="reasoning"||!Number.isFinite(task.expectedAnswer))return null;
672
+ const candidates=extractReasoningNumbers(output);
673
+ const distinct=[...new Set(candidates)];
674
+ if(!distinct.length)return"answer_missing";
675
+ if(distinct.length>1)return"calculation_conflict";
676
+ return distinct[0]!==task.expectedAnswer?"wrong_numeric_answer":null;
677
+ }
678
+
679
  async function fetchBBH(seed){
680
  const row=await hfRow("lukaemon/bbh","logical_deduction_three_objects","test",hfOffset(seed,HF_BBH,3571));
681
  if(!row?.input)return null;
 
715
  if(!res.body)return{ok:false,output:"",engine,ttfa,toolCalls,durationMs:Date.now()-t0,failed:true,failureReason:"stream body mancante"};
716
  const dec=new TextDecoder(); let buf="";
717
  const rd=res.body.getReader();
718
+ let stopStream=false;
719
+ const processSSEData=(raw)=>{
720
+ if(!raw||raw==="[DONE]")return raw==="[DONE]";
721
+ try{
722
+ const ev=JSON.parse(raw),now=Date.now()-t0;
723
+ // Backend emits `event`; tolerate legacy/proxy clients that emit `type`.
724
+ const eventType=ev.type||ev.event||"";
725
+ if(eventType==="tool_use"){toolCalls++;ttfa=ttfa??now;}
726
+ else if(eventType==="step_done"){ttfa=ttfa??now;}
727
+ else if(eventType==="text_chunk"&&!done){
728
+ const value=ev.token??ev.content;
729
+ if(value!==undefined&&value!==null){
730
+ const chunk=String(value),trimmed=chunk.trim();
 
731
  if(!(trimmed.startsWith("{")&&trimmed.endsWith("}")))out+=chunk;
732
  ttfa=ttfa??now;
 
 
 
 
 
 
 
 
 
733
  }
734
+ } else if(eventType==="task_error"){
735
+ failed=true;failureReason=String(ev.error||"task_error");done=true;return true;
736
+ } else if(eventType==="task_done"){
737
+ const rawResult=ev.result??ev.output??ev.answer??ev.content??"";
738
+ const result=typeof rawResult==="string"?rawResult:String(rawResult||"");
739
+ if(ev.success===false||result.startsWith("[LLM_UNAVAILABLE]")||result.includes("tutti i provider configurati sono falliti")){
740
+ failed=true;failureReason=result||"provider_unavailable";engine=ev.engine??engine;done=true;return true;
741
+ }
742
+ if(result&&result.length>(out||"").length)out=result;
743
+ engine=ev.engine??engine;done=true;return true;
744
+ }
745
+ }catch{}
746
+ return false;
747
+ };
748
+ while(!stopStream){
749
+ const{done:d,value}=await rd.read();
750
+ if(d){
751
+ buf+=dec.decode();
752
+ break;
753
+ }
754
+ buf+=dec.decode(value,{stream:true});
755
+ const lines=buf.split(/\r?\n/); buf=lines.pop()??"";
756
+ for(const ln of lines){
757
+ if(!ln.startsWith("data:"))continue;
758
+ if(processSSEData(ln.slice(5).trim())){stopStream=true;break;}
759
  }
760
  }
761
+ // A proxy may close after a final unterminated data line.
762
+ if(!stopStream&&buf.trim().startsWith("data:"))processSSEData(buf.trim().slice(5).trim());
763
  rd.cancel?.();
764
  }catch(e){
765
  if(taskId){
 
1416
  hfSource:"cais/mmlu/"+subject,hfOffset:offset,targetMs:30000,ref:REF.mmlu,
1417
  prompt:"Domanda di informatica a scelta multipla:\n\n"+row.question+"\n\nA) "+row.choices[0]+"\nB) "+row.choices[1]+"\nC) "+row.choices[2]+"\nD) "+row.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
1418
  verify:async function(o){
1419
+ const got=extractMMLUChoice(o);
 
1420
  const correct=got===ansLetter;
1421
  const hasExp=o.length>60&&/perch|quindi|perche|because|quindi|questo|in quanto/i.test(o);
1422
  const judge=await judgeWithLLM(row.question,o,{
 
1436
  hfSource:"local-cs-fundamentals",targetMs:30000,ref:REF.mmlu,
1437
  prompt:"Domanda di informatica a scelta multipla:\n\n"+f.q+"\n\nA) "+f.choices[0]+"\nB) "+f.choices[1]+"\nC) "+f.choices[2]+"\nD) "+f.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
1438
  verify:async function(o){
1439
+ const got=extractMMLUChoice(o);
 
1440
  const correct=got===ansLetter;
1441
  const hasExp=o.length>60;
1442
  const judge=await judgeWithLLM(f.q,o,{
 
1459
  const gsm=await fetchGSM8K(seed);
1460
  if(gsm){
1461
  return{id:"RS",category:"reasoning",label:`GSM8K #${gsm.offset}: ${gsm.question.slice(0,50)}…`,
1462
+ hfSource:gsm.source,hfOffset:gsm.offset,expectedAnswer:gsm.expectedAnswer,targetMs:50000,ref:REF.reasoning,
1463
  prompt:`Risolvi il problema matematico passo per passo.\n\n${gsm.question}\n\nFormato risposta: **#### N** (N = numero intero).`,
1464
  verify:async(o)=>{
1465
  const m=o.match(/####\s*(-?\d[\d,]*)/)||o.match(/(?:answer|risposta|risultato|total|totale|result)[:\s=]+(-?\d[\d,]+)/i)||o.match(/\*\*(-?\d[\d,]+)\*\*/);
benchmarks/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic benchmark validators and observe-only model discovery."""
2
+
3
+ from .model_watch_adapter import (
4
+ CatalogResult,
5
+ CatalogStatus,
6
+ GeminiModelsAdapter,
7
+ ObserveOnlyModelsAdapter,
8
+ ProfileScan,
9
+ ProviderProfile,
10
+ scan_profiles,
11
+ )
12
+ from .shadow_telemetry import (
13
+ shadow_enabled,
14
+ validate_and_record_shadow,
15
+ )
16
+ from .validators import (
17
+ ValidationResult,
18
+ validate_coding_output,
19
+ validate_coding_retry,
20
+ validate_mmlu_output,
21
+ validate_reasoning_output,
22
+ validate_reasoning_retry,
23
+ )
24
+
25
+ __all__ = [
26
+ "CatalogResult",
27
+ "CatalogStatus",
28
+ "GeminiModelsAdapter",
29
+ "ObserveOnlyModelsAdapter",
30
+ "ProfileScan",
31
+ "ProviderProfile",
32
+ "scan_profiles",
33
+ "ValidationResult",
34
+ "shadow_enabled",
35
+ "validate_and_record_shadow",
36
+ "validate_coding_output",
37
+ "validate_coding_retry",
38
+ "validate_mmlu_output",
39
+ "validate_reasoning_output",
40
+ "validate_reasoning_retry",
41
+ ]
benchmarks/model_watch_adapter.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Observe-only model catalog adapters.
2
+
3
+ This module performs discovery only. It never updates ai_providers, selects a
4
+ fallback, or persists credentials. Callers can use CatalogResult as an audit
5
+ record and decide separately whether a later approval/apply phase is allowed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+ import asyncio
11
+ from enum import Enum
12
+ import json
13
+ import re
14
+ from typing import Any, Mapping, Optional
15
+
16
+ import httpx
17
+
18
+
19
+ class CatalogStatus(str, Enum):
20
+ AVAILABLE = "available"
21
+ UNAUTHORIZED = "unauthorized"
22
+ FORBIDDEN = "forbidden"
23
+ RATE_LIMITED = "rate_limited"
24
+ PROVIDER_ERROR = "provider_error"
25
+ TIMEOUT = "timeout"
26
+ NETWORK_ERROR = "network_error"
27
+ MALFORMED = "malformed"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ProviderProfile:
32
+ provider: str
33
+ profile: str
34
+ base_url: str
35
+ api_key: str
36
+ default_model: str
37
+ auth_mode: str = "bearer" # bearer | query_key
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CatalogResult:
42
+ provider: str
43
+ profile: str
44
+ status: CatalogStatus
45
+ http_status: Optional[int] = None
46
+ models: tuple[str, ...] = ()
47
+ default_available: Optional[bool] = None
48
+ retry_after_seconds: Optional[int] = None
49
+ detail: str = ""
50
+ checked_url: str = ""
51
+ metadata: Mapping[str, Any] = field(default_factory=dict)
52
+
53
+ @property
54
+ def should_auto_apply(self) -> bool:
55
+ """Observe-only invariant: this adapter can never authorize a write."""
56
+ return False
57
+
58
+ def as_audit_record(self) -> dict[str, Any]:
59
+ return {
60
+ "provider": self.provider,
61
+ "profile": self.profile,
62
+ "status": self.status.value,
63
+ "http_status": self.http_status,
64
+ "model_count": len(self.models),
65
+ "default_available": self.default_available,
66
+ "retry_after_seconds": self.retry_after_seconds,
67
+ "detail": self.detail[:240],
68
+ "checked_url": self.checked_url,
69
+ "metadata": dict(self.metadata),
70
+ }
71
+
72
+
73
+ _RETRY_AFTER_SECONDS = re.compile(r"^\s*(\d+)\s*$")
74
+
75
+
76
+ def models_url(base_url: str) -> str:
77
+ """Normalize common OpenAI-compatible base URLs to a models endpoint."""
78
+ value = base_url.rstrip("/")
79
+ for suffix in ("/chat/completions", "/completions"):
80
+ if value.endswith(suffix):
81
+ value = value[: -len(suffix)]
82
+ if not value.endswith("/models"):
83
+ value += "/models"
84
+ return value
85
+
86
+
87
+ def _retry_after(headers: Mapping[str, str]) -> Optional[int]:
88
+ raw = headers.get("retry-after") or headers.get("Retry-After")
89
+ if not raw:
90
+ return None
91
+ match = _RETRY_AFTER_SECONDS.match(raw)
92
+ return int(match.group(1)) if match else None
93
+
94
+
95
+ def _safe_detail(response: httpx.Response) -> str:
96
+ """Return bounded provider detail without authorization headers or secrets."""
97
+ try:
98
+ payload = response.json()
99
+ if isinstance(payload, Mapping):
100
+ for key in ("error", "message", "detail", "code"):
101
+ value = payload.get(key)
102
+ if value is not None:
103
+ return str(value)[:240]
104
+ return json.dumps(payload, ensure_ascii=True)[:240]
105
+ except Exception:
106
+ return response.text[:240]
107
+
108
+
109
+ def _models_from_gemini_payload(payload: Any) -> tuple[str, ...] | None:
110
+ """Parse Gemini's native {models: [{name: 'models/<id>'}]} payload."""
111
+ if not isinstance(payload, Mapping) or not isinstance(payload.get("models"), list):
112
+ return None
113
+ models: list[str] = []
114
+ for item in payload["models"]:
115
+ if not isinstance(item, Mapping):
116
+ continue
117
+ name = item.get("name") or item.get("baseModelId")
118
+ if isinstance(name, str) and name.strip():
119
+ normalized = name.strip()
120
+ if normalized.startswith("models/"):
121
+ normalized = normalized[len("models/"):]
122
+ models.append(normalized)
123
+ return tuple(dict.fromkeys(models))
124
+
125
+
126
+ def _models_from_payload(payload: Any) -> tuple[str, ...] | None:
127
+ if isinstance(payload, Mapping):
128
+ items = payload.get("data")
129
+ else:
130
+ items = payload
131
+ if not isinstance(items, list):
132
+ return None
133
+ models: list[str] = []
134
+ for item in items:
135
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"].strip():
136
+ models.append(item["id"].strip())
137
+ return tuple(dict.fromkeys(models))
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class ProfileScan:
142
+ """Results plus profiles skipped because their provider returned 429."""
143
+ results: tuple[CatalogResult, ...]
144
+ skipped_rate_limited: tuple[CatalogResult, ...] = ()
145
+
146
+
147
+ class ObserveOnlyModelsAdapter:
148
+ """Fetch a provider catalog and classify the result; never mutates state."""
149
+
150
+ def __init__(self, *, timeout_seconds: float = 8.0, client: httpx.AsyncClient | None = None):
151
+ self.timeout_seconds = timeout_seconds
152
+ self._client = client
153
+
154
+ async def list_models(self, profile: ProviderProfile) -> CatalogResult:
155
+ url = models_url(profile.base_url)
156
+ headers = {"Accept": "application/json"}
157
+ params: dict[str, str] = {}
158
+ if profile.auth_mode == "query_key":
159
+ params["key"] = profile.api_key
160
+ else:
161
+ headers["Authorization"] = f"Bearer {profile.api_key}"
162
+
163
+ owns_client = self._client is None
164
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
165
+ try:
166
+ response = await client.get(url, headers=headers, params=params)
167
+ status = response.status_code
168
+ if status == 401:
169
+ return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
170
+ if status == 403:
171
+ return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
172
+ if status == 429:
173
+ return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
174
+ if 500 <= status <= 599:
175
+ return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
176
+ if status != 200:
177
+ return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
178
+ try:
179
+ payload = response.json()
180
+ except (ValueError, json.JSONDecodeError):
181
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
182
+ models = _models_from_payload(payload)
183
+ if models is None:
184
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing data list")
185
+ return CatalogResult(
186
+ provider=profile.provider,
187
+ profile=profile.profile,
188
+ status=CatalogStatus.AVAILABLE,
189
+ http_status=status,
190
+ models=models,
191
+ default_available=profile.default_model in models,
192
+ checked_url=url,
193
+ detail="catalog fetched",
194
+ )
195
+ except httpx.TimeoutException as exc:
196
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
197
+ except httpx.RequestError as exc:
198
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
199
+ finally:
200
+ if owns_client:
201
+ await client.aclose()
202
+
203
+ @staticmethod
204
+ def _result(profile: ProviderProfile, url: str, status: CatalogStatus, response: httpx.Response, *, detail: str = "") -> CatalogResult:
205
+ return CatalogResult(
206
+ provider=profile.provider,
207
+ profile=profile.profile,
208
+ status=status,
209
+ http_status=response.status_code,
210
+ retry_after_seconds=_retry_after(response.headers) if status == CatalogStatus.RATE_LIMITED else None,
211
+ detail=detail or _safe_detail(response),
212
+ checked_url=url,
213
+ )
214
+
215
+
216
+ class GeminiModelsAdapter(ObserveOnlyModelsAdapter):
217
+ """Observe-only adapter for Gemini's native ``models`` catalog."""
218
+
219
+ async def list_models(self, profile: ProviderProfile) -> CatalogResult:
220
+ url = models_url(profile.base_url)
221
+ headers = {"Accept": "application/json"}
222
+ params = {"key": profile.api_key}
223
+ owns_client = self._client is None
224
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
225
+ try:
226
+ response = await client.get(url, headers=headers, params=params)
227
+ status = response.status_code
228
+ if status == 401:
229
+ return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
230
+ if status == 403:
231
+ return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
232
+ if status == 429:
233
+ return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
234
+ if 500 <= status <= 599:
235
+ return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
236
+ if status != 200:
237
+ return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
238
+ try:
239
+ payload = response.json()
240
+ except (ValueError, json.JSONDecodeError):
241
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
242
+ models = _models_from_gemini_payload(payload)
243
+ if models is None:
244
+ return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing models list")
245
+ return CatalogResult(
246
+ provider=profile.provider,
247
+ profile=profile.profile,
248
+ status=CatalogStatus.AVAILABLE,
249
+ http_status=status,
250
+ models=models,
251
+ default_available=profile.default_model in models,
252
+ checked_url=url,
253
+ detail="Gemini catalog fetched",
254
+ metadata={"catalog_format": "gemini_native"},
255
+ )
256
+ except httpx.TimeoutException as exc:
257
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
258
+ except httpx.RequestError as exc:
259
+ return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
260
+ finally:
261
+ if owns_client:
262
+ await client.aclose()
263
+
264
+
265
+ async def scan_profiles(
266
+ profiles: list[ProviderProfile],
267
+ *,
268
+ adapter: ObserveOnlyModelsAdapter | None = None,
269
+ ) -> ProfileScan:
270
+ """Scan a mixed pool and isolate 429 profiles without blocking healthy ones."""
271
+ adapter = adapter or ObserveOnlyModelsAdapter()
272
+ results = await asyncio.gather(*(adapter.list_models(profile) for profile in profiles))
273
+ skipped = tuple(
274
+ replace(result, metadata={"skipped": True, "skip_reason": "rate_limited"})
275
+ for result in results
276
+ if result.status == CatalogStatus.RATE_LIMITED
277
+ )
278
+ active = tuple(result for result in results if result.status != CatalogStatus.RATE_LIMITED)
279
+ return ProfileScan(results=active, skipped_rate_limited=skipped)
benchmarks/shadow_telemetry.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-open shadow telemetry for benchmark output validators.
2
+
3
+ Shadow mode records validator outcomes only. It never changes the answer, retry
4
+ budget, provider selection, or benchmark score. Raw model output is deliberately
5
+ not persisted; only length and normalized validator evidence are stored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import threading
15
+ from typing import Any, Mapping, Optional
16
+
17
+ from .validators import ValidationResult, validate_coding_output, validate_mmlu_output
18
+
19
+
20
+ _ENABLED_VALUES = frozenset({"1", "true", "yes", "on"})
21
+ _WRITE_LOCK = threading.Lock()
22
+
23
+
24
+ def shadow_enabled() -> bool:
25
+ return os.getenv("BENCHMARK_SHADOW_MODE", "0").strip().lower() in _ENABLED_VALUES
26
+
27
+
28
+ def infer_benchmark_category(goal: Any) -> Optional[str]:
29
+ """Infer only the two supported benchmark categories from explicit markers."""
30
+
31
+ text = str(goal or "")
32
+ lowered = text.lower()
33
+ if "mmlu" in lowered or "scelta multipla" in lowered or "a/b/c/d" in lowered:
34
+ return "mmlu"
35
+ if "code_correct" in lowered or "typescript" in lowered or "```typescript" in lowered:
36
+ return "coding"
37
+ return None
38
+
39
+
40
+ def _safe_metadata(metadata: Optional[Mapping[str, Any]]) -> dict[str, Any]:
41
+ allowed = {
42
+ "provider",
43
+ "model",
44
+ "profile",
45
+ "attempt",
46
+ "latency_ms",
47
+ "first_token_ms",
48
+ "task_id",
49
+ "source",
50
+ }
51
+ safe: dict[str, Any] = {}
52
+ for key in allowed:
53
+ value = (metadata or {}).get(key)
54
+ if value is None:
55
+ continue
56
+ if isinstance(value, (str, int, float, bool)):
57
+ safe[key] = value
58
+ else:
59
+ safe[key] = str(value)[:120]
60
+ return safe
61
+
62
+
63
+ def _evidence_for_log(result: ValidationResult) -> dict[str, Any]:
64
+ evidence: dict[str, Any] = {}
65
+ for key, value in result.evidence.items():
66
+ if key == "source_length":
67
+ evidence[key] = value
68
+ elif key in {"candidates", "distinct_candidates", "required_symbols", "missing_symbols", "declarations", "fence_count", "languages", "extraction", "significant_lines", "correct", "expected", "has_import_or_export", "has_syntax_tokens"}:
69
+ evidence[key] = value
70
+ return evidence
71
+
72
+
73
+ def _log_path() -> Path:
74
+ return Path(os.getenv("BENCHMARK_SHADOW_LOG_PATH", "/tmp/baida98-benchmark-shadow.jsonl"))
75
+
76
+
77
+ def _append_event(event: dict[str, Any]) -> None:
78
+ path = _log_path()
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ with _WRITE_LOCK:
81
+ with path.open("a", encoding="utf-8") as handle:
82
+ handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
83
+
84
+
85
+ def validate_and_record_shadow(
86
+ *,
87
+ goal: Any,
88
+ answer: Any,
89
+ metadata: Optional[Mapping[str, Any]] = None,
90
+ ) -> Optional[ValidationResult]:
91
+ """Validate and record a supported benchmark response in fail-open shadow mode."""
92
+
93
+ if not shadow_enabled():
94
+ return None
95
+
96
+ category = infer_benchmark_category(goal)
97
+ if category is None:
98
+ return None
99
+
100
+ if category == "mmlu":
101
+ result = validate_mmlu_output(answer)
102
+ validator = "mmlu_v1"
103
+ else:
104
+ result = validate_coding_output(answer)
105
+ validator = "coding_v1"
106
+
107
+ text = answer if isinstance(answer, str) else str(answer or "")
108
+ event = {
109
+ "schema_version": 1,
110
+ "event": "benchmark_shadow_validation",
111
+ "timestamp": datetime.now(timezone.utc).isoformat(),
112
+ "category": category,
113
+ "validator": validator,
114
+ "valid": result.valid,
115
+ "failure_code": result.failure_code,
116
+ "response_chars": len(text),
117
+ "evidence": _evidence_for_log(result),
118
+ "metadata": _safe_metadata(metadata),
119
+ }
120
+ try:
121
+ _append_event(event)
122
+ except Exception:
123
+ # Shadow telemetry must never break the agent loop.
124
+ return result
125
+ return result
benchmarks/validators.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic validators for benchmark outputs.
2
+
3
+ The validators in this module deliberately do not call an LLM or a provider. They
4
+ only normalize an output when the evidence is unambiguous and otherwise return a
5
+ stable failure code that the retry layer can act on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ import json
12
+ import re
13
+ from typing import Any, Iterable, Mapping, Optional
14
+
15
+
16
+ _MMLU_LETTERS = frozenset("ABCD")
17
+ _MMLU_EXPLICIT = re.compile(
18
+ r"\b(?:answer|答案|risposta|final(?:\s+answer)?|choice|scelta)\b\s*"
19
+ r"(?:is|=|:)\s*[*`_\[\(]*([A-D])[*`_\]\)]*",
20
+ re.IGNORECASE,
21
+ )
22
+ _MMLU_MARKED = re.compile(r"(?:^|\n|\s)(?:\(?([A-D])\)?)[\.:\)](?:\s|$)", re.IGNORECASE)
23
+ _MMLU_ISOLATED = re.compile(r"(?<![A-Za-z])([A-D])(?![A-Za-z])", re.IGNORECASE)
24
+
25
+ _CODE_FENCE = re.compile(
26
+ r"```\s*([A-Za-z0-9_+#.-]*)\s*\n?(.*?)```", re.IGNORECASE | re.DOTALL
27
+ )
28
+ _CODE_JSON_KEYS = ("code", "typescript", "source", "implementation")
29
+ _TS_DECLARATION = re.compile(
30
+ r"\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][\w$]*)",
31
+ re.MULTILINE,
32
+ )
33
+ _TS_IMPORT_EXPORT = re.compile(r"\b(?:import|export)\b")
34
+ _TS_SYNTAX_TOKENS = re.compile(r"[{}();]|=>|:\s*[A-Za-z_$][\w$<>,\[\]| ]*")
35
+ _PLACEHOLDER = re.compile(r"\b(?:TODO|TBD|your implementation|implement here)\b", re.IGNORECASE)
36
+ _REASONING_EXPLICIT = re.compile(
37
+ r"(?:####|final\s+answer|answer|risposta|risultato|result|total|totale)\s*[:=]?\s*"
38
+ r"(-?\d[\d,]*(?:\.\d+)?)",
39
+ re.IGNORECASE,
40
+ )
41
+ _REASONING_BOLD = re.compile(r"\*\*\s*(-?\d[\d,]*(?:\.\d+)?)\s*\*\*")
42
+ _REASONING_LINE_NUMBER = re.compile(r"(?m)^\s*(-?\d[\d,]*(?:\.\d+)?)\s*$")
43
+ _REASONING_FAILURES = frozenset({"answer_missing", "wrong_numeric_answer", "calculation_conflict"})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ValidationResult:
48
+ """Stable validator result consumed by shadow mode and retry logic."""
49
+
50
+ valid: bool
51
+ normalized: Optional[str] = None
52
+ failure_code: Optional[str] = None
53
+ evidence: dict[str, Any] = field(default_factory=dict)
54
+ repair_hint: Optional[str] = None
55
+
56
+ def as_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "valid": self.valid,
59
+ "normalized": self.normalized,
60
+ "failure_code": self.failure_code,
61
+ "evidence": self.evidence,
62
+ "repair_hint": self.repair_hint,
63
+ }
64
+
65
+
66
+ def _failure(code: str, *, evidence: Optional[dict[str, Any]] = None, hint: str = "") -> ValidationResult:
67
+ return ValidationResult(
68
+ valid=False,
69
+ failure_code=code,
70
+ evidence=evidence or {},
71
+ repair_hint=hint or None,
72
+ )
73
+
74
+
75
+ def _success(normalized: str, *, evidence: Optional[dict[str, Any]] = None) -> ValidationResult:
76
+ return ValidationResult(valid=True, normalized=normalized, evidence=evidence or {})
77
+
78
+
79
+ def _clean_text(raw: Any) -> str:
80
+ if raw is None:
81
+ return ""
82
+ if isinstance(raw, str):
83
+ return raw.strip()
84
+ return str(raw).strip()
85
+
86
+
87
+ def _mmlu_candidates(text: str) -> list[str]:
88
+ """Return candidates in evidence order, preserving duplicates for ambiguity checks."""
89
+ explicit = [m.group(1).upper() for m in _MMLU_EXPLICIT.finditer(text)]
90
+ if explicit:
91
+ return explicit
92
+ marked = [m.group(1).upper() for m in _MMLU_MARKED.finditer(text)]
93
+ if marked:
94
+ return marked
95
+ return [m.group(1).upper() for m in _MMLU_ISOLATED.finditer(text)]
96
+
97
+
98
+ def validate_mmlu_output(raw: Any, *, expected: Optional[str] = None) -> ValidationResult:
99
+ """Validate a multiple-choice answer without guessing from explanation prose.
100
+
101
+ Accepted outputs contain one unambiguous A/B/C/D choice. Explicit labels such
102
+ as ``ANSWER: C`` have priority over marked choices and isolated letters. If
103
+ multiple distinct candidates are present, the result is ambiguous and fails.
104
+ ``expected`` is optional and is only used to expose correctness in evidence; it
105
+ never changes the parsing result.
106
+ """
107
+
108
+ text = _clean_text(raw)
109
+ if not text:
110
+ return _failure(
111
+ "answer_missing",
112
+ hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
113
+ )
114
+
115
+ candidates = _mmlu_candidates(text)
116
+ distinct = sorted(set(candidates))
117
+ evidence: dict[str, Any] = {
118
+ "candidates": candidates,
119
+ "distinct_candidates": distinct,
120
+ "source_length": len(text),
121
+ }
122
+ if expected is not None:
123
+ normalized_expected = str(expected).strip().upper()
124
+ evidence["expected"] = normalized_expected
125
+ if normalized_expected in _MMLU_LETTERS:
126
+ evidence["correct"] = len(distinct) == 1 and distinct[0] == normalized_expected
127
+
128
+ if not candidates:
129
+ return _failure(
130
+ "answer_missing",
131
+ evidence=evidence,
132
+ hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
133
+ )
134
+ if len(distinct) != 1 or distinct[0] not in _MMLU_LETTERS:
135
+ return _failure(
136
+ "answer_ambiguous",
137
+ evidence=evidence,
138
+ hint="Remove competing choices and return one letter: A, B, C, or D.",
139
+ )
140
+
141
+ return _success(distinct[0], evidence=evidence)
142
+
143
+
144
+ def _extract_code(raw: Any) -> tuple[Optional[str], str, dict[str, Any]]:
145
+ """Extract code from a TypeScript fence or a JSON envelope."""
146
+
147
+ text = _clean_text(raw)
148
+ if not text:
149
+ return None, "none", {"source_length": 0}
150
+
151
+ try:
152
+ decoded = json.loads(text)
153
+ except (TypeError, json.JSONDecodeError):
154
+ decoded = None
155
+ if isinstance(decoded, Mapping):
156
+ for key in _CODE_JSON_KEYS:
157
+ value = decoded.get(key)
158
+ if isinstance(value, str) and value.strip():
159
+ return value.strip(), f"json:{key}", {"source_length": len(text)}
160
+
161
+ fences = _CODE_FENCE.findall(text)
162
+ if fences:
163
+ typed = [body.strip() for language, body in fences if language.lower() in {"ts", "typescript"}]
164
+ if typed:
165
+ return max(typed, key=len), "fence:typescript", {"fence_count": len(fences)}
166
+ return None, "fence:wrong-language", {"languages": [language.lower() for language, _ in fences]}
167
+
168
+ return None, "none", {"source_length": len(text)}
169
+
170
+
171
+ def _normalize_symbols(required_symbols: Iterable[str]) -> list[str]:
172
+ return [symbol.strip() for symbol in required_symbols if str(symbol).strip()]
173
+
174
+
175
+ def _parse_numeric_token(value: str) -> int | float:
176
+ normalized = value.replace(",", "").strip()
177
+ number = float(normalized) if "." in normalized else int(normalized)
178
+ return number
179
+
180
+
181
+ def _reasoning_candidates(text: str) -> tuple[list[int | float], str]:
182
+ """Extract answer candidates conservatively, preferring explicit final markers."""
183
+ explicit = [_parse_numeric_token(match.group(1)) for match in _REASONING_EXPLICIT.finditer(text)]
184
+ if explicit:
185
+ return explicit, "explicit"
186
+ bold = [_parse_numeric_token(match.group(1)) for match in _REASONING_BOLD.finditer(text)]
187
+ if bold:
188
+ return bold, "bold"
189
+ lines = [_parse_numeric_token(match.group(1)) for match in _REASONING_LINE_NUMBER.finditer(text)]
190
+ if lines:
191
+ return lines[-1:], "final_line"
192
+ return [], "none"
193
+
194
+
195
+ def validate_reasoning_output(raw: Any, *, expected: Optional[int | float] = None) -> ValidationResult:
196
+ """Validate a numeric reasoning answer without calling an LLM.
197
+
198
+ Explicit final markers have priority over intermediate arithmetic. Multiple
199
+ distinct explicit answers are classified as a conflict rather than guessed.
200
+ """
201
+ text = _clean_text(raw)
202
+ if not text:
203
+ return _failure(
204
+ "answer_missing",
205
+ hint="Show the calculation and finish with #### N, where N is the final integer.",
206
+ )
207
+
208
+ candidates, source = _reasoning_candidates(text)
209
+ distinct = list(dict.fromkeys(candidates))
210
+ evidence: dict[str, Any] = {
211
+ "candidates": candidates,
212
+ "distinct_candidates": distinct,
213
+ "source": source,
214
+ "source_length": len(text),
215
+ }
216
+ if expected is not None:
217
+ try:
218
+ normalized_expected = _parse_numeric_token(str(expected))
219
+ evidence["expected"] = normalized_expected
220
+ except ValueError:
221
+ normalized_expected = expected
222
+
223
+ if not candidates:
224
+ return _failure(
225
+ "answer_missing",
226
+ evidence=evidence,
227
+ hint="Show the calculation and finish with #### N, where N is the final integer.",
228
+ )
229
+ if len(distinct) > 1:
230
+ return _failure(
231
+ "calculation_conflict",
232
+ evidence=evidence,
233
+ hint="Recalculate the final value and provide exactly one final numeric answer.",
234
+ )
235
+
236
+ normalized = distinct[0]
237
+ if expected is not None and normalized != normalized_expected:
238
+ evidence["correct"] = False
239
+ return _failure(
240
+ "wrong_numeric_answer",
241
+ evidence=evidence,
242
+ hint="Recheck every arithmetic step and return the corrected final number.",
243
+ )
244
+
245
+ evidence["correct"] = True if expected is not None else None
246
+ return _success(str(normalized), evidence=evidence)
247
+
248
+
249
+ def validate_reasoning_retry(
250
+ goal: Any,
251
+ raw: Any,
252
+ *,
253
+ expected: Optional[int | float] = None,
254
+ is_last_attempt: bool,
255
+ ) -> Optional[ValidationResult]:
256
+ """Return a reasoning failure only when a non-final numeric retry is warranted."""
257
+ if is_last_attempt or not any(marker in str(goal or "").lower() for marker in ("reasoning", "gsm8k", "risolvi il problema matematico")):
258
+ return None
259
+ result = validate_reasoning_output(raw, expected=expected)
260
+ return result if result.failure_code in _REASONING_FAILURES else None
261
+
262
+
263
+ _CODING_RETRY_FAILURES = frozenset({
264
+ "code_missing",
265
+ "code_wrong_language",
266
+ "code_empty",
267
+ "code_placeholder",
268
+ "required_symbol_missing",
269
+ "code_syntax_suspect",
270
+ })
271
+
272
+
273
+ def is_typescript_goal(goal: Any) -> bool:
274
+ lowered = str(goal or "").lower()
275
+ return any(marker in lowered for marker in ("code_correct", "typescript", "```ts", "```typescript"))
276
+
277
+
278
+ def validate_coding_retry(goal: Any, raw: Any, *, is_last_attempt: bool) -> Optional[ValidationResult]:
279
+ """Return the failed result only when a non-final TypeScript retry is warranted."""
280
+
281
+ if is_last_attempt or not is_typescript_goal(goal):
282
+ return None
283
+ result = validate_coding_output(raw)
284
+ return result if result.failure_code in _CODING_RETRY_FAILURES else None
285
+
286
+
287
+ def validate_coding_output(
288
+ raw: Any,
289
+ *,
290
+ required_symbols: Iterable[str] = (),
291
+ min_significant_lines: int = 1,
292
+ reject_placeholders: bool = True,
293
+ ) -> ValidationResult:
294
+ """Validate extraction and minimum structural quality of TypeScript output.
295
+
296
+ This is intentionally a contract validator, not a compiler. Syntax checks are
297
+ conservative and deterministic; full compilation remains a separate isolated
298
+ integration test because it depends on the repository's TypeScript toolchain.
299
+ """
300
+
301
+ code, source, extraction_evidence = _extract_code(raw)
302
+ if code is None:
303
+ failure = "code_wrong_language" if source == "fence:wrong-language" else "code_missing"
304
+ return _failure(
305
+ failure,
306
+ evidence=extraction_evidence | {"extraction": source},
307
+ hint="Return exactly one non-empty ```typescript code block.",
308
+ )
309
+
310
+ significant_lines = [line for line in code.splitlines() if line.strip() and not line.strip().startswith("//")]
311
+ evidence: dict[str, Any] = extraction_evidence | {
312
+ "extraction": source,
313
+ "significant_lines": len(significant_lines),
314
+ "has_import_or_export": bool(_TS_IMPORT_EXPORT.search(code)),
315
+ "has_syntax_tokens": bool(_TS_SYNTAX_TOKENS.search(code)),
316
+ }
317
+
318
+ if not significant_lines or len(significant_lines) < max(1, min_significant_lines):
319
+ return _failure(
320
+ "code_empty",
321
+ evidence=evidence,
322
+ hint="Provide a complete non-empty TypeScript implementation.",
323
+ )
324
+ if reject_placeholders and _PLACEHOLDER.search(code):
325
+ return _failure(
326
+ "code_placeholder",
327
+ evidence=evidence,
328
+ hint="Replace TODO/TBD placeholders with executable TypeScript.",
329
+ )
330
+
331
+ declarations = {match.group(1) for match in _TS_DECLARATION.finditer(code)}
332
+ required = _normalize_symbols(required_symbols)
333
+ missing = [symbol for symbol in required if symbol not in declarations and not re.search(rf"\b{re.escape(symbol)}\b", code)]
334
+ evidence["declarations"] = sorted(declarations)
335
+ evidence["required_symbols"] = required
336
+ evidence["missing_symbols"] = missing
337
+ if missing:
338
+ return _failure(
339
+ "required_symbol_missing",
340
+ evidence=evidence,
341
+ hint=f"Implement and expose the required symbols: {', '.join(missing)}.",
342
+ )
343
+
344
+ if not _TS_SYNTAX_TOKENS.search(code):
345
+ return _failure(
346
+ "code_syntax_suspect",
347
+ evidence=evidence,
348
+ hint="Return syntactically structured TypeScript with declarations and delimiters.",
349
+ )
350
+
351
+ return _success(code, evidence=evidence)
models/ai_client.py CHANGED
@@ -16,6 +16,7 @@ from __future__ import annotations
16
  import asyncio
17
  import json
18
  import os
 
19
  import time as _time_mod
20
  from dataclasses import dataclass
21
  from typing import AsyncIterator, Optional, List, Tuple
@@ -62,7 +63,7 @@ _PROVIDER_DEFS = [
62
  # tier 0 — free tier veloce e affidabile
63
  {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "openai/gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
64
  {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
65
- {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.2", "tier": 0, "purpose": "reasoning"},
66
  # tier 1 — free tier con rate limit più stretti
67
  {"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "openai/gpt-oss-20b:free", "tier": 1, "purpose": "coding"},
68
  {"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
@@ -261,12 +262,26 @@ class AIClient:
261
  state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
262
  failures = int(state.get("failures", 0)) + 1
263
  severe = any(token in message for token in ("401", "403"))
264
- threshold = 1 if severe else self._breaker_threshold
 
 
 
 
265
  if failures >= threshold:
266
- cooldown = 900.0 if severe else (120.0 if any(token in message for token in ("429", "rate limit", "quota")) else self._breaker_cooldown_s)
267
  state["open_until"] = _time_mod.monotonic() + cooldown
268
  state["failures"] = failures
269
 
 
 
 
 
 
 
 
 
 
 
270
  def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
271
  """Return one rotated, healthy profile per provider endpoint group."""
272
  groups: dict[tuple[str, str], list[ProviderConfig]] = {}
@@ -286,6 +301,7 @@ class AIClient:
286
  self,
287
  purpose: str,
288
  excluded: set[str] | None = None,
 
289
  ) -> list[ProviderConfig]:
290
  """Select one healthy profile per provider, prioritizing the target purpose.
291
 
@@ -294,8 +310,9 @@ class AIClient:
294
  fallback. This prevents retry storms against an exhausted pool.
295
  """
296
  excluded = excluded or set()
 
297
  candidates = [
298
- provider for provider in self.providers
299
  if provider.name not in excluded and self._is_available(provider)
300
  ]
301
  candidates.sort(key=lambda provider: (
@@ -454,7 +471,7 @@ class AIClient:
454
 
455
  # Un profilo sano per provider: se l’intero pool primario è in rate
456
  # limit, il fallback passa automaticamente al provider successivo.
457
- providers = self._inter_provider_fallback_pool("stream")
458
  attempted: list[str] = []
459
  for provider in providers:
460
  attempted.append(provider.name)
 
16
  import asyncio
17
  import json
18
  import os
19
+ import re
20
  import time as _time_mod
21
  from dataclasses import dataclass
22
  from typing import AsyncIterator, Optional, List, Tuple
 
63
  # tier 0 — free tier veloce e affidabile
64
  {"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "openai/gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
65
  {"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
66
+ {"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.1", "tier": 0, "purpose": "reasoning"},
67
  # tier 1 — free tier con rate limit più stretti
68
  {"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "openai/gpt-oss-20b:free", "tier": 1, "purpose": "coding"},
69
  {"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
 
262
  state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
263
  failures = int(state.get("failures", 0)) + 1
264
  severe = any(token in message for token in ("401", "403"))
265
+ quota_limited = any(token in message for token in ("429", "rate limit", "quota"))
266
+ # A quota/rate-limit response is deterministic: retrying the same
267
+ # profile immediately only creates a storm. Open that profile on the
268
+ # first signal and let the provider pool move to another provider.
269
+ threshold = 1 if severe or quota_limited else self._breaker_threshold
270
  if failures >= threshold:
271
+ cooldown = 900.0 if severe else self._rate_limit_cooldown_seconds(message) if quota_limited else self._breaker_cooldown_s
272
  state["open_until"] = _time_mod.monotonic() + cooldown
273
  state["failures"] = failures
274
 
275
+ @staticmethod
276
+ def _rate_limit_cooldown_seconds(message: str) -> float:
277
+ """Return a provider reset-aware cooldown, never shorter than 15 min."""
278
+ reset_match = re.search(r"x-ratelimit-reset[^0-9]*(\d{10,13})", message, re.IGNORECASE)
279
+ if reset_match:
280
+ reset_value = float(reset_match.group(1))
281
+ reset_epoch = reset_value / 1000.0 if reset_value > 10_000_000_000 else reset_value
282
+ return max(900.0, reset_epoch - _time_mod.time())
283
+ return 900.0
284
+
285
  def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
286
  """Return one rotated, healthy profile per provider endpoint group."""
287
  groups: dict[tuple[str, str], list[ProviderConfig]] = {}
 
301
  self,
302
  purpose: str,
303
  excluded: set[str] | None = None,
304
+ providers: list[ProviderConfig] | None = None,
305
  ) -> list[ProviderConfig]:
306
  """Select one healthy profile per provider, prioritizing the target purpose.
307
 
 
310
  fallback. This prevents retry storms against an exhausted pool.
311
  """
312
  excluded = excluded or set()
313
+ source = self.providers if providers is None else providers
314
  candidates = [
315
+ provider for provider in source
316
  if provider.name not in excluded and self._is_available(provider)
317
  ]
318
  candidates.sort(key=lambda provider: (
 
471
 
472
  # Un profilo sano per provider: se l’intero pool primario è in rate
473
  # limit, il fallback passa automaticamente al provider successivo.
474
+ providers = self._inter_provider_fallback_pool("stream", providers=providers)
475
  attempted: list[str] = []
476
  for provider in providers:
477
  attempted.append(provider.name)
tests/test_ai_client_provider_unavailability.py CHANGED
@@ -26,6 +26,12 @@ class _ClientWithFailingProviders(AIClient):
26
  ]
27
  self._client_cache = {}
28
  self._rr_indices = {}
 
 
 
 
 
 
29
 
30
  def _client_for(self, _provider):
31
  return _FailingClient()
@@ -38,7 +44,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
38
  with self.assertRaises(ProviderUnavailableError) as raised:
39
  await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
40
 
41
- self.assertEqual(raised.exception.providers, ("primary", "fallback"))
42
  self.assertNotIn("api_key", str(raised.exception).lower())
43
 
44
  async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
@@ -48,7 +54,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
48
  async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
49
  pass
50
 
51
- self.assertEqual(raised.exception.providers, ("primary", "fallback"))
52
  self.assertNotIn("api_key", str(raised.exception).lower())
53
 
54
  async def test_stream_chat_expands_a_role_specific_provider_pool(self):
@@ -65,7 +71,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
65
  async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
66
  pass
67
 
68
- self.assertEqual(raised.exception.providers, ("gemini-role", "nvidia"))
69
 
70
 
71
  class RuntimeModelOverrideTests(unittest.TestCase):
 
26
  ]
27
  self._client_cache = {}
28
  self._rr_indices = {}
29
+ # Stato minimo richiesto dai percorsi chat/stream dopo l’introduzione
30
+ # del circuit breaker per profilo. Non chiama AIClient.__init__ e non
31
+ # carica provider o segreti dall’ambiente.
32
+ self._breaker = {}
33
+ self._breaker_threshold = 2
34
+ self._breaker_cooldown_s = 60.0
35
 
36
  def _client_for(self, _provider):
37
  return _FailingClient()
 
44
  with self.assertRaises(ProviderUnavailableError) as raised:
45
  await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
46
 
47
+ self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
48
  self.assertNotIn("api_key", str(raised.exception).lower())
49
 
50
  async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
 
54
  async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
55
  pass
56
 
57
+ self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
58
  self.assertNotIn("api_key", str(raised.exception).lower())
59
 
60
  async def test_stream_chat_expands_a_role_specific_provider_pool(self):
 
71
  async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
72
  pass
73
 
74
+ self.assertCountEqual(raised.exception.providers, ("gemini-role", "nvidia"))
75
 
76
 
77
  class RuntimeModelOverrideTests(unittest.TestCase):
tests/test_benchmark_validators.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from benchmarks.validators import (
4
+ validate_coding_output,
5
+ validate_coding_retry,
6
+ validate_mmlu_output,
7
+ validate_reasoning_output,
8
+ validate_reasoning_retry,
9
+ )
10
+
11
+
12
+ class MMLUValidatorTests(unittest.TestCase):
13
+ def test_accepts_explicit_answer_with_explanation(self):
14
+ result = validate_mmlu_output(
15
+ "ANSWER: C\nPerché la complessità nel caso peggiore è quadratica.",
16
+ expected="C",
17
+ )
18
+ self.assertTrue(result.valid)
19
+ self.assertEqual(result.normalized, "C")
20
+ self.assertTrue(result.evidence["correct"])
21
+
22
+ def test_accepts_marked_choice(self):
23
+ result = validate_mmlu_output("La scelta corretta è (B). La stack segue LIFO.")
24
+ self.assertTrue(result.valid)
25
+ self.assertEqual(result.normalized, "B")
26
+
27
+ def test_accepts_single_isolated_letter(self):
28
+ result = validate_mmlu_output("D")
29
+ self.assertTrue(result.valid)
30
+ self.assertEqual(result.normalized, "D")
31
+
32
+ def test_accepts_final_answer_contract_used_by_retry(self):
33
+ result = validate_mmlu_output("Final answer: D\nThe two values overflow because both are negative.")
34
+ self.assertTrue(result.valid)
35
+ self.assertEqual(result.normalized, "D")
36
+
37
+ def test_accepts_runner_bold_contract(self):
38
+ result = validate_mmlu_output("**(B)** — risposta scelta")
39
+ self.assertTrue(result.valid)
40
+ self.assertEqual(result.normalized, "B")
41
+
42
+ def test_explanation_letters_do_not_override_explicit_answer(self):
43
+ result = validate_mmlu_output("ANSWER: A. Le opzioni B, C e D sono errate.")
44
+ self.assertTrue(result.valid)
45
+ self.assertEqual(result.normalized, "A")
46
+
47
+ def test_rejects_missing_answer(self):
48
+ result = validate_mmlu_output("La spiegazione descrive il concetto ma non seleziona un'opzione.")
49
+ self.assertFalse(result.valid)
50
+ self.assertEqual(result.failure_code, "answer_missing")
51
+
52
+ def test_rejects_conflicting_explicit_answers(self):
53
+ result = validate_mmlu_output("ANSWER: A\nFinal answer: C")
54
+ self.assertFalse(result.valid)
55
+ self.assertEqual(result.failure_code, "answer_ambiguous")
56
+ self.assertEqual(result.evidence["distinct_candidates"], ["A", "C"])
57
+
58
+ def test_rejects_empty_output(self):
59
+ result = validate_mmlu_output(None)
60
+ self.assertFalse(result.valid)
61
+ self.assertEqual(result.failure_code, "answer_missing")
62
+
63
+ def test_expected_answer_only_affects_evidence(self):
64
+ result = validate_mmlu_output("ANSWER: B", expected="C")
65
+ self.assertTrue(result.valid)
66
+ self.assertFalse(result.evidence["correct"])
67
+ self.assertEqual(result.normalized, "B")
68
+
69
+
70
+ class CodingValidatorTests(unittest.TestCase):
71
+ def test_accepts_typescript_fence_and_required_symbol(self):
72
+ output = """Ecco l'implementazione:
73
+ ```typescript
74
+ export function reverseWords(value: string): string {
75
+ return value.trim().split(/\\s+/).reverse().join(' ');
76
+ }
77
+ ```
78
+ """
79
+ result = validate_coding_output(output, required_symbols=["reverseWords"], min_significant_lines=3)
80
+ self.assertTrue(result.valid)
81
+ self.assertIn("reverseWords", result.normalized)
82
+ self.assertEqual(result.evidence["missing_symbols"], [])
83
+
84
+ def test_accepts_json_envelope(self):
85
+ output = '{"language":"typescript","code":"export const add = (a: number, b: number): number => a + b;"}'
86
+ result = validate_coding_output(output, required_symbols=["add"])
87
+ self.assertTrue(result.valid)
88
+ self.assertEqual(result.evidence["extraction"], "json:code")
89
+
90
+ def test_rejects_empty_fence(self):
91
+ result = validate_coding_output("```typescript\n\n```")
92
+ self.assertFalse(result.valid)
93
+ self.assertEqual(result.failure_code, "code_empty")
94
+
95
+ def test_rejects_missing_code_block(self):
96
+ result = validate_coding_output("La soluzione è implementata nel testo seguente, ma il codice non è incluso.")
97
+ self.assertFalse(result.valid)
98
+ self.assertEqual(result.failure_code, "code_missing")
99
+
100
+ def test_rejects_wrong_language_fence(self):
101
+ result = validate_coding_output("```python\ndef add(a, b): return a + b\n```")
102
+ self.assertFalse(result.valid)
103
+ self.assertEqual(result.failure_code, "code_wrong_language")
104
+
105
+ def test_rejects_required_symbol_missing(self):
106
+ result = validate_coding_output(
107
+ "```ts\nexport function subtract(a: number, b: number): number { return a - b; }\n```",
108
+ required_symbols=["add"],
109
+ )
110
+ self.assertFalse(result.valid)
111
+ self.assertEqual(result.failure_code, "required_symbol_missing")
112
+ self.assertEqual(result.evidence["missing_symbols"], ["add"])
113
+
114
+ def test_repaired_typescript_output_passes_contract(self):
115
+ result = validate_coding_output(
116
+ "```typescript\nexport function add(a: number, b: number): number {\n return a + b;\n}\n```",
117
+ required_symbols=["add"],
118
+ min_significant_lines=3,
119
+ )
120
+ self.assertTrue(result.valid)
121
+ self.assertIsNone(result.failure_code)
122
+
123
+ def test_retry_is_requested_for_missing_typescript_before_last_attempt(self):
124
+ result = validate_coding_retry(
125
+ "code_correct: implementa TypeScript",
126
+ "La spiegazione non contiene codice.",
127
+ is_last_attempt=False,
128
+ )
129
+ self.assertIsNotNone(result)
130
+ self.assertEqual(result.failure_code, "code_missing")
131
+
132
+ def test_retry_is_not_requested_on_last_attempt(self):
133
+ result = validate_coding_retry(
134
+ "code_correct: implementa TypeScript",
135
+ "La spiegazione non contiene codice.",
136
+ is_last_attempt=True,
137
+ )
138
+ self.assertIsNone(result)
139
+
140
+ def test_retry_is_not_requested_for_non_coding_goal(self):
141
+ result = validate_coding_retry(
142
+ "Scrivi una spiegazione concettuale",
143
+ "La spiegazione non contiene codice.",
144
+ is_last_attempt=False,
145
+ )
146
+ self.assertIsNone(result)
147
+
148
+ def test_retry_is_not_requested_for_valid_typescript(self):
149
+ result = validate_coding_retry(
150
+ "code_correct: implementa TypeScript",
151
+ "```typescript\nexport const add = (a: number, b: number): number => a + b;\n```",
152
+ is_last_attempt=False,
153
+ )
154
+ self.assertIsNone(result)
155
+
156
+ def test_rejects_placeholder_implementation(self):
157
+ result = validate_coding_output(
158
+ "```typescript\nexport function add(a: number, b: number): number {\n // TODO implement here\n return 0;\n}\n```",
159
+ required_symbols=["add"],
160
+ )
161
+ self.assertFalse(result.valid)
162
+ self.assertEqual(result.failure_code, "code_placeholder")
163
+
164
+ def test_rejects_non_typescript_prose_inside_fence(self):
165
+ result = validate_coding_output("```typescript\nThis is only explanatory prose.\n```")
166
+ self.assertFalse(result.valid)
167
+ self.assertEqual(result.failure_code, "code_syntax_suspect")
168
+
169
+
170
+ class ReasoningValidatorTests(unittest.TestCase):
171
+ def test_accepts_gsm8k_contract_with_thousands_separator(self):
172
+ result = validate_reasoning_output(
173
+ "Somma i valori: 100 + 125 = 225.\n#### 225",
174
+ expected=225,
175
+ )
176
+ self.assertTrue(result.valid)
177
+ self.assertEqual(result.normalized, "225")
178
+ self.assertTrue(result.evidence["correct"])
179
+
180
+ def test_accepts_labeled_final_answer(self):
181
+ result = validate_reasoning_output("I passaggi portano al totale. Final answer: 2,250", expected=2250)
182
+ self.assertTrue(result.valid)
183
+ self.assertEqual(result.normalized, "2250")
184
+
185
+ def test_classifies_wrong_numeric_answer(self):
186
+ result = validate_reasoning_output("Calcolo completo. #### 250", expected=225)
187
+ self.assertFalse(result.valid)
188
+ self.assertEqual(result.failure_code, "wrong_numeric_answer")
189
+ self.assertFalse(result.evidence["correct"])
190
+
191
+ def test_classifies_missing_numeric_answer(self):
192
+ result = validate_reasoning_output("La spiegazione termina senza un numero finale.", expected=225)
193
+ self.assertFalse(result.valid)
194
+ self.assertEqual(result.failure_code, "answer_missing")
195
+
196
+ def test_classifies_conflicting_explicit_answers(self):
197
+ result = validate_reasoning_output("#### 250\nFinal answer: 225", expected=225)
198
+ self.assertFalse(result.valid)
199
+ self.assertEqual(result.failure_code, "calculation_conflict")
200
+ self.assertEqual(result.evidence["distinct_candidates"], [250, 225])
201
+
202
+ def test_reasoning_retry_is_requested_for_wrong_answer_before_last_attempt(self):
203
+ result = validate_reasoning_retry(
204
+ "reasoning GSM8K: risolvi il problema",
205
+ "#### 250",
206
+ expected=225,
207
+ is_last_attempt=False,
208
+ )
209
+ self.assertIsNotNone(result)
210
+ self.assertEqual(result.failure_code, "wrong_numeric_answer")
211
+
212
+ def test_reasoning_retry_is_not_requested_on_last_attempt(self):
213
+ result = validate_reasoning_retry(
214
+ "reasoning GSM8K: risolvi il problema",
215
+ "#### 250",
216
+ expected=225,
217
+ is_last_attempt=True,
218
+ )
219
+ self.assertIsNone(result)
220
+
221
+ def test_reasoning_retry_is_not_requested_for_non_reasoning_goal(self):
222
+ result = validate_reasoning_retry(
223
+ "Implementa un componente TypeScript",
224
+ "#### 250",
225
+ expected=225,
226
+ is_last_attempt=False,
227
+ )
228
+ self.assertIsNone(result)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ unittest.main()
tests/test_model_watch_adapter.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ import httpx
4
+
5
+ from benchmarks.model_watch_adapter import (
6
+ CatalogStatus,
7
+ GeminiModelsAdapter,
8
+ ObserveOnlyModelsAdapter,
9
+ ProviderProfile,
10
+ models_url,
11
+ )
12
+
13
+
14
+ class ModelWatchAdapterTests(unittest.IsolatedAsyncioTestCase):
15
+ def profile(self, **overrides):
16
+ values = {
17
+ "provider": "groq",
18
+ "profile": "A",
19
+ "base_url": "https://api.example.test/openai/v1",
20
+ "api_key": "secret-not-logged",
21
+ "default_model": "openai/gpt-oss-120b",
22
+ }
23
+ values.update(overrides)
24
+ return ProviderProfile(**values)
25
+
26
+ def adapter(self, handler):
27
+ transport = httpx.MockTransport(handler)
28
+ return ObserveOnlyModelsAdapter(client=httpx.AsyncClient(transport=transport))
29
+
30
+ async def test_catalog_available_and_default_present(self):
31
+ async def handler(request):
32
+ self.assertEqual(request.url.path, "/openai/v1/models")
33
+ self.assertEqual(request.headers["Authorization"], "Bearer secret-not-logged")
34
+ return httpx.Response(200, json={"data": [{"id": "openai/gpt-oss-120b"}, {"id": "other"}]})
35
+
36
+ adapter = self.adapter(handler)
37
+ result = await adapter.list_models(self.profile())
38
+ await adapter._client.aclose()
39
+ self.assertEqual(result.status, CatalogStatus.AVAILABLE)
40
+ self.assertTrue(result.default_available)
41
+ self.assertFalse(result.should_auto_apply)
42
+ self.assertEqual(result.as_audit_record()["model_count"], 2)
43
+
44
+ async def test_unauthorized_never_suggests_apply(self):
45
+ async def handler(_request):
46
+ return httpx.Response(401, json={"error": "invalid key"})
47
+
48
+ adapter = self.adapter(handler)
49
+ result = await adapter.list_models(self.profile())
50
+ await adapter._client.aclose()
51
+ self.assertEqual(result.status, CatalogStatus.UNAUTHORIZED)
52
+ self.assertEqual(result.http_status, 401)
53
+ self.assertFalse(result.should_auto_apply)
54
+
55
+ async def test_forbidden_is_distinct_from_unauthorized(self):
56
+ async def handler(_request):
57
+ return httpx.Response(403, json={"error": "forbidden"})
58
+
59
+ adapter = self.adapter(handler)
60
+ result = await adapter.list_models(self.profile())
61
+ await adapter._client.aclose()
62
+ self.assertEqual(result.status, CatalogStatus.FORBIDDEN)
63
+
64
+ async def test_rate_limit_preserves_retry_after_without_secret(self):
65
+ async def handler(_request):
66
+ return httpx.Response(
67
+ 429,
68
+ headers={"Retry-After": "37"},
69
+ json={"error": "quota exceeded", "key": "must-not-be-recorded"},
70
+ )
71
+
72
+ adapter = self.adapter(handler)
73
+ result = await adapter.list_models(self.profile())
74
+ await adapter._client.aclose()
75
+ self.assertEqual(result.status, CatalogStatus.RATE_LIMITED)
76
+ self.assertEqual(result.retry_after_seconds, 37)
77
+ self.assertNotIn("must-not-be-recorded", result.detail)
78
+
79
+ async def test_server_error_is_provider_error(self):
80
+ async def handler(_request):
81
+ return httpx.Response(503, text="temporarily unavailable")
82
+
83
+ adapter = self.adapter(handler)
84
+ result = await adapter.list_models(self.profile())
85
+ await adapter._client.aclose()
86
+ self.assertEqual(result.status, CatalogStatus.PROVIDER_ERROR)
87
+ self.assertEqual(result.http_status, 503)
88
+
89
+ async def test_malformed_catalog_is_not_empty_catalog(self):
90
+ async def handler(_request):
91
+ return httpx.Response(200, json={"models": [{"id": "x"}]})
92
+
93
+ adapter = self.adapter(handler)
94
+ result = await adapter.list_models(self.profile())
95
+ await adapter._client.aclose()
96
+ self.assertEqual(result.status, CatalogStatus.MALFORMED)
97
+ self.assertIsNone(result.default_available)
98
+
99
+ async def test_timeout_is_classified(self):
100
+ async def handler(_request):
101
+ raise httpx.ReadTimeout("provider timeout")
102
+
103
+ adapter = self.adapter(handler)
104
+ result = await adapter.list_models(self.profile())
105
+ await adapter._client.aclose()
106
+ self.assertEqual(result.status, CatalogStatus.TIMEOUT)
107
+
108
+ async def test_query_key_auth_does_not_use_bearer(self):
109
+ async def handler(request):
110
+ self.assertEqual(request.url.params.get("key"), "secret-not-logged")
111
+ self.assertNotIn("authorization", request.headers)
112
+ return httpx.Response(200, json=[{"id": "gemini-3.6-flash"}])
113
+
114
+ adapter = self.adapter(handler)
115
+ result = await adapter.list_models(self.profile(auth_mode="query_key", default_model="gemini-3.6-flash"))
116
+ await adapter._client.aclose()
117
+ self.assertEqual(result.status, CatalogStatus.AVAILABLE)
118
+ self.assertTrue(result.default_available)
119
+
120
+
121
+ class ModelWatchUrlTests(unittest.TestCase):
122
+ def test_normalizes_completion_url(self):
123
+ self.assertEqual(models_url("https://x/v1/chat/completions"), "https://x/v1/models")
124
+
125
+ def test_keeps_existing_models_suffix(self):
126
+ self.assertEqual(models_url("https://x/v1/models"), "https://x/v1/models")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
131
+
132
+
133
+ class GeminiModelsAdapterTests(unittest.IsolatedAsyncioTestCase):
134
+ def profile(self, profile="A", default_model="gemini-3.6-flash"):
135
+ return ProviderProfile(
136
+ provider="gemini",
137
+ profile=profile,
138
+ base_url="https://generativelanguage.googleapis.com/v1beta",
139
+ api_key="gemini-secret-not-logged",
140
+ default_model=default_model,
141
+ auth_mode="query_key",
142
+ )
143
+
144
+ async def test_native_models_payload_is_parsed_and_prefix_removed(self):
145
+ async def handler(request):
146
+ self.assertEqual(request.url.params.get("key"), "gemini-secret-not-logged")
147
+ return httpx.Response(200, json={"models": [
148
+ {"name": "models/gemini-3.6-flash"},
149
+ {"name": "models/gemini-3.5-flash"},
150
+ ]})
151
+
152
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
153
+ result = await GeminiModelsAdapter(client=client).list_models(self.profile())
154
+ await client.aclose()
155
+ self.assertEqual(result.status, CatalogStatus.AVAILABLE)
156
+ self.assertEqual(result.models, ("gemini-3.6-flash", "gemini-3.5-flash"))
157
+ self.assertTrue(result.default_available)
158
+ self.assertEqual(result.metadata["catalog_format"], "gemini_native")
159
+
160
+ async def test_rate_limited_gemini_profile_is_classified(self):
161
+ async def handler(_request):
162
+ return httpx.Response(429, headers={"Retry-After": "60"}, json={"error": "quota"})
163
+
164
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
165
+ result = await GeminiModelsAdapter(client=client).list_models(self.profile())
166
+ await client.aclose()
167
+ self.assertEqual(result.status, CatalogStatus.RATE_LIMITED)
168
+ self.assertEqual(result.retry_after_seconds, 60)
169
+
170
+ async def test_scan_profiles_skips_429_but_keeps_healthy_profiles(self):
171
+ calls = []
172
+
173
+ async def handler(request):
174
+ profile = request.url.params.get("profile")
175
+ calls.append(request.url.path)
176
+ if len(calls) == 1:
177
+ return httpx.Response(429, json={"error": "quota"})
178
+ return httpx.Response(200, json={"models": [{"name": "models/gemini-3.6-flash"}]})
179
+
180
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
181
+ adapter = GeminiModelsAdapter(client=client)
182
+ from benchmarks.model_watch_adapter import scan_profiles
183
+ scan = await scan_profiles([self.profile("A"), self.profile("B")], adapter=adapter)
184
+ await client.aclose()
185
+ self.assertEqual(len(scan.skipped_rate_limited), 1)
186
+ self.assertEqual(scan.skipped_rate_limited[0].metadata["skip_reason"], "rate_limited")
187
+ self.assertEqual(len(scan.results), 1)
188
+ self.assertEqual(scan.results[0].status, CatalogStatus.AVAILABLE)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ unittest.main()
tests/test_provider_model_defaults.py CHANGED
@@ -23,6 +23,9 @@ class ProviderModelDefaultsTests(unittest.TestCase):
23
  self.assertEqual(defaults["gemini"], "gemini-3.6-flash")
24
  self.assertEqual(defaults["cerebras"], "gpt-oss-120b")
25
  self.assertEqual(defaults["openrouter"], "openai/gpt-oss-20b:free")
 
 
 
26
 
27
  def test_retired_provider_fallbacks_are_not_reintroduced(self):
28
  defaults = set(self._provider_defaults().values())
@@ -30,6 +33,7 @@ class ProviderModelDefaultsTests(unittest.TestCase):
30
  self.assertNotIn("gemini-2.0-flash-exp", defaults)
31
  self.assertNotIn("llama-4-scout", defaults)
32
  self.assertNotIn("meta-llama/llama-4-scout:free", defaults)
 
33
 
34
 
35
  if __name__ == "__main__":
 
23
  self.assertEqual(defaults["gemini"], "gemini-3.6-flash")
24
  self.assertEqual(defaults["cerebras"], "gpt-oss-120b")
25
  self.assertEqual(defaults["openrouter"], "openai/gpt-oss-20b:free")
26
+ self.assertEqual(defaults["sambanova"], "DeepSeek-V3.1")
27
+ self.assertEqual(defaults["hf_router"], "Qwen/Qwen2.5-Coder-32B-Instruct")
28
+ self.assertEqual(defaults["nvidia"], "nvidia/nemotron-3-ultra-550b-a55b")
29
 
30
  def test_retired_provider_fallbacks_are_not_reintroduced(self):
31
  defaults = set(self._provider_defaults().values())
 
33
  self.assertNotIn("gemini-2.0-flash-exp", defaults)
34
  self.assertNotIn("llama-4-scout", defaults)
35
  self.assertNotIn("meta-llama/llama-4-scout:free", defaults)
36
+ self.assertNotIn("DeepSeek-V3.2", defaults)
37
 
38
 
39
  if __name__ == "__main__":
tests/test_provider_profile_pool.py CHANGED
@@ -1,6 +1,7 @@
1
  import json
2
  import os
3
  import types
 
4
  import unittest
5
  from unittest.mock import AsyncMock, patch
6
 
@@ -79,11 +80,30 @@ class ProviderProfilePoolTests(unittest.TestCase):
79
  self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "a")
80
  self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "b")
81
  client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
82
- client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
83
  self.assertFalse(client._is_available(profiles[1]))
84
  selected = client._execution_pool(profiles, "coding")
85
  self.assertNotEqual(selected[0].profile, "b")
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
89
  async def test_chat_falls_back_when_primary_pool_returns_errors(self):
 
1
  import json
2
  import os
3
  import types
4
+ import time
5
  import unittest
6
  from unittest.mock import AsyncMock, patch
7
 
 
80
  self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "a")
81
  self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "b")
82
  client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
 
83
  self.assertFalse(client._is_available(profiles[1]))
84
  selected = client._execution_pool(profiles, "coding")
85
  self.assertNotEqual(selected[0].profile, "b")
86
 
87
+ def test_rate_limit_reset_opens_profile_on_first_error(self):
88
+ client = AIClient()
89
+ profile = self._profiles()[0]
90
+ client._record_failure(
91
+ profile,
92
+ RuntimeError("429 free-models-per-day X-RateLimit-Reset: 4102444800000"),
93
+ )
94
+ self.assertFalse(client._is_available(profile))
95
+ self.assertGreater(
96
+ client._breaker[profile.identity]["open_until"],
97
+ time.monotonic() + 900,
98
+ )
99
+
100
+ def test_all_openrouter_profiles_are_removed_from_execution_pool(self):
101
+ client = AIClient()
102
+ profiles = self._profiles()
103
+ for profile in profiles:
104
+ client._record_failure(profile, RuntimeError("HTTP 429 free-models-per-day"))
105
+ self.assertEqual(client._execution_pool(profiles, "coding"), [])
106
+
107
 
108
  class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
109
  async def test_chat_falls_back_when_primary_pool_returns_errors(self):
tests/test_shadow_telemetry.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import tempfile
5
+ import unittest
6
+ from unittest.mock import patch
7
+
8
+ from benchmarks.shadow_telemetry import validate_and_record_shadow
9
+
10
+
11
+ class ShadowTelemetryTests(unittest.TestCase):
12
+ def test_disabled_mode_does_not_write_or_validate(self):
13
+ with tempfile.TemporaryDirectory() as directory:
14
+ path = Path(directory) / "shadow.jsonl"
15
+ with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "0", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
16
+ result = validate_and_record_shadow(goal="MMLU domanda A/B/C/D", answer="ANSWER: C")
17
+ self.assertIsNone(result)
18
+ self.assertFalse(path.exists())
19
+
20
+ def test_mmlu_failure_event_is_recorded_without_raw_answer(self):
21
+ with tempfile.TemporaryDirectory() as directory:
22
+ path = Path(directory) / "shadow.jsonl"
23
+ raw = "RISPOSTA SEGRETA: il testo non deve essere salvato"
24
+ with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "1", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
25
+ result = validate_and_record_shadow(
26
+ goal="MMLU domanda di informatica A/B/C/D",
27
+ answer=raw,
28
+ metadata={"provider": "mock", "model": "test", "secret": "must-drop"},
29
+ )
30
+ self.assertIsNotNone(result)
31
+ self.assertEqual(result.failure_code, "answer_missing")
32
+ event = json.loads(path.read_text(encoding="utf-8"))
33
+ self.assertEqual(event["category"], "mmlu")
34
+ self.assertEqual(event["failure_code"], "answer_missing")
35
+ self.assertEqual(event["metadata"], {"provider": "mock", "model": "test"})
36
+ self.assertNotIn(raw, path.read_text(encoding="utf-8"))
37
+
38
+ def test_coding_failure_event_is_recorded(self):
39
+ with tempfile.TemporaryDirectory() as directory:
40
+ path = Path(directory) / "shadow.jsonl"
41
+ with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "true", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
42
+ result = validate_and_record_shadow(
43
+ goal="code_correct: implementa in TypeScript",
44
+ answer="Non posso includere il codice.",
45
+ metadata={"attempt": 1, "latency_ms": 123.4},
46
+ )
47
+ self.assertIsNotNone(result)
48
+ self.assertEqual(result.failure_code, "code_missing")
49
+ event = json.loads(path.read_text(encoding="utf-8"))
50
+ self.assertEqual(event["validator"], "coding_v1")
51
+ self.assertEqual(event["metadata"]["attempt"], 1)
52
+ self.assertEqual(event["metadata"]["latency_ms"], 123.4)
53
+
54
+ def test_unsupported_category_is_ignored(self):
55
+ with tempfile.TemporaryDirectory() as directory:
56
+ path = Path(directory) / "shadow.jsonl"
57
+ with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "1", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
58
+ result = validate_and_record_shadow(goal="generic task", answer="output")
59
+ self.assertIsNone(result)
60
+ self.assertFalse(path.exists())
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()