youssefreda9 commited on
Commit
e128e0e
·
1 Parent(s): 57104c1

Phase A: Add v2.0 3-level test harness (L1 raw, L2 solo, L3 integrated + master matrix)

Browse files
tests/v2/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # BAYAN v2.0 Test Suite
tests/v2/benchmark_matrix.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BAYAN v2.0 — Benchmark Matrix Runner
3
+ ======================================
4
+ Master script that runs all 3 test levels and produces a side-by-side
5
+ comparison matrix showing raw model vs solo API vs integrated pipeline.
6
+
7
+ Usage:
8
+ # Full matrix (all 3 levels)
9
+ python tests/v2/benchmark_matrix.py --url URL
10
+
11
+ # Single level only
12
+ python tests/v2/benchmark_matrix.py --url URL --level 1
13
+ python tests/v2/benchmark_matrix.py --url URL --level 3
14
+
15
+ # Single dataset
16
+ python tests/v2/benchmark_matrix.py --url URL --dataset spelling
17
+
18
+ # Compare saved results
19
+ python tests/v2/benchmark_matrix.py --compare
20
+ """
21
+ import argparse
22
+ import json
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ REPORT_DIR = Path(__file__).parent / "reports"
28
+
29
+
30
+ def run_level(level: int, url: str, dataset: str = None):
31
+ """Run a specific test level."""
32
+ import subprocess
33
+ scripts = {
34
+ 1: "tests/v2/test_level1_raw.py",
35
+ 2: "tests/v2/test_level2_solo.py",
36
+ 3: "tests/v2/test_level3_integrated.py",
37
+ }
38
+ script = scripts[level]
39
+ cmd = [sys.executable, script, "--url", url]
40
+ if dataset:
41
+ cmd.extend(["--dataset", dataset])
42
+
43
+ print(f"\n{'#'*70}")
44
+ print(f"# RUNNING LEVEL {level}: {script}")
45
+ print(f"{'#'*70}\n")
46
+
47
+ result = subprocess.run(cmd, cwd=str(Path(__file__).parent.parent.parent))
48
+ return result.returncode == 0
49
+
50
+
51
+ def load_report(level: int) -> dict:
52
+ """Load saved report for a level."""
53
+ filenames = {
54
+ 1: "level1_raw_results.json",
55
+ 2: "level2_solo_results.json",
56
+ 3: "level3_integrated_results.json",
57
+ }
58
+ path = REPORT_DIR / filenames[level]
59
+ if path.exists():
60
+ with open(path, 'r', encoding='utf-8') as f:
61
+ return json.load(f)
62
+ return None
63
+
64
+
65
+ def print_comparison_matrix():
66
+ """Load all 3 reports and print side-by-side comparison."""
67
+ l1 = load_report(1)
68
+ l2 = load_report(2)
69
+ l3 = load_report(3)
70
+
71
+ print(f"\n{'='*70}")
72
+ print("BAYAN v2.0 — BENCHMARK COMPARISON MATRIX")
73
+ print(f"{'='*70}")
74
+
75
+ # Timestamps
76
+ for level, report, name in [(1, l1, "L1-Raw"), (2, l2, "L2-Solo"), (3, l3, "L3-Pipeline")]:
77
+ if report:
78
+ ts = report.get("timestamp", "N/A")
79
+ print(f" {name}: {ts}")
80
+ else:
81
+ print(f" {name}: NOT RUN")
82
+
83
+ # ── Level 1 Summary ──
84
+ if l1:
85
+ print(f"\n{'─'*70}")
86
+ print("Level 1: RAW MODEL OUTPUT (no filters)")
87
+ print(f"{'─'*70}")
88
+ a = l1.get("analysis", {})
89
+ by_model = a.get("by_model", {})
90
+ for model, data in by_model.items():
91
+ changed = data.get("changed", 0)
92
+ unchanged = data.get("unchanged", 0)
93
+ total = changed + unchanged + data.get("errors", 0)
94
+ rate = changed / total * 100 if total else 0
95
+ print(f" {model:<12}: {changed}/{total} texts modified ({rate:.1f}%)")
96
+
97
+ # ── Level 2 Summary ──
98
+ if l2:
99
+ print(f"\n{'─'*70}")
100
+ print("Level 2: SOLO API (single model + filters, no integration)")
101
+ print(f"{'─'*70}")
102
+ a = l2.get("analysis", {})
103
+ by_model = a.get("by_model", {})
104
+ print(f" {'Model':<12} {'TP':>4} {'TN':>4} {'FP':>4} {'FN':>4} {'Pass%':>7}")
105
+ print(f" {'-'*12} {'-'*4} {'-'*4} {'-'*4} {'-'*4} {'-'*7}")
106
+ for model, data in by_model.items():
107
+ pr = data.get("pass_rate", 0) * 100
108
+ print(f" {model:<12} {data.get('TP',0):>4} {data.get('TN',0):>4} "
109
+ f"{data.get('FP',0):>4} {data.get('FN',0):>4} {pr:>6.1f}%")
110
+
111
+ # ── Level 3 Summary ──
112
+ if l3:
113
+ print(f"\n{'─'*70}")
114
+ print("Level 3: INTEGRATED PIPELINE (full Spelling→Grammar→Punctuation)")
115
+ print(f"{'─'*70}")
116
+ a = l3.get("analysis", {})
117
+ agg = a.get("aggregate", {})
118
+ total = a.get("total", 0)
119
+ pr = agg.get("pass_rate", 0) * 100
120
+ print(f" Overall: {pr:.1f}% pass ({total} tests)")
121
+ print(f" TP={agg.get('TP',0)} TN={agg.get('TN',0)} "
122
+ f"FP={agg.get('FP',0)} FN={agg.get('FN',0)}")
123
+
124
+ print(f"\n {'Dataset':<14} {'Pass%':>7} {'TP':>4} {'TN':>4} {'FP':>4} {'FN':>4}")
125
+ print(f" {'-'*14} {'-'*7} {'-'*4} {'-'*4} {'-'*4} {'-'*4}")
126
+ by_ds = a.get("by_dataset", {})
127
+ for ds in sorted(by_ds.keys()):
128
+ d = by_ds[ds]
129
+ dp = d.get("pass_rate", 0) * 100
130
+ print(f" {ds:<14} {dp:>6.1f}% {d.get('TP',0):>4} {d.get('TN',0):>4} "
131
+ f"{d.get('FP',0):>4} {d.get('FN',0):>4}")
132
+
133
+ # ── Cross-Level Comparison ──
134
+ if l2 and l3:
135
+ print(f"\n{'─'*70}")
136
+ print("CROSS-LEVEL COMPARISON: Solo vs Integrated")
137
+ print(f"{'─'*70}")
138
+
139
+ l2_ds = l2.get("analysis", {}).get("by_dataset", {})
140
+ l3_ds = l3.get("analysis", {}).get("by_dataset", {})
141
+
142
+ all_datasets = sorted(set(list(l2_ds.keys()) + list(l3_ds.keys())))
143
+
144
+ print(f" {'Dataset':<14} {'L2-Spell':>10} {'L2-Gram':>10} {'L2-Punct':>10} {'L3-Pipeline':>12} {'Delta':>7}")
145
+ print(f" {'-'*14} {'-'*10} {'-'*10} {'-'*10} {'-'*12} {'-'*7}")
146
+
147
+ for ds in all_datasets:
148
+ l2d = l2_ds.get(ds, {})
149
+ l3d = l3_ds.get(ds, {})
150
+
151
+ l2_s = l2d.get("spelling", {}).get("pass_rate", 0) * 100
152
+ l2_g = l2d.get("grammar", {}).get("pass_rate", 0) * 100
153
+ l2_p = l2d.get("punctuation", {}).get("pass_rate", 0) * 100
154
+ l3_p = l3d.get("pass_rate", 0) * 100
155
+
156
+ # Best solo model vs pipeline
157
+ best_solo = max(l2_s, l2_g, l2_p)
158
+ delta = l3_p - best_solo
159
+
160
+ delta_str = f"{delta:+.1f}%"
161
+ print(f" {ds:<14} {l2_s:>9.1f}% {l2_g:>9.1f}% {l2_p:>9.1f}% {l3_p:>11.1f}% {delta_str:>7}")
162
+
163
+ # Save comparison
164
+ comparison = {
165
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
166
+ "l1_timestamp": l1.get("timestamp") if l1 else None,
167
+ "l2_timestamp": l2.get("timestamp") if l2 else None,
168
+ "l3_timestamp": l3.get("timestamp") if l3 else None,
169
+ "l2_summary": l2.get("analysis", {}).get("by_model") if l2 else None,
170
+ "l3_summary": l3.get("analysis", {}).get("aggregate") if l3 else None,
171
+ }
172
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
173
+ with open(REPORT_DIR / "benchmark_matrix.json", 'w', encoding='utf-8') as f:
174
+ json.dump(comparison, f, ensure_ascii=False, indent=2)
175
+ print(f"\n[MATRIX] Comparison → {REPORT_DIR / 'benchmark_matrix.json'}")
176
+
177
+
178
+ def main():
179
+ parser = argparse.ArgumentParser(description="BAYAN v2.0 Benchmark Matrix")
180
+ parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space")
181
+ parser.add_argument("--level", type=int, default=None, help="Run specific level (1/2/3)")
182
+ parser.add_argument("--dataset", default=None, help="Filter to single dataset")
183
+ parser.add_argument("--compare", action="store_true", help="Compare saved results only")
184
+ args = parser.parse_args()
185
+
186
+ if args.compare:
187
+ print_comparison_matrix()
188
+ return
189
+
190
+ levels = [args.level] if args.level else [1, 2, 3]
191
+
192
+ print(f"\n{'#'*70}")
193
+ print(f"# BAYAN v2.0 — BENCHMARK MATRIX")
194
+ print(f"# Levels: {levels}")
195
+ print(f"# Target: {args.url}")
196
+ print(f"{'#'*70}")
197
+
198
+ for level in levels:
199
+ success = run_level(level, args.url, args.dataset)
200
+ if not success:
201
+ print(f"\n❌ Level {level} failed!")
202
+ sys.exit(1)
203
+ print(f"\n✅ Level {level} complete!")
204
+
205
+ # Print comparison
206
+ print_comparison_matrix()
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
tests/v2/test_level1_raw.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BAYAN v2.0 — Level 1: Raw Model Tests
3
+ ======================================
4
+ Tests each ML model DIRECTLY — no filters, no pipeline, no StageLocker.
5
+ Measures the raw model ceiling: what's the best each model can do?
6
+
7
+ Usage:
8
+ python tests/v2/test_level1_raw.py --url URL [--dataset DATASET]
9
+ """
10
+ import argparse
11
+ import json
12
+ import re
13
+ import time
14
+ import sys
15
+ from pathlib import Path
16
+ from dataclasses import dataclass, field, asdict
17
+ from typing import List, Dict, Optional
18
+ import requests
19
+
20
+ # Datasets
21
+ DATASETS_DIR = Path(__file__).parent.parent / "phase10" / "gold_datasets"
22
+
23
+ REPORT_DIR = Path(__file__).parent / "reports"
24
+
25
+
26
+ @dataclass
27
+ class RawModelResult:
28
+ id: str
29
+ dataset: str
30
+ category: str
31
+ input_text: str
32
+ expected: str # What the benchmark expects
33
+ severity: str
34
+ # Raw model outputs
35
+ spelling_raw: str = ""
36
+ spelling_ms: int = 0
37
+ grammar_raw: str = ""
38
+ grammar_ms: int = 0
39
+ punctuation_raw: str = ""
40
+ punctuation_ms: int = 0
41
+ # Analysis
42
+ spelling_changed: bool = False
43
+ grammar_changed: bool = False
44
+ punctuation_changed: bool = False
45
+
46
+
47
+ class APIClient:
48
+ """Minimal client to call individual model endpoints."""
49
+ def __init__(self, base_url):
50
+ self.base = base_url.rstrip('/')
51
+ self.session = requests.Session()
52
+ self.session.headers['Content-Type'] = 'application/json'
53
+
54
+ def _post(self, endpoint, text, timeout=120):
55
+ t0 = time.time()
56
+ try:
57
+ r = self.session.post(
58
+ f"{self.base}{endpoint}",
59
+ json={"text": text},
60
+ timeout=timeout
61
+ )
62
+ ms = int((time.time() - t0) * 1000)
63
+ data = r.json()
64
+ return data, ms
65
+ except Exception as e:
66
+ ms = int((time.time() - t0) * 1000)
67
+ return {"error": str(e)}, ms
68
+
69
+ def spelling_raw(self, text):
70
+ """Call /api/spelling — raw spelling model through API."""
71
+ data, ms = self._post("/api/spelling", text)
72
+ corrected = data.get("corrected_text", data.get("corrected", text))
73
+ return corrected, ms
74
+
75
+ def grammar_raw(self, text):
76
+ """Call /api/grammar — raw grammar model through API."""
77
+ data, ms = self._post("/api/grammar", text)
78
+ corrected = data.get("corrected_text", data.get("corrected", text))
79
+ return corrected, ms
80
+
81
+ def punctuation_raw(self, text):
82
+ """Call /api/punctuation — raw punctuation model through API."""
83
+ data, ms = self._post("/api/punctuation", text)
84
+ corrected = data.get("corrected_text", data.get("corrected", text))
85
+ return corrected, ms
86
+
87
+
88
+ def load_datasets(dataset_filter=None):
89
+ """Load all gold datasets."""
90
+ datasets = {}
91
+ for f in sorted(DATASETS_DIR.glob("*.json")):
92
+ name = f.stem
93
+ if dataset_filter and name != dataset_filter:
94
+ continue
95
+ with open(f, 'r', encoding='utf-8') as fh:
96
+ data = json.load(fh)
97
+ datasets[name] = data
98
+ return datasets
99
+
100
+
101
+ def strip_diacritics(text):
102
+ return re.sub(r'[\u064B-\u065F\u0670]', '', text)
103
+
104
+
105
+ def normalize(text):
106
+ t = strip_diacritics(text)
107
+ t = re.sub(r'\s+', ' ', t).strip()
108
+ return t
109
+
110
+
111
+ def run_level1(api: APIClient, datasets: dict) -> List[RawModelResult]:
112
+ """Run each test case through all 3 raw models."""
113
+ results = []
114
+ total = sum(len(v) for v in datasets.values())
115
+ idx = 0
116
+
117
+ for ds_name, cases in datasets.items():
118
+ print(f"\n{'='*60}")
119
+ print(f"DATASET: {ds_name.upper()} ({len(cases)} samples)")
120
+ print(f"{'='*60}")
121
+
122
+ for case in cases:
123
+ idx += 1
124
+ cid = case.get('id', f'{ds_name}_{idx}')
125
+ cat = case.get('category', '')
126
+ inp = case.get('input', '')
127
+ expected = case.get('expected', case.get('input', ''))
128
+ severity = case.get('severity', '')
129
+
130
+ r = RawModelResult(
131
+ id=cid, dataset=ds_name, category=cat,
132
+ input_text=inp, expected=expected, severity=severity
133
+ )
134
+
135
+ print(f" [{idx}/{total}] {cid} ({cat})...", end=" ", flush=True)
136
+
137
+ # ── Spelling ──
138
+ try:
139
+ r.spelling_raw, r.spelling_ms = api.spelling_raw(inp)
140
+ r.spelling_changed = (normalize(r.spelling_raw) != normalize(inp))
141
+ except Exception as e:
142
+ r.spelling_raw = f"ERROR: {e}"
143
+
144
+ # ── Grammar ──
145
+ try:
146
+ r.grammar_raw, r.grammar_ms = api.grammar_raw(inp)
147
+ r.grammar_changed = (normalize(r.grammar_raw) != normalize(inp))
148
+ except Exception as e:
149
+ r.grammar_raw = f"ERROR: {e}"
150
+
151
+ # ── Punctuation ──
152
+ try:
153
+ r.punctuation_raw, r.punctuation_ms = api.punctuation_raw(inp)
154
+ r.punctuation_changed = (normalize(r.punctuation_raw) != normalize(inp))
155
+ except Exception as e:
156
+ r.punctuation_raw = f"ERROR: {e}"
157
+
158
+ total_ms = r.spelling_ms + r.grammar_ms + r.punctuation_ms
159
+ changes = []
160
+ if r.spelling_changed: changes.append("S")
161
+ if r.grammar_changed: changes.append("G")
162
+ if r.punctuation_changed: changes.append("P")
163
+ change_str = "+".join(changes) if changes else "none"
164
+ print(f"[{change_str}] ({total_ms}ms)")
165
+
166
+ results.append(r)
167
+
168
+ return results
169
+
170
+
171
+ def analyze_results(results: List[RawModelResult]) -> dict:
172
+ """Produce per-model analysis of raw outputs."""
173
+ analysis = {
174
+ "total": len(results),
175
+ "by_model": {
176
+ "spelling": {"changed": 0, "unchanged": 0, "errors": 0},
177
+ "grammar": {"changed": 0, "unchanged": 0, "errors": 0},
178
+ "punctuation": {"changed": 0, "unchanged": 0, "errors": 0},
179
+ },
180
+ "by_dataset": {},
181
+ }
182
+
183
+ for r in results:
184
+ ds = r.dataset
185
+ if ds not in analysis["by_dataset"]:
186
+ analysis["by_dataset"][ds] = {
187
+ "total": 0,
188
+ "spelling_changed": 0,
189
+ "grammar_changed": 0,
190
+ "punctuation_changed": 0,
191
+ }
192
+ analysis["by_dataset"][ds]["total"] += 1
193
+
194
+ # Spelling
195
+ if "ERROR" in r.spelling_raw:
196
+ analysis["by_model"]["spelling"]["errors"] += 1
197
+ elif r.spelling_changed:
198
+ analysis["by_model"]["spelling"]["changed"] += 1
199
+ analysis["by_dataset"][ds]["spelling_changed"] += 1
200
+ else:
201
+ analysis["by_model"]["spelling"]["unchanged"] += 1
202
+
203
+ # Grammar
204
+ if "ERROR" in r.grammar_raw:
205
+ analysis["by_model"]["grammar"]["errors"] += 1
206
+ elif r.grammar_changed:
207
+ analysis["by_model"]["grammar"]["changed"] += 1
208
+ analysis["by_dataset"][ds]["grammar_changed"] += 1
209
+ else:
210
+ analysis["by_model"]["grammar"]["unchanged"] += 1
211
+
212
+ # Punctuation
213
+ if "ERROR" in r.punctuation_raw:
214
+ analysis["by_model"]["punctuation"]["errors"] += 1
215
+ elif r.punctuation_changed:
216
+ analysis["by_model"]["punctuation"]["changed"] += 1
217
+ analysis["by_dataset"][ds]["punctuation_changed"] += 1
218
+ else:
219
+ analysis["by_model"]["punctuation"]["unchanged"] += 1
220
+
221
+ return analysis
222
+
223
+
224
+ def print_analysis(analysis: dict):
225
+ print(f"\n{'='*60}")
226
+ print("LEVEL 1: RAW MODEL ANALYSIS")
227
+ print(f"{'='*60}")
228
+
229
+ print(f"\n## Per-Model Summary ({analysis['total']} tests)")
230
+ print(f"| Model | Changed | Unchanged | Errors |")
231
+ print(f"|-------------|---------|-----------|--------|")
232
+ for model, data in analysis["by_model"].items():
233
+ print(f"| {model:<11} | {data['changed']:>7} | {data['unchanged']:>9} | {data['errors']:>6} |")
234
+
235
+ print(f"\n## Change Rate by Dataset")
236
+ print(f"| Dataset | Total | S-Changed | G-Changed | P-Changed |")
237
+ print(f"|--------------|-------|-----------|-----------|-----------|")
238
+ for ds, data in sorted(analysis["by_dataset"].items()):
239
+ print(f"| {ds:<12} | {data['total']:>5} | {data['spelling_changed']:>9} | {data['grammar_changed']:>9} | {data['punctuation_changed']:>9} |")
240
+
241
+
242
+ def main():
243
+ parser = argparse.ArgumentParser(description="Level 1: Raw Model Tests")
244
+ parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space")
245
+ parser.add_argument("--dataset", default=None, help="Filter to single dataset")
246
+ args = parser.parse_args()
247
+
248
+ api = APIClient(args.url)
249
+ datasets = load_datasets(args.dataset)
250
+
251
+ print(f"\n{'='*60}")
252
+ print("BAYAN v2.0 — Level 1: Raw Model Tests")
253
+ print(f"{'='*60}")
254
+ print(f" Target: {args.url}")
255
+ print(f" Datasets: {list(datasets.keys())}")
256
+ print(f" Total: {sum(len(v) for v in datasets.values())} tests")
257
+ print(f"{'='*60}")
258
+
259
+ results = run_level1(api, datasets)
260
+ analysis = analyze_results(results)
261
+ print_analysis(analysis)
262
+
263
+ # Save results
264
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
265
+ out_path = REPORT_DIR / "level1_raw_results.json"
266
+ report = {
267
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
268
+ "target": args.url,
269
+ "analysis": analysis,
270
+ "results": [asdict(r) for r in results],
271
+ }
272
+ with open(out_path, 'w', encoding='utf-8') as f:
273
+ json.dump(report, f, ensure_ascii=False, indent=2)
274
+ print(f"\n[L1] Results → {out_path}")
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
tests/v2/test_level2_solo.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BAYAN v2.0 — Level 2: Solo API Tests
3
+ =====================================
4
+ Tests each model through its INDIVIDUAL API endpoint (/api/spelling, /api/grammar,
5
+ /api/punctuation). This measures what each stage produces in isolation — with
6
+ any endpoint-level preprocessing but WITHOUT pipeline integration (StageLocker,
7
+ OffsetMapper, cross-stage interaction).
8
+
9
+ Compares with Level 1 raw results to measure filter impact:
10
+ - If L2 passes more tests than L1 → filters are helping
11
+ - If L2 passes fewer tests than L1 → filters are over-filtering
12
+
13
+ Usage:
14
+ python tests/v2/test_level2_solo.py --url URL [--dataset DATASET]
15
+ """
16
+ import argparse
17
+ import json
18
+ import re
19
+ import time
20
+ from pathlib import Path
21
+ from dataclasses import dataclass, asdict
22
+ from typing import List
23
+ import requests
24
+
25
+ DATASETS_DIR = Path(__file__).parent.parent / "phase10" / "gold_datasets"
26
+ REPORT_DIR = Path(__file__).parent / "reports"
27
+
28
+
29
+ def normalize(text):
30
+ t = re.sub(r'[\u064B-\u065F\u0670]', '', text)
31
+ t = re.sub(r'\s+', ' ', t).strip()
32
+ return t
33
+
34
+
35
+ @dataclass
36
+ class SoloResult:
37
+ id: str
38
+ dataset: str
39
+ category: str
40
+ input_text: str
41
+ expected: str
42
+ severity: str
43
+ # Solo API outputs (each model called independently on the SAME input)
44
+ spelling_solo: str = ""
45
+ spelling_ms: int = 0
46
+ grammar_solo: str = ""
47
+ grammar_ms: int = 0
48
+ punctuation_solo: str = ""
49
+ punctuation_ms: int = 0
50
+ # Verdict per model
51
+ spelling_verdict: str = "" # TP, TN, FP, FN
52
+ grammar_verdict: str = ""
53
+ punctuation_verdict: str = ""
54
+
55
+
56
+ class APIClient:
57
+ def __init__(self, base_url):
58
+ self.base = base_url.rstrip('/')
59
+ self.session = requests.Session()
60
+ self.session.headers['Content-Type'] = 'application/json'
61
+
62
+ def call(self, endpoint, text, timeout=120):
63
+ t0 = time.time()
64
+ try:
65
+ r = self.session.post(
66
+ f"{self.base}{endpoint}",
67
+ json={"text": text},
68
+ timeout=timeout
69
+ )
70
+ ms = int((time.time() - t0) * 1000)
71
+ data = r.json()
72
+ corrected = data.get("corrected_text", data.get("corrected", text))
73
+ return corrected, ms
74
+ except Exception as e:
75
+ ms = int((time.time() - t0) * 1000)
76
+ return f"ERROR: {e}", ms
77
+
78
+
79
+ def classify_result(input_text, output_text, expected_text, dataset):
80
+ """Classify a single model output as TP/TN/FP/FN.
81
+
82
+ For datasets that test CORRECTION (spelling, grammar, punctuation):
83
+ - TP: model corrected AND the correction is in the expected direction
84
+ - FN: model did NOT correct (output == input) but should have
85
+ - FP: model corrected but incorrectly (changed text that was correct or wrong direction)
86
+ - TN: model correctly left unchanged (output == input AND input was correct)
87
+
88
+ For datasets that test PRESERVATION (entities, religious, structured, hallucination):
89
+ - TN: model left text unchanged → PASS
90
+ - FP: model modified text → FAIL
91
+ """
92
+ inp_n = normalize(input_text)
93
+ out_n = normalize(output_text)
94
+ exp_n = normalize(expected_text)
95
+
96
+ is_preservation = dataset in ('entities', 'religious', 'structured', 'hallucination')
97
+ text_changed = (out_n != inp_n)
98
+
99
+ if is_preservation:
100
+ # For preservation tests, the expected output == input (don't change)
101
+ if not text_changed:
102
+ return "TN" # Correctly preserved
103
+ else:
104
+ return "FP" # Incorrectly modified
105
+ else:
106
+ # For correction tests
107
+ needs_correction = (inp_n != exp_n)
108
+
109
+ if needs_correction:
110
+ if text_changed:
111
+ # Check if output matches expected (or is closer to expected)
112
+ if out_n == exp_n:
113
+ return "TP" # Perfect correction
114
+ elif _edit_distance(out_n, exp_n) < _edit_distance(inp_n, exp_n):
115
+ return "TP" # Partial but improving correction
116
+ else:
117
+ return "FP" # Changed but not in right direction
118
+ else:
119
+ return "FN" # Should have corrected but didn't
120
+ else:
121
+ if text_changed:
122
+ return "FP" # Changed text that was already correct
123
+ else:
124
+ return "TN" # Correctly left unchanged
125
+
126
+
127
+ def _edit_distance(a, b):
128
+ """Simple Levenshtein edit distance."""
129
+ if len(a) < len(b):
130
+ return _edit_distance(b, a)
131
+ if len(b) == 0:
132
+ return len(a)
133
+
134
+ prev = list(range(len(b) + 1))
135
+ for i, ca in enumerate(a):
136
+ curr = [i + 1]
137
+ for j, cb in enumerate(b):
138
+ cost = 0 if ca == cb else 1
139
+ curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
140
+ prev = curr
141
+ return prev[len(b)]
142
+
143
+
144
+ def load_datasets(dataset_filter=None):
145
+ datasets = {}
146
+ for f in sorted(DATASETS_DIR.glob("*.json")):
147
+ name = f.stem
148
+ if dataset_filter and name != dataset_filter:
149
+ continue
150
+ with open(f, 'r', encoding='utf-8') as fh:
151
+ datasets[name] = json.load(fh)
152
+ return datasets
153
+
154
+
155
+ def run_level2(api: APIClient, datasets: dict) -> List[SoloResult]:
156
+ results = []
157
+ total = sum(len(v) for v in datasets.values())
158
+ idx = 0
159
+
160
+ for ds_name, cases in datasets.items():
161
+ print(f"\n{'='*60}")
162
+ print(f"DATASET: {ds_name.upper()} ({len(cases)} samples)")
163
+ print(f"{'='*60}")
164
+
165
+ for case in cases:
166
+ idx += 1
167
+ cid = case.get('id', f'{ds_name}_{idx}')
168
+ cat = case.get('category', '')
169
+ inp = case.get('input', '')
170
+ expected = case.get('expected', case.get('input', ''))
171
+ severity = case.get('severity', '')
172
+
173
+ r = SoloResult(
174
+ id=cid, dataset=ds_name, category=cat,
175
+ input_text=inp, expected=expected, severity=severity
176
+ )
177
+
178
+ print(f" [{idx}/{total}] {cid} ({cat})...", end=" ", flush=True)
179
+
180
+ # Test each model independently on the SAME original input
181
+ r.spelling_solo, r.spelling_ms = api.call("/api/spelling", inp)
182
+ r.grammar_solo, r.grammar_ms = api.call("/api/grammar", inp)
183
+ r.punctuation_solo, r.punctuation_ms = api.call("/api/punctuation", inp)
184
+
185
+ # Classify each model's result
186
+ r.spelling_verdict = classify_result(inp, r.spelling_solo, expected, ds_name)
187
+ r.grammar_verdict = classify_result(inp, r.grammar_solo, expected, ds_name)
188
+ r.punctuation_verdict = classify_result(inp, r.punctuation_solo, expected, ds_name)
189
+
190
+ total_ms = r.spelling_ms + r.grammar_ms + r.punctuation_ms
191
+ print(f"S={r.spelling_verdict} G={r.grammar_verdict} P={r.punctuation_verdict} ({total_ms}ms)")
192
+
193
+ results.append(r)
194
+
195
+ return results
196
+
197
+
198
+ def analyze_and_print(results: List[SoloResult]) -> dict:
199
+ analysis = {"total": len(results), "by_model": {}, "by_dataset": {}}
200
+
201
+ for model in ("spelling", "grammar", "punctuation"):
202
+ verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0}
203
+ for r in results:
204
+ v = getattr(r, f"{model}_verdict", "")
205
+ if v in verdicts:
206
+ verdicts[v] += 1
207
+ total = sum(verdicts.values())
208
+ pass_count = verdicts["TP"] + verdicts["TN"]
209
+ analysis["by_model"][model] = {
210
+ **verdicts,
211
+ "pass_rate": round(pass_count / total, 4) if total else 0,
212
+ }
213
+
214
+ # Per-dataset breakdown
215
+ for ds in set(r.dataset for r in results):
216
+ ds_results = [r for r in results if r.dataset == ds]
217
+ ds_analysis = {}
218
+ for model in ("spelling", "grammar", "punctuation"):
219
+ verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0}
220
+ for r in ds_results:
221
+ v = getattr(r, f"{model}_verdict", "")
222
+ if v in verdicts:
223
+ verdicts[v] += 1
224
+ total = sum(verdicts.values())
225
+ pass_count = verdicts["TP"] + verdicts["TN"]
226
+ ds_analysis[model] = {
227
+ **verdicts,
228
+ "pass_rate": round(pass_count / total, 4) if total else 0,
229
+ }
230
+ analysis["by_dataset"][ds] = {"total": len(ds_results), **ds_analysis}
231
+
232
+ # Print
233
+ print(f"\n{'='*60}")
234
+ print("LEVEL 2: SOLO API ANALYSIS")
235
+ print(f"{'='*60}")
236
+
237
+ print(f"\n## Per-Model Summary ({analysis['total']} tests)")
238
+ print(f"| Model | TP | TN | FP | FN | Pass% |")
239
+ print(f"|-------------|-----|-----|-----|-----|--------|")
240
+ for model, data in analysis["by_model"].items():
241
+ print(f"| {model:<11} | {data['TP']:>3} | {data['TN']:>3} | {data['FP']:>3} | {data['FN']:>3} | {data['pass_rate']*100:5.1f}% |")
242
+
243
+ print(f"\n## Per-Dataset × Model Pass Rate")
244
+ print(f"| Dataset | Spelling | Grammar | Punctuation |")
245
+ print(f"|--------------|----------|---------|-------------|")
246
+ for ds in sorted(analysis["by_dataset"].keys()):
247
+ d = analysis["by_dataset"][ds]
248
+ s = d["spelling"]["pass_rate"] * 100
249
+ g = d["grammar"]["pass_rate"] * 100
250
+ p = d["punctuation"]["pass_rate"] * 100
251
+ print(f"| {ds:<12} | {s:6.1f}% | {g:5.1f}% | {p:9.1f}% |")
252
+
253
+ return analysis
254
+
255
+
256
+ def main():
257
+ parser = argparse.ArgumentParser(description="Level 2: Solo API Tests")
258
+ parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space")
259
+ parser.add_argument("--dataset", default=None)
260
+ args = parser.parse_args()
261
+
262
+ api = APIClient(args.url)
263
+ datasets = load_datasets(args.dataset)
264
+
265
+ print(f"\n{'='*60}")
266
+ print("BAYAN v2.0 — Level 2: Solo API Tests")
267
+ print(f"{'='*60}")
268
+ print(f" Target: {args.url}")
269
+ print(f" Datasets: {list(datasets.keys())}")
270
+ print(f" Total: {sum(len(v) for v in datasets.values())} tests")
271
+
272
+ results = run_level2(api, datasets)
273
+ analysis = analyze_and_print(results)
274
+
275
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
276
+ out_path = REPORT_DIR / "level2_solo_results.json"
277
+ report = {
278
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
279
+ "target": args.url,
280
+ "analysis": analysis,
281
+ "results": [asdict(r) for r in results],
282
+ }
283
+ with open(out_path, 'w', encoding='utf-8') as f:
284
+ json.dump(report, f, ensure_ascii=False, indent=2)
285
+ print(f"\n[L2] Results → {out_path}")
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
tests/v2/test_level3_integrated.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BAYAN v2.0 — Level 3: Integrated Pipeline Tests
3
+ =================================================
4
+ Tests the FULL integrated pipeline through /api/analyze.
5
+ This is the end-to-end test: Spelling → Grammar → Punctuation
6
+ with all filters, StageLocker, OffsetMapper, PatchSet.
7
+
8
+ Reuses the exact same verdict logic as the existing benchmark_runner.py
9
+ to ensure comparability.
10
+
11
+ Usage:
12
+ python tests/v2/test_level3_integrated.py --url URL [--dataset DATASET]
13
+ """
14
+ import argparse
15
+ import json
16
+ import re
17
+ import time
18
+ from pathlib import Path
19
+ from dataclasses import dataclass, asdict
20
+ from typing import List
21
+ import requests
22
+
23
+ DATASETS_DIR = Path(__file__).parent.parent / "phase10" / "gold_datasets"
24
+ REPORT_DIR = Path(__file__).parent / "reports"
25
+
26
+
27
+ def normalize(text):
28
+ t = re.sub(r'[\u064B-\u065F\u0670]', '', text)
29
+ t = re.sub(r'\s+', ' ', t).strip()
30
+ return t
31
+
32
+
33
+ @dataclass
34
+ class IntegratedResult:
35
+ id: str
36
+ dataset: str
37
+ category: str
38
+ input_text: str
39
+ expected: str
40
+ severity: str
41
+ # Pipeline output
42
+ pipeline_corrected: str = ""
43
+ pipeline_suggestions: int = 0
44
+ pipeline_ms: int = 0
45
+ spelling_ms: int = 0
46
+ grammar_ms: int = 0
47
+ punctuation_ms: int = 0
48
+ # Verdict
49
+ verdict: str = "" # TP, TN, FP, FN
50
+ detail: str = ""
51
+
52
+
53
+ class APIClient:
54
+ def __init__(self, base_url):
55
+ self.base = base_url.rstrip('/')
56
+ self.session = requests.Session()
57
+ self.session.headers['Content-Type'] = 'application/json'
58
+
59
+ def analyze(self, text, timeout=120):
60
+ t0 = time.time()
61
+ try:
62
+ r = self.session.post(
63
+ f"{self.base}/api/analyze",
64
+ json={"text": text},
65
+ timeout=timeout
66
+ )
67
+ ms = int((time.time() - t0) * 1000)
68
+ return r.json(), ms
69
+ except Exception as e:
70
+ ms = int((time.time() - t0) * 1000)
71
+ return {"error": str(e)}, ms
72
+
73
+
74
+ def classify_pipeline(input_text, corrected_text, expected_text, dataset, entity=None):
75
+ """Classify pipeline output.
76
+
77
+ For correction datasets (spelling, grammar, punctuation, collision):
78
+ Input has errors → should be corrected to match expected.
79
+
80
+ For preservation datasets (entities, religious, structured, hallucination):
81
+ Input is correct → should NOT be modified.
82
+ For entity tests: specifically check that the entity string is preserved.
83
+ """
84
+ inp_n = normalize(input_text)
85
+ out_n = normalize(corrected_text)
86
+ exp_n = normalize(expected_text)
87
+
88
+ is_preservation = dataset in ('entities', 'religious', 'structured', 'hallucination')
89
+ text_changed = (out_n != inp_n)
90
+
91
+ if dataset == 'entities' and entity:
92
+ # Entity tests: check if entity is preserved in output
93
+ entity_n = normalize(entity)
94
+ if entity_n in out_n:
95
+ return "TN", "Entity preserved"
96
+ elif not text_changed:
97
+ return "TN", "Text unchanged"
98
+ else:
99
+ return "FP", f"ENTITY CORRUPTED: '{entity}' missing from output"
100
+
101
+ if is_preservation:
102
+ if not text_changed:
103
+ return "TN", "Text correctly preserved"
104
+ else:
105
+ # Check what changed
106
+ inp_words = inp_n.split()
107
+ out_words = out_n.split()
108
+ changes = []
109
+ for iw, ow in zip(inp_words, out_words):
110
+ if iw != ow:
111
+ changes.append(f"{iw}→{ow}")
112
+ detail = f"Text modified: {changes[:5]}"
113
+ return "FP", detail
114
+ else:
115
+ # Correction dataset
116
+ needs_correction = (inp_n != exp_n)
117
+
118
+ if needs_correction:
119
+ if out_n == exp_n:
120
+ return "TP", "Exact match"
121
+ elif text_changed and _closer(out_n, inp_n, exp_n):
122
+ return "TP", "Partial improvement"
123
+ elif not text_changed:
124
+ return "FN", "No correction applied"
125
+ else:
126
+ return "FP", f"Wrong correction"
127
+ else:
128
+ if not text_changed:
129
+ return "TN", "Correctly unchanged"
130
+ else:
131
+ return "FP", f"Modified correct text"
132
+
133
+
134
+ def _closer(output, input_text, expected):
135
+ """Is output closer to expected than input was?"""
136
+ d_out = _edit_distance(output, expected)
137
+ d_inp = _edit_distance(input_text, expected)
138
+ return d_out < d_inp
139
+
140
+
141
+ def _edit_distance(a, b):
142
+ if len(a) < len(b):
143
+ return _edit_distance(b, a)
144
+ if len(b) == 0:
145
+ return len(a)
146
+ prev = list(range(len(b) + 1))
147
+ for i, ca in enumerate(a):
148
+ curr = [i + 1]
149
+ for j, cb in enumerate(b):
150
+ cost = 0 if ca == cb else 1
151
+ curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
152
+ prev = curr
153
+ return prev[len(b)]
154
+
155
+
156
+ def load_datasets(dataset_filter=None):
157
+ datasets = {}
158
+ for f in sorted(DATASETS_DIR.glob("*.json")):
159
+ name = f.stem
160
+ if dataset_filter and name != dataset_filter:
161
+ continue
162
+ with open(f, 'r', encoding='utf-8') as fh:
163
+ datasets[name] = json.load(fh)
164
+ return datasets
165
+
166
+
167
+ def run_level3(api: APIClient, datasets: dict) -> List[IntegratedResult]:
168
+ results = []
169
+ total = sum(len(v) for v in datasets.values())
170
+ idx = 0
171
+
172
+ for ds_name, cases in datasets.items():
173
+ print(f"\n{'='*60}")
174
+ print(f"DATASET: {ds_name.upper()} ({len(cases)} samples)")
175
+ print(f"{'='*60}")
176
+
177
+ tp = tn = fp = fn = 0
178
+ for case in cases:
179
+ idx += 1
180
+ cid = case.get('id', f'{ds_name}_{idx}')
181
+ cat = case.get('category', '')
182
+ inp = case.get('input', '')
183
+ expected = case.get('expected', case.get('input', ''))
184
+ severity = case.get('severity', '')
185
+ entity = case.get('entity', None)
186
+
187
+ r = IntegratedResult(
188
+ id=cid, dataset=ds_name, category=cat,
189
+ input_text=inp, expected=expected, severity=severity
190
+ )
191
+
192
+ print(f" [{idx}/{total}] {cid} ({cat})...", end=" ", flush=True)
193
+
194
+ data, ms = api.analyze(inp)
195
+ r.pipeline_ms = ms
196
+ r.pipeline_corrected = data.get('corrected', inp)
197
+ r.pipeline_suggestions = len(data.get('suggestions', []))
198
+ timing = data.get('timing_ms', {})
199
+ r.spelling_ms = timing.get('spelling_ms', 0)
200
+ r.grammar_ms = timing.get('grammar_ms', 0)
201
+ r.punctuation_ms = timing.get('punctuation_ms', 0)
202
+
203
+ r.verdict, r.detail = classify_pipeline(
204
+ inp, r.pipeline_corrected, expected, ds_name, entity
205
+ )
206
+
207
+ icon = {"TP": "✅", "TN": "✅", "FP": "❌", "FN": "⚠️"}.get(r.verdict, "?")
208
+ print(f"{icon} {r.verdict} ({r.pipeline_ms}ms)")
209
+
210
+ if r.verdict == "TP": tp += 1
211
+ elif r.verdict == "TN": tn += 1
212
+ elif r.verdict == "FP": fp += 1
213
+ elif r.verdict == "FN": fn += 1
214
+
215
+ results.append(r)
216
+
217
+ total_ds = tp + tn + fp + fn
218
+ pass_pct = (tp + tn) / total_ds * 100 if total_ds else 0
219
+ print(f"\n Pass={pass_pct:.1f}% TP={tp} TN={tn} FP={fp} FN={fn}")
220
+
221
+ return results
222
+
223
+
224
+ def analyze_and_print(results: List[IntegratedResult]) -> dict:
225
+ verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0}
226
+ by_dataset = {}
227
+
228
+ for r in results:
229
+ verdicts[r.verdict] = verdicts.get(r.verdict, 0) + 1
230
+ if r.dataset not in by_dataset:
231
+ by_dataset[r.dataset] = {"TP": 0, "TN": 0, "FP": 0, "FN": 0, "total": 0}
232
+ by_dataset[r.dataset][r.verdict] += 1
233
+ by_dataset[r.dataset]["total"] += 1
234
+
235
+ total = sum(verdicts.values())
236
+ pass_count = verdicts["TP"] + verdicts["TN"]
237
+
238
+ analysis = {
239
+ "total": total,
240
+ "aggregate": {
241
+ **verdicts,
242
+ "pass_rate": round(pass_count / total, 4) if total else 0,
243
+ },
244
+ "by_dataset": {},
245
+ }
246
+
247
+ print(f"\n{'='*60}")
248
+ print("LEVEL 3: INTEGRATED PIPELINE ANALYSIS")
249
+ print(f"{'='*60}")
250
+ print(f"\n Total: {total} | Pass: {pass_count}/{total} ({analysis['aggregate']['pass_rate']*100:.1f}%)")
251
+ print(f" TP={verdicts['TP']} TN={verdicts['TN']} FP={verdicts['FP']} FN={verdicts['FN']}")
252
+
253
+ print(f"\n| Dataset | Total | TP | TN | FP | FN | Pass% |")
254
+ print(f"|--------------|-------|-----|-----|-----|-----|--------|")
255
+ for ds in sorted(by_dataset.keys()):
256
+ d = by_dataset[ds]
257
+ p = (d["TP"] + d["TN"]) / d["total"] * 100 if d["total"] else 0
258
+ print(f"| {ds:<12} | {d['total']:>5} | {d['TP']:>3} | {d['TN']:>3} | {d['FP']:>3} | {d['FN']:>3} | {p:5.1f}% |")
259
+ analysis["by_dataset"][ds] = {**d, "pass_rate": round(p / 100, 4)}
260
+
261
+ return analysis
262
+
263
+
264
+ def main():
265
+ parser = argparse.ArgumentParser(description="Level 3: Integrated Pipeline Tests")
266
+ parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space")
267
+ parser.add_argument("--dataset", default=None)
268
+ args = parser.parse_args()
269
+
270
+ api = APIClient(args.url)
271
+ datasets = load_datasets(args.dataset)
272
+
273
+ print(f"\n{'='*60}")
274
+ print("BAYAN v2.0 — Level 3: Integrated Pipeline Tests")
275
+ print(f"{'='*60}")
276
+ print(f" Target: {args.url}")
277
+ print(f" Datasets: {list(datasets.keys())}")
278
+ print(f" Total: {sum(len(v) for v in datasets.values())} tests")
279
+
280
+ results = run_level3(api, datasets)
281
+ analysis = analyze_and_print(results)
282
+
283
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
284
+ out_path = REPORT_DIR / "level3_integrated_results.json"
285
+ report = {
286
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
287
+ "target": args.url,
288
+ "analysis": analysis,
289
+ "results": [asdict(r) for r in results],
290
+ }
291
+ with open(out_path, 'w', encoding='utf-8') as f:
292
+ json.dump(report, f, ensure_ascii=False, indent=2)
293
+ print(f"\n[L3] Results → {out_path}")
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()