bayan-api / tests /v2 /test_level1_raw.py
youssefreda9's picture
Phase A: Upgrade L1 with TP/FP/FN/TN verdicts + word edits, add F1 scores to matrix
1c376a4
Raw
History Blame Contribute Delete
14.2 kB
"""
BAYAN v2.0 — Level 1: Raw Model Tests
======================================
Tests each ML model through its individual API endpoint — measuring the RAW
model output with no pipeline integration (no StageLocker, no OffsetMapper,
no cross-stage interaction).
NOTE: On HF Spaces deployment, the solo endpoints (/api/spelling, /api/grammar,
/api/punctuation) call the models directly with minimal preprocessing. This is
as close to "raw model" as we can get without local model access.
Produces:
- TP/FP/FN/TN verdicts per model per test case
- Per-word edit analysis (what words each model changed)
- Change rate and accuracy per model per dataset
Usage:
python tests/v2/test_level1_raw.py --url URL [--dataset DATASET]
"""
import argparse
import json
import re
import time
import sys
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional
import requests
# Datasets
DATASETS_DIR = Path(__file__).parent.parent / "phase10" / "gold_datasets"
REPORT_DIR = Path(__file__).parent / "reports"
# Terminal punctuation to strip before comparison
_TERMINAL_PUNCT = '.،؛؟!?!'
@dataclass
class RawModelResult:
id: str
dataset: str
category: str
input_text: str
expected: str # What the benchmark expects
severity: str
# Raw model outputs
spelling_raw: str = ""
spelling_ms: int = 0
grammar_raw: str = ""
grammar_ms: int = 0
punctuation_raw: str = ""
punctuation_ms: int = 0
# Change detection
spelling_changed: bool = False
grammar_changed: bool = False
punctuation_changed: bool = False
# Verdicts (TP/TN/FP/FN)
spelling_verdict: str = ""
grammar_verdict: str = ""
punctuation_verdict: str = ""
# Word-level edits
spelling_edits: str = "" # "word1→word2, word3→word4"
grammar_edits: str = ""
punctuation_edits: str = ""
class APIClient:
"""Minimal client to call individual model endpoints."""
def __init__(self, base_url):
self.base = base_url.rstrip('/')
self.session = requests.Session()
self.session.headers['Content-Type'] = 'application/json'
def _post(self, endpoint, text, timeout=120):
t0 = time.time()
try:
r = self.session.post(
f"{self.base}{endpoint}",
json={"text": text},
timeout=timeout
)
ms = int((time.time() - t0) * 1000)
data = r.json()
return data, ms
except Exception as e:
ms = int((time.time() - t0) * 1000)
return {"error": str(e)}, ms
def spelling_raw(self, text):
data, ms = self._post("/api/spelling", text)
corrected = data.get("corrected_text", data.get("corrected", text))
return corrected, ms
def grammar_raw(self, text):
data, ms = self._post("/api/grammar", text)
corrected = data.get("corrected_text", data.get("corrected", text))
return corrected, ms
def punctuation_raw(self, text):
data, ms = self._post("/api/punctuation", text)
corrected = data.get("corrected_text", data.get("corrected", text))
return corrected, ms
def load_datasets(dataset_filter=None):
datasets = {}
for f in sorted(DATASETS_DIR.glob("*.json")):
name = f.stem
if dataset_filter and name != dataset_filter:
continue
with open(f, 'r', encoding='utf-8') as fh:
datasets[name] = json.load(fh)
return datasets
def normalize(text):
t = re.sub(r'[\u064B-\u065F\u0670]', '', text)
t = re.sub(r'\s+', ' ', t).strip()
return t
def _edit_distance(a, b):
if len(a) < len(b):
return _edit_distance(b, a)
if len(b) == 0:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a):
curr = [i + 1]
for j, cb in enumerate(b):
cost = 0 if ca == cb else 1
curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
prev = curr
return prev[len(b)]
def get_word_edits(input_text, output_text):
"""Get word-level edits between input and output."""
inp_words = normalize(input_text).split()
out_words = normalize(output_text).split()
edits = []
# Simple alignment: match by position
max_len = max(len(inp_words), len(out_words))
for i in range(max_len):
iw = inp_words[i] if i < len(inp_words) else "∅"
ow = out_words[i] if i < len(out_words) else "∅"
if iw != ow:
edits.append(f"{iw}{ow}")
return ", ".join(edits[:5]) # Cap at 5 edits for readability
def classify_raw(input_text, output_text, expected_text, dataset):
"""Classify raw model output as TP/TN/FP/FN.
Same logic as L2/L3 but applied to raw model output.
"""
inp_n = normalize(input_text)
out_n = normalize(output_text)
exp_n = normalize(expected_text)
out_stripped = out_n.rstrip(_TERMINAL_PUNCT).rstrip()
text_changed = (out_n != inp_n)
is_preservation = dataset in ('entities', 'religious', 'structured', 'hallucination')
if is_preservation:
if not text_changed:
return "TN"
elif out_stripped == inp_n:
return "TN" # Only punctuation added — not entity corruption
else:
return "FP"
else:
needs_correction = (inp_n != exp_n)
if needs_correction:
if out_n == exp_n or out_stripped == exp_n:
return "TP"
elif text_changed and _edit_distance(out_stripped, exp_n) < _edit_distance(inp_n, exp_n):
return "TP"
elif text_changed and _edit_distance(out_n, exp_n) < _edit_distance(inp_n, exp_n):
return "TP"
elif not text_changed:
return "FN"
else:
return "FP"
else:
if not text_changed:
return "TN"
elif out_stripped == inp_n:
return "TN" # Only punctuation added
else:
return "FP"
def run_level1(api: APIClient, datasets: dict) -> List[RawModelResult]:
results = []
total = sum(len(v) for v in datasets.values())
idx = 0
for ds_name, cases in datasets.items():
print(f"\n{'='*60}")
print(f"DATASET: {ds_name.upper()} ({len(cases)} samples)")
print(f"{'='*60}")
for case in cases:
idx += 1
cid = case.get('id', f'{ds_name}_{idx}')
cat = case.get('category', '')
inp = case.get('input', '')
expected = case.get('expected', case.get('input', ''))
severity = case.get('severity', '')
r = RawModelResult(
id=cid, dataset=ds_name, category=cat,
input_text=inp, expected=expected, severity=severity
)
print(f" [{idx}/{total}] {cid} ({cat})...", end=" ", flush=True)
# ── Spelling ──
try:
r.spelling_raw, r.spelling_ms = api.spelling_raw(inp)
r.spelling_changed = (normalize(r.spelling_raw) != normalize(inp))
r.spelling_verdict = classify_raw(inp, r.spelling_raw, expected, ds_name)
if r.spelling_changed:
r.spelling_edits = get_word_edits(inp, r.spelling_raw)
except Exception as e:
r.spelling_raw = f"ERROR: {e}"
r.spelling_verdict = "ERR"
# ── Grammar ──
try:
r.grammar_raw, r.grammar_ms = api.grammar_raw(inp)
r.grammar_changed = (normalize(r.grammar_raw) != normalize(inp))
r.grammar_verdict = classify_raw(inp, r.grammar_raw, expected, ds_name)
if r.grammar_changed:
r.grammar_edits = get_word_edits(inp, r.grammar_raw)
except Exception as e:
r.grammar_raw = f"ERROR: {e}"
r.grammar_verdict = "ERR"
# ── Punctuation ──
try:
r.punctuation_raw, r.punctuation_ms = api.punctuation_raw(inp)
r.punctuation_changed = (normalize(r.punctuation_raw) != normalize(inp))
r.punctuation_verdict = classify_raw(inp, r.punctuation_raw, expected, ds_name)
if r.punctuation_changed:
r.punctuation_edits = get_word_edits(inp, r.punctuation_raw)
except Exception as e:
r.punctuation_raw = f"ERROR: {e}"
r.punctuation_verdict = "ERR"
total_ms = r.spelling_ms + r.grammar_ms + r.punctuation_ms
print(f"S={r.spelling_verdict} G={r.grammar_verdict} P={r.punctuation_verdict} ({total_ms}ms)")
results.append(r)
return results
def analyze_results(results: List[RawModelResult]) -> dict:
analysis = {
"total": len(results),
"by_model": {},
"by_dataset": {},
}
for model in ("spelling", "grammar", "punctuation"):
verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0, "ERR": 0}
changed = 0
for r in results:
v = getattr(r, f"{model}_verdict", "")
if v in verdicts:
verdicts[v] += 1
if getattr(r, f"{model}_changed", False):
changed += 1
total = sum(v for k, v in verdicts.items() if k != "ERR")
pass_count = verdicts["TP"] + verdicts["TN"]
analysis["by_model"][model] = {
**verdicts,
"changed": changed,
"total": len(results),
"change_rate": round(changed / len(results), 4) if results else 0,
"pass_rate": round(pass_count / total, 4) if total else 0,
}
# Per-dataset breakdown
for ds in sorted(set(r.dataset for r in results)):
ds_results = [r for r in results if r.dataset == ds]
ds_analysis = {"total": len(ds_results)}
for model in ("spelling", "grammar", "punctuation"):
verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0}
changed = 0
for r in ds_results:
v = getattr(r, f"{model}_verdict", "")
if v in verdicts:
verdicts[v] += 1
if getattr(r, f"{model}_changed", False):
changed += 1
total = sum(verdicts.values())
pass_count = verdicts["TP"] + verdicts["TN"]
ds_analysis[model] = {
**verdicts,
"changed": changed,
"change_rate": round(changed / len(ds_results), 4) if ds_results else 0,
"pass_rate": round(pass_count / total, 4) if total else 0,
}
analysis["by_dataset"][ds] = ds_analysis
return analysis
def print_analysis(analysis: dict, results: List[RawModelResult]):
print(f"\n{'='*60}")
print("LEVEL 1: RAW MODEL ANALYSIS")
print(f"{'='*60}")
# Per-model summary
print(f"\n## Per-Model Summary ({analysis['total']} tests)")
print(f"| Model | Changed | TP | TN | FP | FN | ChgRate | Pass% |")
print(f"|-------------|---------|-----|-----|-----|-----|---------|--------|")
for model, data in analysis["by_model"].items():
cr = data['change_rate'] * 100
pr = data['pass_rate'] * 100
print(f"| {model:<11} | {data['changed']:>7} | {data['TP']:>3} | {data['TN']:>3} "
f"| {data['FP']:>3} | {data['FN']:>3} | {cr:5.1f}% | {pr:5.1f}% |")
# Per-dataset breakdown
print(f"\n## Per-Dataset × Model Pass Rate")
print(f"| Dataset | Total | S-Pass% | G-Pass% | P-Pass% | S-Chg% | G-Chg% | P-Chg% |")
print(f"|--------------|-------|---------|---------|---------|--------|--------|--------|")
for ds in sorted(analysis["by_dataset"].keys()):
d = analysis["by_dataset"][ds]
sp = d["spelling"]["pass_rate"] * 100
gp = d["grammar"]["pass_rate"] * 100
pp = d["punctuation"]["pass_rate"] * 100
sc = d["spelling"]["change_rate"] * 100
gc = d["grammar"]["change_rate"] * 100
pc = d["punctuation"]["change_rate"] * 100
print(f"| {ds:<12} | {d['total']:>5} | {sp:5.1f}% | {gp:5.1f}% | {pp:5.1f}% "
f"| {sc:4.1f}% | {gc:4.1f}% | {pc:4.1f}% |")
# Word-level edit summary (top FP edits)
print(f"\n## Top FP Word Edits (model changed text incorrectly)")
for model in ("spelling", "grammar", "punctuation"):
fp_edits = []
for r in results:
if getattr(r, f"{model}_verdict") == "FP":
edits = getattr(r, f"{model}_edits", "")
if edits:
fp_edits.append(f" {r.id}: {edits}")
if fp_edits:
print(f"\n [{model.upper()}] {len(fp_edits)} FP cases:")
for e in fp_edits[:10]: # Show first 10
print(e)
def main():
parser = argparse.ArgumentParser(description="Level 1: Raw Model Tests")
parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space")
parser.add_argument("--dataset", default=None, help="Filter to single dataset")
args = parser.parse_args()
api = APIClient(args.url)
datasets = load_datasets(args.dataset)
print(f"\n{'='*60}")
print("BAYAN v2.0 — Level 1: Raw Model Tests")
print(f"{'='*60}")
print(f" Target: {args.url}")
print(f" Datasets: {list(datasets.keys())}")
print(f" Total: {sum(len(v) for v in datasets.values())} tests")
print(f"{'='*60}")
results = run_level1(api, datasets)
analysis = analyze_results(results)
print_analysis(analysis, results)
# Save results
REPORT_DIR.mkdir(parents=True, exist_ok=True)
out_path = REPORT_DIR / "level1_raw_results.json"
report = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"target": args.url,
"analysis": analysis,
"results": [asdict(r) for r in results],
}
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\n[L1] Results → {out_path}")
if __name__ == "__main__":
main()