bayan-api / tests /phase10 /run_collision_benchmark.py
youssefreda9's picture
FIX-38/39 + Layer 1/2/3: Benchmark normalization + spelling safety + grammar rules
79db738
Raw
History Blame Contribute Delete
9.18 kB
"""
Phase 11 — Collision Benchmark Runner
======================================
Integrates with the benchmark_runner.py API client pattern.
Produces a Markdown/CLI table report with pass/fail rates and root causes.
Usage:
python tests/phase10/run_collision_benchmark.py [--url URL]
"""
import argparse
import json
import re
import sys
import time
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import List
# Reuse API client from benchmark_runner
sys.path.insert(0, str(Path(__file__).parent))
from benchmark_runner import API, BenchResult, calc_metrics, strip_punct_only
GOLD_DIR = Path(__file__).parent / "gold_datasets"
REPORT_DIR = Path(__file__).parent / "reports"
DEFAULT_URL = "https://bayan10-bayan-api.hf.space"
def _strip_diacritics(text):
return re.sub(r'[\u064B-\u065F\u0670]', '', text)
def _normalize(text):
"""Normalize for comparison: strip diacritics + trailing punct + collapse whitespace."""
text = _strip_diacritics(text)
text = text.rstrip('.،؛؟!?!') # Terminal punct is not a correctness criterion
return re.sub(r'\s+', ' ', text).strip()
def run_collision_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'], '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', [])
original = resp.get('original', s['input'])
# Normalize for comparison
norm_output = _normalize(r.pipeline_output)
norm_expected = _normalize(s.get('expected', ''))
if norm_output == norm_expected:
r.pipeline_verdict = "TP"
r.pipeline_detail = "All corrections applied correctly"
else:
# Classify the failure
category = s.get('category', '')
stages = [sg.get('type', '') for sg in r.pipeline_suggestions]
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 correction (StageLocker)"
else:
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model missed gender agreement correction"
elif category == 'grammar_drops_spelling':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Grammar stage dropped spelling fix"
elif category == 'spelling_grammar_overlap':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Spelling and grammar corrections overlapped"
elif category == 'multi_stage_collision':
if not any(t == 'grammar' for t in stages):
r.root_cause_component = "MODEL"
r.root_cause_stage = "grammar"
r.root_cause_detail = "Grammar model missed SV/gender agreement"
else:
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Multi-stage interaction failure"
elif category == 'three_stage_collision':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Three-stage collision: spelling+grammar+punctuation"
elif category == 'punctuation_near_spelling':
r.root_cause_component = "MODEL"
r.root_cause_stage = "spelling"
r.root_cause_detail = "Spelling correction near punctuation boundary"
elif category == 'adjacent_corrections':
r.root_cause_component = "PIPELINE"
r.root_cause_stage = "integration"
r.root_cause_detail = "Adjacent word corrections interfered"
else:
r.root_cause_component = "UNKNOWN"
r.root_cause_stage = "unknown"
r.root_cause_detail = f"Unclassified failure in {category}"
# Check what's wrong specifically
exp_words = set(norm_expected.split())
act_words = set(norm_output.split())
missing = exp_words - act_words
extra = act_words - exp_words
r.pipeline_verdict = "FN"
r.pipeline_detail = (
f"Missing: {list(missing)[:5]}, Extra: {list(extra)[:5]}"
if missing or extra
else f"Output mismatch: '{r.pipeline_output[:60]}' vs '{s['expected'][:60]}'"
)
# 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
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 main():
parser = argparse.ArgumentParser(description="Phase 11 Collision Benchmark")
parser.add_argument("--url", default=DEFAULT_URL)
args = parser.parse_args()
api = API(args.url)
dataset_path = GOLD_DIR / "pipeline_collision.json"
if not dataset_path.exists():
print(f"❌ Dataset not found: {dataset_path}")
sys.exit(1)
with open(dataset_path, 'r', encoding='utf-8') as f:
samples = json.load(f)
print(f"\n{'='*60}")
print(f"COLLISION BENCHMARK ({len(samples)} samples)")
print(f"Target: {args.url}")
print(f"{'='*60}")
results = run_collision_benchmark(api, samples)
m = calc_metrics(results)
# ── Per-category breakdown ──
categories = {}
for r in results:
cat = r.category
if cat not in categories:
categories[cat] = {"total": 0, "pass": 0, "fail": 0}
categories[cat]["total"] += 1
if r.pipeline_verdict in ("TP", "TN"):
categories[cat]["pass"] += 1
else:
categories[cat]["fail"] += 1
# ── Print report ──
print(f"\n{'='*60}")
print("COLLISION BENCHMARK RESULTS")
print(f"{'='*60}")
print(f"\n## Summary")
print(f"| Metric | Value |")
print(f"|---------------|-------|")
print(f"| Total | {m['total']} |")
print(f"| Passed (TP) | {m['TP']} |")
print(f"| Failed (FN) | {m['FN']} |")
print(f"| FP | {m['FP']} |")
print(f"| Errors | {m['ERROR']} |")
print(f"| Pass Rate | {m['pass_rate']:.1%} |")
print(f"\n## By Category")
print(f"| Category | Total | Pass | Fail | Rate |")
print(f"|----------|-------|------|------|------|")
for cat, data in sorted(categories.items()):
rate = data['pass'] / data['total'] * 100 if data['total'] > 0 else 0
print(f"| {cat} | {data['total']} | {data['pass']} | {data['fail']} | {rate:.0f}% |")
# ── Root cause for failures ──
failures = [r for r in results if r.pipeline_verdict in ("FN", "FP")]
if failures:
print(f"\n## Failure Details")
print(f"| ID | Category | Input | Expected | Actual | Root Cause |")
print(f"|----|----------|-------|----------|--------|------------|")
for r in failures:
print(
f"| {r.id} | {r.category} | "
f"{r.input[:30]}... | {r.expected[:30]}... | "
f"{r.pipeline_output[:30]}... | {r.root_cause_detail[:40]} |"
)
# ── Save JSON report ──
REPORT_DIR.mkdir(parents=True, exist_ok=True)
report = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"target": args.url,
"metrics": m,
"by_category": categories,
"results": [asdict(r) for r in results],
}
out_path = REPORT_DIR / "collision_benchmark_results.json"
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\n[P11] Report → {out_path}")
if __name__ == "__main__":
main()