bayan-api / tests /phase10 /benchmark_runner.py
youssefreda9's picture
HF Deploy: Fix syntax error with smart quotes in popup.js
fe1e225
Raw
History Blame Contribute Delete
37.7 kB
"""
BAYAN Phase 10 — Unified Benchmark Runner
==========================================
Runs ALL gold datasets through raw models AND full pipeline.
Performs root cause attribution for every failure.
Generates regression analysis and stage interaction matrix.
Usage:
python tests/phase10/benchmark_runner.py [--url URL] [--dataset NAMES] [--out DIR]
"""
import argparse, json, time, re, os, sys
sys.stdout.reconfigure(encoding='utf-8')
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Any
import requests
DEFAULT_URL = "https://bayan10-bayan-api.hf.space"
GOLD_DIR = Path(__file__).parent / "gold_datasets"
REPORT_DIR = Path(__file__).parent / "reports"
# ═══════════════════════════════════════════════════════════════
# API Client
# ═══════════════════════════════════════════════════════════════
class API:
def __init__(self, base):
self.base = base.rstrip('/')
self.s = requests.Session()
self.s.headers['Content-Type'] = 'application/json'
def _post(self, ep, payload, timeout=180):
t0 = time.time()
try:
r = self.s.post(f"{self.base}{ep}", json=payload, timeout=timeout)
ms = int((time.time()-t0)*1000)
d = r.json(); d['_ms'] = ms; d['_status'] = r.status_code
return d
except requests.Timeout:
return {'error':'TIMEOUT','_ms':int((time.time()-t0)*1000),'_status':0}
except Exception as e:
return {'error':str(e),'_ms':int((time.time()-t0)*1000),'_status':0}
def analyze(self, text): return self._post('/api/analyze', {'text': text})
def grammar(self, text): return self._post('/api/grammar', {'text': text})
def punctuation(self, text): return self._post('/api/punctuation', {'text': text})
# ═══════════════════════════════════════════════════════════════
# Result Types
# ═══════════════════════════════════════════════════════════════
@dataclass
class BenchResult:
id: str
dataset: str
category: str
input: str
expected: str = ""
severity: str = ""
# Pipeline results
pipeline_output: str = ""
pipeline_suggestions: list = field(default_factory=list)
pipeline_timing: dict = field(default_factory=dict)
pipeline_ms: int = 0
# Raw model results
grammar_raw_output: str = ""
grammar_raw_ms: int = 0
punctuation_raw_output: str = ""
punctuation_raw_ms: int = 0
# Verdicts
pipeline_verdict: str = "" # TP, FP, TN, FN, ERROR
pipeline_detail: str = ""
# Root cause
root_cause_component: str = "" # MODEL, RULE, PIPELINE, SPAN, UI, UNKNOWN
root_cause_stage: str = "" # spelling, grammar, punctuation, integration
root_cause_detail: str = ""
# Regression
regression_type: str = "" # fix_lost, reversal, introduced_error, none
regression_detail: str = ""
# Span check
span_valid: bool = True
span_detail: str = ""
def _strip_trailing_punct(t):
if not isinstance(t, str): return ""
return re.sub(r'[.,،؛؟!:;\?\!\s]+$', '', t).strip()
def strip_punct_only(text):
"""Remove ONLY punctuation chars to compare word content."""
return re.sub(r'[.,،؛؟!:;?!\s\u060C\u061B\u061F]+', ' ', text).strip()
def words(text):
return re.sub(r'[.,،؛؟!:;?!\s]+', ' ', text).strip().split()
# ═══════════════════════════════════════════════════════════════
# Benchmark Modules
# ═══════════════════════════════════════════════════════════════
def run_spelling_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'spelling', s.get('category',''), s['input'],
s.get('expected',''), s.get('severity',''))
# Pipeline
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
r.pipeline_timing = resp.get('timing_ms', {})
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp['error']
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
original = resp.get('original', s['input'])
changed = _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(original)
error_words = s.get('error_words', [])
has_errors = len(error_words) > 0
# Span check
for sg in r.pipeline_suggestions:
actual_slice = original[sg['start']:sg['end']]
if actual_slice != sg.get('original', ''):
r.span_valid = False
r.span_detail = f"SPAN[{sg['start']}:{sg['end']}] exp='{sg.get('original','')}' got='{actual_slice}'"
break
if has_errors:
unfixed = [w for w in error_words if w in r.pipeline_output]
if unfixed:
r.pipeline_verdict = "FN"
r.pipeline_detail = f"Errors NOT fixed: {unfixed}"
else:
r.pipeline_verdict = "TP"
r.pipeline_detail = f"{len(r.pipeline_suggestions)} fixes"
else:
if changed:
# Check what changed
sugg_types = [sg.get('type','') for sg in r.pipeline_suggestions]
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
r.pipeline_verdict = "FP"
r.pipeline_detail = f"Overcorrected: {changes[:3]}"
# Root cause: if only punctuation suggestions → punctuation model
if all(t == 'punctuation' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation model added marks to correct text"
elif any(t == 'grammar' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model made unnecessary changes"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "spelling"
r.root_cause_detail = "Spelling model overcorrected"
else:
r.pipeline_verdict = "TN"
r.pipeline_detail = "Correctly unchanged"
# Root cause for FN
if r.pipeline_verdict == "FN":
r.root_cause_component = "MODEL"
r.root_cause_stage = "spelling"
r.root_cause_detail = f"Spelling model missed: {s.get('error_words',[])}"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
def _strip_diacritics(text):
"""Strip Arabic diacritics for comparison."""
return re.sub(r'[\u064B-\u065F\u0670]', '', text)
def _word_in_text(word, text):
"""Check if word appears as a standalone word in text (not as substring of another word)."""
# Strip diacritics for fair comparison
word_clean = _strip_diacritics(word)
text_clean = _strip_diacritics(text)
text_words = text_clean.split()
return word_clean in text_words
def _expected_fix_present(expected_fix, output):
"""Check if the expected fix (or any alternative) is present in the output.
expected_fix can contain / for alternatives: 'ذهبن/ذهبت' """
if not expected_fix:
return False
output_clean = _strip_diacritics(output)
output_words = output_clean.split()
alternatives = [_strip_diacritics(alt.strip()) for alt in expected_fix.split('/')]
for alt in alternatives:
if alt in output_words:
return True
return False
def run_grammar_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'grammar', s.get('category',''), s['input'],
s.get('expected_fix',''), s.get('severity',''))
# Raw grammar
resp_g = api.grammar(s['input'])
r.grammar_raw_ms = resp_g.get('_ms', 0)
r.grammar_raw_output = resp_g.get('corrected_text', resp_g.get('corrected', ''))
# Pipeline
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
r.pipeline_timing = resp.get('timing_ms', {})
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
original = resp.get('original', s['input'])
changed = _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(original)
error_words = s.get('error_words', [])
has_errors = len(error_words) > 0
expected_fix = s.get('expected_fix', '')
# Span check
for sg in r.pipeline_suggestions:
actual_slice = original[sg['start']:sg['end']]
if actual_slice != sg.get('original', ''):
r.span_valid = False
r.span_detail = f"SPAN mismatch"
break
if has_errors:
# ── Phase 12 (B2): Improved grammar comparison ──
# Use word-boundary matching instead of substring matching.
# Also check if expected_fix is present in output (sentence-level validation).
unfixed = [w for w in error_words if _word_in_text(w, r.pipeline_output)]
# Secondary check: even if error word seems present,
# check if the expected fix is ALSO present (grammar may have
# added the fix while the error word exists in context)
fix_present = _expected_fix_present(expected_fix, r.pipeline_output) if expected_fix else False
if unfixed and not fix_present:
r.pipeline_verdict = "FN"
r.pipeline_detail = f"Errors NOT fixed: {unfixed}"
# Root cause: did raw grammar fix it?
raw_unfixed = [w for w in error_words if _word_in_text(w, r.grammar_raw_output)]
raw_fixed = len(raw_unfixed) == 0
if raw_fixed:
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Grammar model fixed it but pipeline lost the fix"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = f"Grammar model did not fix: {unfixed}"
else:
r.pipeline_verdict = "TP"
if fix_present:
r.pipeline_detail = f"Fixed (expected fix present)"
else:
r.pipeline_detail = f"Fixed (error word removed)"
else:
if changed:
sugg_types = [sg.get('type','') for sg in r.pipeline_suggestions]
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
r.pipeline_verdict = "FP"
r.pipeline_detail = f"Overcorrected: {changes[:3]}"
if all(t == 'punctuation' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation over-injection on correct grammar text"
else:
raw_changed = r.grammar_raw_output != s['input']
if raw_changed:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = f"Grammar model hallucinated"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation model caused FP"
else:
r.pipeline_verdict = "TN"
r.pipeline_detail = "Correctly unchanged"
# Regression: did grammar fix get lost in pipeline?
if has_errors and r.grammar_raw_output != s['input']:
raw_fixed_words = [w for w in error_words if not _word_in_text(w, r.grammar_raw_output)]
pipeline_fixed = [w for w in error_words if not _word_in_text(w, r.pipeline_output)]
lost = set(raw_fixed_words) - set(pipeline_fixed)
if lost:
r.regression_type = "fix_lost"
r.regression_detail = f"Grammar fixed {raw_fixed_words} but pipeline lost {list(lost)}"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms) raw_g={r.grammar_raw_ms}ms")
results.append(r)
return results
def run_punctuation_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'punctuation', s.get('category',''), s['input'],
severity=s.get('severity',''))
# Raw punctuation
resp_p = api.punctuation(s['input'])
r.punctuation_raw_ms = resp_p.get('_ms', 0)
r.punctuation_raw_output = resp_p.get('corrected_text', resp_p.get('corrected', ''))
# Pipeline
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
should_add = s.get('should_add_punct', False)
word_pres = s.get('expected_words_unchanged', False)
# Check word preservation — verify that PUNCTUATION suggestions don't change words.
# We check the pipeline suggestions, not raw model output, because the pipeline
# has guards (_strip_non_punctuation_changes) that prevent word modifications.
# Spelling corrections are expected and should not count as punctuation word changes.
if word_pres or s.get('category') == 'word_preservation':
punct_word_changes = [
sg for sg in r.pipeline_suggestions
if sg.get('type') == 'punctuation'
and strip_punct_only(sg.get('original', '')) != strip_punct_only(sg.get('correction', ''))
]
if punct_word_changes:
changes_str = ', '.join(f"'{sg.get('original','')}' → '{sg.get('correction','')}'" for sg in punct_word_changes[:3])
r.pipeline_verdict = "FP"
r.pipeline_detail = f"WORD CHANGE in punct: {changes_str}"
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation model changed words"
else:
r.pipeline_verdict = "TN"
r.pipeline_detail = "Words preserved (punctuation stage)"
elif should_add:
if r.punctuation_raw_output != s['input']:
r.pipeline_verdict = "TP"; r.pipeline_detail = "Punctuation added"
else:
r.pipeline_verdict = "FN"; r.pipeline_detail = "No punctuation added"
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Model failed to add punctuation"
else:
if r.punctuation_raw_output != s['input']:
r.pipeline_verdict = "FP"
r.pipeline_detail = f"Over-punctuated: '{r.punctuation_raw_output[:60]}'"
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Model modified already-punctuated text"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "Correctly unchanged"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.punctuation_raw_ms}ms)")
results.append(r)
return results
def run_entity_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'entities', s.get('category',''), s['input'],
severity=s.get('severity',''))
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
entity = s.get('entity', '')
if entity and entity.replace(' ', '') not in r.pipeline_output.replace(' ', ''):
r.pipeline_verdict = "FP"
r.pipeline_detail = f"ENTITY CORRUPTED: '{entity}' missing from output"
r.root_cause_component = "MODEL"
# Check which stage corrupted it
sugg_types = [sg.get('type','') for sg in r.pipeline_suggestions]
if 'grammar' in sugg_types:
r.root_cause_stage = "grammar"
elif 'spelling' in sugg_types:
r.root_cause_stage = "spelling"
else:
r.root_cause_stage = "punctuation"
r.root_cause_detail = f"Entity '{entity}' was modified"
elif _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(resp.get('original', s['input'])):
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
if changes:
r.pipeline_verdict = "FP"
r.pipeline_detail = f"Text modified: {changes[:3]}"
if all(sg.get('type') == 'punctuation' for sg in r.pipeline_suggestions):
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation added to entity context"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar modified entity context"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "Entity preserved"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "Entity preserved"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
def run_religious_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'religious', s.get('category',''), s['input'],
severity=s.get('severity',''))
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
original = resp.get('original', s['input'])
if _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(original):
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
r.pipeline_verdict = "FP"
r.pipeline_detail = f"RELIGIOUS TEXT MODIFIED: {changes[:3]}"
sugg_types = [sg.get('type','') for sg in r.pipeline_suggestions]
if all(t == 'punctuation' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation model modified religious text"
elif any(t == 'grammar' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model rewrote religious text"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "spelling"
r.root_cause_detail = "Spelling model modified religious text"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "Religious text preserved"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
def run_structured_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'structured', s.get('category',''), s['input'],
severity=s.get('severity',''))
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
protected = s.get('protected', '')
if protected and protected not in r.pipeline_output:
r.pipeline_verdict = "FP"
r.pipeline_detail = f"STRUCTURED CORRUPTED: '{protected}' destroyed"
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = f"Grammar model destroyed: {s.get('category','')}"
elif _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(resp.get('original', s['input'])):
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
r.pipeline_verdict = "FP"
r.pipeline_detail = f"Modified: {changes[:3]}"
if all(sg.get('type') == 'punctuation' for sg in r.pipeline_suggestions):
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = f"Model corrupted structured content: {s.get('category','')}"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "Structured content preserved"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
def run_hallucination_benchmark(api: API, samples: list) -> List[BenchResult]:
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} {s.get('category','')}... ", end="", flush=True)
r = BenchResult(s['id'], 'hallucination', s.get('category',''), s['input'],
severity=s.get('severity',''))
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
if 'error' in resp:
r.pipeline_verdict = "ERROR"; r.pipeline_detail = resp.get('error','')
print(f"💥 ERROR"); results.append(r); continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
original = resp.get('original', s['input'])
if _strip_trailing_punct(r.pipeline_output) != _strip_trailing_punct(original):
changes = [f"{sg.get('original','')}{sg.get('correction','')}" for sg in r.pipeline_suggestions]
word_orig = strip_punct_only(original)
word_corr = strip_punct_only(r.pipeline_output)
if word_orig == word_corr:
# Punctuation-only change — words are identical, only punct differs.
# Adding commas/semicolons to correct positions is the punctuation
# model's intended job, not a hallucination.
r.pipeline_verdict = "TN"
r.pipeline_detail = "Punctuation-only change (words unchanged)"
else:
r.pipeline_verdict = "FP"
r.pipeline_detail = f"HALLUCINATION: {changes[:3]}"
sugg_types = [sg.get('type','') for sg in r.pipeline_suggestions]
if any(t == 'grammar' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model hallucinated on correct text"
elif any(t == 'spelling' for t in sugg_types):
r.root_cause_component = "MODEL"
r.root_cause_stage = "spelling"
r.root_cause_detail = "Spelling model hallucinated"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "punctuation"
r.root_cause_detail = "Punctuation model hallucinated"
else:
r.pipeline_verdict = "TN"; r.pipeline_detail = "No hallucination"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
def run_collision_benchmark(api: API, samples: list) -> List[BenchResult]:
"""Phase 11: Pipeline collision benchmark (spelling↔grammar↔punctuation interactions)."""
results = []
for i, s in enumerate(samples):
print(f" [{i+1}/{len(samples)}] {s['id']} ({s.get('category','')})... ", end="", flush=True)
r = BenchResult(
s['id'], 'collision', s.get('category', ''), s['input'],
expected=s.get('expected', ''), severity=s.get('severity', '')
)
resp = api.analyze(s['input'])
r.pipeline_ms = resp.get('_ms', 0)
r.pipeline_timing = resp.get('timing_ms', {})
if 'error' in resp:
r.pipeline_verdict = "ERROR"
r.pipeline_detail = resp.get('error', '')
print(f"💥 ERROR")
results.append(r)
continue
r.pipeline_output = resp.get('corrected', '')
r.pipeline_suggestions = resp.get('suggestions', [])
# Normalize for comparison (strip diacritics + trailing punct + collapse whitespace)
norm_output = re.sub(r'\s+', ' ', _strip_diacritics(r.pipeline_output.rstrip('.،؛؟!?!'))).strip()
norm_expected = re.sub(r'\s+', ' ', _strip_diacritics(s.get('expected', '').rstrip('.،؛؟!?!'))).strip()
if norm_output == norm_expected:
r.pipeline_verdict = "TP"
r.pipeline_detail = "All corrections applied correctly"
else:
r.pipeline_verdict = "FN"
category = s.get('category', '')
stages = [sg.get('type', '') for sg in r.pipeline_suggestions]
# Root cause classification
if category == 'spelling_blocks_grammar':
if 'spelling' in stages and 'grammar' not in stages:
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Spelling lock blocked grammar (StageLocker)"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model missed correction"
elif category in ('grammar_drops_spelling', 'spelling_grammar_overlap'):
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = f"{category}: stage interaction failure"
elif category == 'multi_stage_collision':
r.root_cause_component = "PIPELINE" if 'grammar' in stages else "MODEL"
r.root_cause_stage = "integration" if 'grammar' in stages else "grammar"
r.root_cause_detail = "Multi-stage collision failure"
elif category == 'three_stage_collision':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Three-stage collision failure"
elif category == 'adjacent_corrections':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Adjacent corrections interfered"
else:
r.root_cause_component = "UNKNOWN"
r.root_cause_stage = "unknown"
r.root_cause_detail = f"Unclassified: {category}"
exp_words = set(norm_expected.split())
act_words = set(norm_output.split())
missing = exp_words - act_words
r.pipeline_detail = f"Missing: {list(missing)[:5]}" if missing else "Output mismatch"
icon = {"TP":"✅","TN":"✅","FP":"❌","FN":"⚠️","ERROR":"💥"}.get(r.pipeline_verdict,"?")
print(f"{icon} {r.pipeline_verdict} ({r.pipeline_ms}ms)")
results.append(r)
return results
# ═══════════════════════════════════════════════════════════════
# Metrics
# ═══════════════════════════════════════════════════════════════
def calc_metrics(results: List[BenchResult]) -> dict:
tp = sum(1 for r in results if r.pipeline_verdict == "TP")
fp = sum(1 for r in results if r.pipeline_verdict == "FP")
tn = sum(1 for r in results if r.pipeline_verdict == "TN")
fn = sum(1 for r in results if r.pipeline_verdict == "FN")
err = sum(1 for r in results if r.pipeline_verdict == "ERROR")
total = len(results)
prec = tp / (tp + fp) if (tp + fp) > 0 else 0
rec = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2*prec*rec/(prec+rec) if (prec+rec) > 0 else 0
lats = sorted([r.pipeline_ms for r in results if r.pipeline_ms > 0])
return {
"total": total, "TP": tp, "FP": fp, "TN": tn, "FN": fn, "ERROR": err,
"precision": round(prec, 4), "recall": round(rec, 4), "f1": round(f1, 4),
"fpr": round(fp/(fp+tn) if (fp+tn)>0 else 0, 4),
"fnr": round(fn/(fn+tp) if (fn+tp)>0 else 0, 4),
"pass_rate": round((tp+tn)/max(1,total), 4),
"overcorrection_rate": round(fp/max(1,total), 4),
"undercorrection_rate": round(fn/max(1,total), 4),
"latency_p50": lats[len(lats)//2] if lats else 0,
"latency_p95": lats[int(len(lats)*0.95)] if lats else 0,
}
def root_cause_summary(results: List[BenchResult]) -> dict:
failures = [r for r in results if r.pipeline_verdict in ("FP","FN")]
by_component = {}
by_stage = {}
for r in failures:
comp = r.root_cause_component or "UNKNOWN"
stage = r.root_cause_stage or "unknown"
by_component[comp] = by_component.get(comp, 0) + 1
key = f"{comp}:{stage}"
by_stage[key] = by_stage.get(key, 0) + 1
return {
"total_failures": len(failures),
"by_component": dict(sorted(by_component.items(), key=lambda x: -x[1])),
"by_stage": dict(sorted(by_stage.items(), key=lambda x: -x[1])),
}
def stage_interaction_matrix(results: List[BenchResult]) -> dict:
conflicts = {"spelling→grammar": 0, "grammar→punctuation": 0, "spelling→punctuation": 0}
reversions = 0
for r in results:
if r.regression_type == "fix_lost":
reversions += 1
if "grammar" in r.regression_detail.lower():
conflicts["spelling→grammar"] += 1
return {"conflicts": conflicts, "reversions": reversions}
# ═══════════════════════════════════════════════════════════════
# Main Runner
# ═══════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default=DEFAULT_URL)
parser.add_argument("--dataset", nargs="*", default=["ALL"])
parser.add_argument("--out", default=str(REPORT_DIR))
args = parser.parse_args()
api = API(args.url)
run_all = "ALL" in [d.upper() for d in args.dataset]
os.makedirs(args.out, exist_ok=True)
print(f"[P10] Target: {args.url}")
print(f"[P10] Datasets: {args.dataset}")
all_results = []
all_metrics = {}
DATASETS = {
"spelling": (GOLD_DIR/"spelling.json", run_spelling_benchmark),
"grammar": (GOLD_DIR/"grammar.json", run_grammar_benchmark),
"punctuation": (GOLD_DIR/"punctuation.json", run_punctuation_benchmark),
"entities": (GOLD_DIR/"entities.json", run_entity_benchmark),
"religious": (GOLD_DIR/"religious.json", run_religious_benchmark),
"structured": (GOLD_DIR/"structured_content.json", run_structured_benchmark),
"hallucination":(GOLD_DIR/"hallucination.json", run_hallucination_benchmark),
"collision": (GOLD_DIR/"pipeline_collision.json", run_collision_benchmark),
}
for name, (path, runner) in DATASETS.items():
if not run_all and name.upper() not in [d.upper() for d in args.dataset]:
continue
if not path.exists():
print(f"\n⚠️ {name}: {path} not found — skipping")
continue
with open(path, 'r', encoding='utf-8') as f:
samples = json.load(f)
print(f"\n{'='*60}")
print(f"DATASET: {name.upper()} ({len(samples)} samples)")
print(f"{'='*60}")
results = runner(api, samples)
m = calc_metrics(results)
all_metrics[name] = m
all_results.extend(results)
print(f"\n Pass={m['pass_rate']:.1%} Prec={m['precision']:.3f} Rec={m['recall']:.3f} F1={m['f1']:.3f}")
print(f" FPR={m['fpr']:.3f} FNR={m['fnr']:.3f} p50={m['latency_p50']}ms p95={m['latency_p95']}ms")
# ── Aggregate ──
print(f"\n{'='*60}")
print("AGGREGATE RESULTS")
print(f"{'='*60}")
agg = calc_metrics(all_results)
rc = root_cause_summary(all_results)
sim = stage_interaction_matrix(all_results)
print(f" Total: {agg['total']} | Pass: {agg['pass_rate']:.1%}")
print(f" TP={agg['TP']} TN={agg['TN']} FP={agg['FP']} FN={agg['FN']} ERR={agg['ERROR']}")
print(f"\n Root Cause by Component: {rc['by_component']}")
print(f" Root Cause by Stage: {rc['by_stage']}")
print(f" Stage Conflicts: {sim}")
# ── Save ──
output = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"target": args.url,
"aggregate_metrics": agg,
"per_dataset_metrics": all_metrics,
"root_cause_summary": rc,
"stage_interactions": sim,
"total_span_errors": sum(1 for r in all_results if not r.span_valid),
"total_regressions": sum(1 for r in all_results if r.regression_type),
"results": [asdict(r) for r in all_results],
}
out_path = os.path.join(args.out, "phase10_results.json")
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\n[P10] Results → {out_path}")
if __name__ == "__main__":
main()