Phase 11: Telemetry instrumentation + OffsetMapper tests
Browse filesTask 5: OffsetMapper validation suite (36 tests, all passing)
- Covers: insertions, deletions, replacements, Arabic text, multi-edits, chained mutations
- FINDINGS: Delete-boundary off-by-one behavior documented
- FINDINGS: Tanween deletion end-position mapping loss documented
Task 1: Grammar filter telemetry instrumentation
- Added [FILTER-TEL] JSON events at every grammar filter decision point
- Events: grammar_raw_output, grammar_diffs_extracted, grammar_diff,
filter_reject (StageLocker|Jaccard_03|Jaccard_05|DigitGuard|TanweenGuard|
PunctuationGuard|LatinGuard|DirectionalBlock|IVtoOOV), patch_accepted
- NO correction logic changes
Task 8: Local telemetry capture tool
- telemetry_capture.py: runs benchmark, fetches HF logs, extracts events
- Stores: artifacts/telemetry.jsonl, artifacts/telemetry_summary.json
- src/app.py +20 -2
- tests/phase10/reports/phase10_results.json +0 -0
- tests/phase11/telemetry_capture.py +203 -0
- tests/test_offset_mapper.py +469 -0
|
@@ -1575,6 +1575,10 @@ def analyze_text():
|
|
| 1575 |
timing_ms['grammar_ms'] = int((time.time() - t0) * 1000)
|
| 1576 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1578 |
# FIX-03: Restore structured content in grammar output
|
| 1579 |
if _structured_placeholders:
|
| 1580 |
# Restore in forward order
|
|
@@ -1584,18 +1588,22 @@ def analyze_text():
|
|
| 1584 |
if corrected_grammar != ctx.current_text:
|
| 1585 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
| 1586 |
_grammar_accepted_diffs = [] # FIX-04: track accepted diffs
|
|
|
|
|
|
|
| 1587 |
for d in diffs:
|
|
|
|
|
|
|
|
|
|
| 1588 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1589 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1590 |
logger.info(
|
| 1591 |
f"[LOCK] Grammar blocked on [{d['start']}:{d['end']}] "
|
| 1592 |
f"'{d.get('original','')}' — locked by previous stage"
|
| 1593 |
)
|
|
|
|
| 1594 |
continue
|
| 1595 |
|
| 1596 |
# Reject grammar hallucinations (e.g. جالس→جاكسون)
|
| 1597 |
-
orig_text = d.get('original', '')
|
| 1598 |
-
corr_text = d.get('correction', '')
|
| 1599 |
if orig_text and corr_text:
|
| 1600 |
orig_chars = set(orig_text.replace(' ', ''))
|
| 1601 |
corr_chars = set(corr_text.replace(' ', ''))
|
|
@@ -1606,6 +1614,7 @@ def analyze_text():
|
|
| 1606 |
f"[GRAMMAR] Rejected hallucination: '{orig_text}'→'{corr_text}' "
|
| 1607 |
f"(jaccard={jaccard:.2f})"
|
| 1608 |
)
|
|
|
|
| 1609 |
continue
|
| 1610 |
|
| 1611 |
# ── FIX-13: Named entity protection ──
|
|
@@ -1620,6 +1629,7 @@ def analyze_text():
|
|
| 1620 |
f"[GRAMMAR] Skipping entity (contains Latin): "
|
| 1621 |
f"'{orig_text}'→'{corr_text}'"
|
| 1622 |
)
|
|
|
|
| 1623 |
continue
|
| 1624 |
|
| 1625 |
# ── FIX-22: Emoji protection ──
|
|
@@ -1644,6 +1654,7 @@ def analyze_text():
|
|
| 1644 |
f"[GRAMMAR] Blocked tanween removal: "
|
| 1645 |
f"'{orig_text}'→'{corr_text}'"
|
| 1646 |
)
|
|
|
|
| 1647 |
continue
|
| 1648 |
|
| 1649 |
# ── FIX-24: Grammar punctuation stripping blocker ──
|
|
@@ -1660,6 +1671,7 @@ def analyze_text():
|
|
| 1660 |
f"[GRAMMAR] Blocked punct stripping: "
|
| 1661 |
f"'{orig_text}'→'{corr_text}'"
|
| 1662 |
)
|
|
|
|
| 1663 |
continue
|
| 1664 |
# Also block combined tanween + punct stripping
|
| 1665 |
_TANWEEN2 = '\u064B\u064C\u064D'
|
|
@@ -1670,6 +1682,7 @@ def analyze_text():
|
|
| 1670 |
f"[GRAMMAR] Blocked tanween+punct strip: "
|
| 1671 |
f"'{orig_text}'→'{corr_text}'"
|
| 1672 |
)
|
|
|
|
| 1673 |
continue
|
| 1674 |
|
| 1675 |
# ── FIX-25: Grammar punctuation spacing blocker ──
|
|
@@ -1702,6 +1715,7 @@ def analyze_text():
|
|
| 1702 |
f"[GRAMMAR] Blocked digit-containing diff: "
|
| 1703 |
f"'{orig_text}'\u2192'{corr_text}'"
|
| 1704 |
)
|
|
|
|
| 1705 |
continue
|
| 1706 |
|
| 1707 |
# ── FIX-27b: Grammar hallucination guard (Jaccard) ──
|
|
@@ -1720,6 +1734,7 @@ def analyze_text():
|
|
| 1720 |
f"[GRAMMAR] Blocked low-Jaccard diff (j={_jac:.2f}): "
|
| 1721 |
f"'{orig_text}'\u2192'{corr_text}'"
|
| 1722 |
)
|
|
|
|
| 1723 |
continue
|
| 1724 |
|
| 1725 |
# ── FIX-06: Directional block protection for grammar ──
|
|
@@ -1729,6 +1744,7 @@ def analyze_text():
|
|
| 1729 |
logger.info(
|
| 1730 |
f"[GRAMMAR] Directional block: '{orig_text}'→'{corr_text}'"
|
| 1731 |
)
|
|
|
|
| 1732 |
continue
|
| 1733 |
# Also check with clitic prefixes
|
| 1734 |
_gram_dir_blocked = False
|
|
@@ -1806,6 +1822,7 @@ def analyze_text():
|
|
| 1806 |
f"[GRAMMAR] Rejected corruption: '{orig_text}'→'{corr_text}' "
|
| 1807 |
f"(valid word → non-word)"
|
| 1808 |
)
|
|
|
|
| 1809 |
continue
|
| 1810 |
except Exception:
|
| 1811 |
pass
|
|
@@ -1825,6 +1842,7 @@ def analyze_text():
|
|
| 1825 |
if _is_spelling_only_change(orig_text, corr_text):
|
| 1826 |
stage_label = 'spelling'
|
| 1827 |
_grammar_accepted_diffs.append(d) # FIX-04: track accepted
|
|
|
|
| 1828 |
ctx.add_patch(
|
| 1829 |
stage_label, d['start'], d['end'],
|
| 1830 |
corr_text, confidence=1.0
|
|
|
|
| 1575 |
timing_ms['grammar_ms'] = int((time.time() - t0) * 1000)
|
| 1576 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1577 |
|
| 1578 |
+
# ── Phase 11: Telemetry — raw grammar output ──
|
| 1579 |
+
import json as _tel_json
|
| 1580 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"grammar_raw_output","input":_grammar_input_text[:200],"output":corrected_grammar[:200]})}')
|
| 1581 |
+
|
| 1582 |
# FIX-03: Restore structured content in grammar output
|
| 1583 |
if _structured_placeholders:
|
| 1584 |
# Restore in forward order
|
|
|
|
| 1588 |
if corrected_grammar != ctx.current_text:
|
| 1589 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
| 1590 |
_grammar_accepted_diffs = [] # FIX-04: track accepted diffs
|
| 1591 |
+
_grammar_total_diffs = len(diffs)
|
| 1592 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"grammar_diffs_extracted","count":_grammar_total_diffs})}')
|
| 1593 |
for d in diffs:
|
| 1594 |
+
orig_text = d.get('original', '')
|
| 1595 |
+
corr_text = d.get('correction', '')
|
| 1596 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"grammar_diff","original":orig_text[:80],"correction":corr_text[:80],"start":d["start"],"end":d["end"]})}')
|
| 1597 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1598 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1599 |
logger.info(
|
| 1600 |
f"[LOCK] Grammar blocked on [{d['start']}:{d['end']}] "
|
| 1601 |
f"'{d.get('original','')}' — locked by previous stage"
|
| 1602 |
)
|
| 1603 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"StageLocker","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1604 |
continue
|
| 1605 |
|
| 1606 |
# Reject grammar hallucinations (e.g. جالس→جاكسون)
|
|
|
|
|
|
|
| 1607 |
if orig_text and corr_text:
|
| 1608 |
orig_chars = set(orig_text.replace(' ', ''))
|
| 1609 |
corr_chars = set(corr_text.replace(' ', ''))
|
|
|
|
| 1614 |
f"[GRAMMAR] Rejected hallucination: '{orig_text}'→'{corr_text}' "
|
| 1615 |
f"(jaccard={jaccard:.2f})"
|
| 1616 |
)
|
| 1617 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"Jaccard_03","original":orig_text[:80],"correction":corr_text[:80],"jaccard":round(jaccard,3)})}')
|
| 1618 |
continue
|
| 1619 |
|
| 1620 |
# ── FIX-13: Named entity protection ──
|
|
|
|
| 1629 |
f"[GRAMMAR] Skipping entity (contains Latin): "
|
| 1630 |
f"'{orig_text}'→'{corr_text}'"
|
| 1631 |
)
|
| 1632 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"LatinGuard","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1633 |
continue
|
| 1634 |
|
| 1635 |
# ── FIX-22: Emoji protection ──
|
|
|
|
| 1654 |
f"[GRAMMAR] Blocked tanween removal: "
|
| 1655 |
f"'{orig_text}'→'{corr_text}'"
|
| 1656 |
)
|
| 1657 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"TanweenGuard","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1658 |
continue
|
| 1659 |
|
| 1660 |
# ── FIX-24: Grammar punctuation stripping blocker ──
|
|
|
|
| 1671 |
f"[GRAMMAR] Blocked punct stripping: "
|
| 1672 |
f"'{orig_text}'→'{corr_text}'"
|
| 1673 |
)
|
| 1674 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"PunctuationGuard","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1675 |
continue
|
| 1676 |
# Also block combined tanween + punct stripping
|
| 1677 |
_TANWEEN2 = '\u064B\u064C\u064D'
|
|
|
|
| 1682 |
f"[GRAMMAR] Blocked tanween+punct strip: "
|
| 1683 |
f"'{orig_text}'→'{corr_text}'"
|
| 1684 |
)
|
| 1685 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"PunctuationGuard","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1686 |
continue
|
| 1687 |
|
| 1688 |
# ── FIX-25: Grammar punctuation spacing blocker ──
|
|
|
|
| 1715 |
f"[GRAMMAR] Blocked digit-containing diff: "
|
| 1716 |
f"'{orig_text}'\u2192'{corr_text}'"
|
| 1717 |
)
|
| 1718 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"DigitGuard","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1719 |
continue
|
| 1720 |
|
| 1721 |
# ── FIX-27b: Grammar hallucination guard (Jaccard) ──
|
|
|
|
| 1734 |
f"[GRAMMAR] Blocked low-Jaccard diff (j={_jac:.2f}): "
|
| 1735 |
f"'{orig_text}'\u2192'{corr_text}'"
|
| 1736 |
)
|
| 1737 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"Jaccard_05","original":orig_text[:80],"correction":corr_text[:80],"jaccard":round(_jac,3)})}')
|
| 1738 |
continue
|
| 1739 |
|
| 1740 |
# ── FIX-06: Directional block protection for grammar ──
|
|
|
|
| 1744 |
logger.info(
|
| 1745 |
f"[GRAMMAR] Directional block: '{orig_text}'→'{corr_text}'"
|
| 1746 |
)
|
| 1747 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"DirectionalBlock","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1748 |
continue
|
| 1749 |
# Also check with clitic prefixes
|
| 1750 |
_gram_dir_blocked = False
|
|
|
|
| 1822 |
f"[GRAMMAR] Rejected corruption: '{orig_text}'→'{corr_text}' "
|
| 1823 |
f"(valid word → non-word)"
|
| 1824 |
)
|
| 1825 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"filter_reject","filter":"IVtoOOV","original":orig_text[:80],"correction":corr_text[:80]})}')
|
| 1826 |
continue
|
| 1827 |
except Exception:
|
| 1828 |
pass
|
|
|
|
| 1842 |
if _is_spelling_only_change(orig_text, corr_text):
|
| 1843 |
stage_label = 'spelling'
|
| 1844 |
_grammar_accepted_diffs.append(d) # FIX-04: track accepted
|
| 1845 |
+
logger.info(f'[FILTER-TEL] {_tel_json.dumps({"event":"patch_accepted","stage":stage_label,"original":orig_text[:80],"correction":corr_text[:80],"start":d["start"],"end":d["end"]})}')
|
| 1846 |
ctx.add_patch(
|
| 1847 |
stage_label, d['start'], d['end'],
|
| 1848 |
corr_text, confidence=1.0
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 11 — Task 8: Local Telemetry Capture
|
| 3 |
+
|
| 4 |
+
Runs all benchmark samples through the pipeline and captures structured
|
| 5 |
+
telemetry events from HF logs. Stores telemetry locally as JSONL files.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python tests/phase11/telemetry_capture.py --url https://bayan10-bayan-api.hf.space
|
| 9 |
+
|
| 10 |
+
Output:
|
| 11 |
+
tests/phase11/artifacts/telemetry.jsonl
|
| 12 |
+
tests/phase11/artifacts/telemetry_summary.json
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
import re
|
| 20 |
+
import requests
|
| 21 |
+
import glob
|
| 22 |
+
|
| 23 |
+
# Add project root to path
|
| 24 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 25 |
+
|
| 26 |
+
HF_API_TOKEN = os.environ.get('HF_API_TOKEN', '')
|
| 27 |
+
SPACE_ID = 'bayan10/bayan-api'
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def load_all_datasets():
|
| 31 |
+
"""Load all benchmark samples from gold datasets."""
|
| 32 |
+
datasets_dir = os.path.join(os.path.dirname(__file__), '..', 'phase10', 'gold_datasets')
|
| 33 |
+
samples = []
|
| 34 |
+
for f in sorted(glob.glob(os.path.join(datasets_dir, '*.json'))):
|
| 35 |
+
name = os.path.basename(f).replace('.json', '')
|
| 36 |
+
data = json.load(open(f, encoding='utf-8'))
|
| 37 |
+
items = data if isinstance(data, list) else data.get('samples', [])
|
| 38 |
+
for item in items:
|
| 39 |
+
item['dataset'] = name
|
| 40 |
+
samples.extend(items)
|
| 41 |
+
return samples
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def call_analyze(url, text):
|
| 45 |
+
"""Call the /api/analyze endpoint."""
|
| 46 |
+
resp = requests.post(
|
| 47 |
+
f"{url}/api/analyze",
|
| 48 |
+
json={"text": text},
|
| 49 |
+
timeout=120
|
| 50 |
+
)
|
| 51 |
+
resp.raise_for_status()
|
| 52 |
+
return resp.json()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fetch_logs(n_lines=500):
|
| 56 |
+
"""Fetch recent HF Spaces logs."""
|
| 57 |
+
url = f"https://api.hf.space/v1/{SPACE_ID}/logs"
|
| 58 |
+
headers = {"Authorization": f"Bearer {HF_API_TOKEN}"}
|
| 59 |
+
try:
|
| 60 |
+
resp = requests.get(url, headers=headers, timeout=30, stream=True)
|
| 61 |
+
lines = []
|
| 62 |
+
for line in resp.iter_lines(decode_unicode=True):
|
| 63 |
+
if line:
|
| 64 |
+
lines.append(line)
|
| 65 |
+
if len(lines) >= n_lines:
|
| 66 |
+
break
|
| 67 |
+
return lines
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f" [WARN] Failed to fetch logs: {e}")
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def extract_telemetry_events(log_lines):
|
| 74 |
+
"""Extract [FILTER-TEL] JSON events from log lines."""
|
| 75 |
+
events = []
|
| 76 |
+
for line in log_lines:
|
| 77 |
+
# Find [FILTER-TEL] markers
|
| 78 |
+
match = re.search(r'\[FILTER-TEL\]\s*(\{.*\})', line)
|
| 79 |
+
if match:
|
| 80 |
+
try:
|
| 81 |
+
event = json.loads(match.group(1))
|
| 82 |
+
events.append(event)
|
| 83 |
+
except json.JSONDecodeError:
|
| 84 |
+
pass
|
| 85 |
+
return events
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def run_telemetry_capture(url, max_samples=None):
|
| 89 |
+
"""Run all samples and capture telemetry."""
|
| 90 |
+
samples = load_all_datasets()
|
| 91 |
+
if max_samples:
|
| 92 |
+
samples = samples[:max_samples]
|
| 93 |
+
|
| 94 |
+
artifacts_dir = os.path.join(os.path.dirname(__file__), 'artifacts')
|
| 95 |
+
os.makedirs(artifacts_dir, exist_ok=True)
|
| 96 |
+
telemetry_path = os.path.join(artifacts_dir, 'telemetry.jsonl')
|
| 97 |
+
summary_path = os.path.join(artifacts_dir, 'telemetry_summary.json')
|
| 98 |
+
|
| 99 |
+
all_events = []
|
| 100 |
+
sample_results = []
|
| 101 |
+
|
| 102 |
+
print(f"Running telemetry capture on {len(samples)} samples...")
|
| 103 |
+
print(f"URL: {url}")
|
| 104 |
+
print(f"Output: {telemetry_path}")
|
| 105 |
+
print()
|
| 106 |
+
|
| 107 |
+
with open(telemetry_path, 'w', encoding='utf-8') as f:
|
| 108 |
+
for i, sample in enumerate(samples):
|
| 109 |
+
sid = sample.get('id', f'UNKNOWN_{i}')
|
| 110 |
+
dataset = sample.get('dataset', 'unknown')
|
| 111 |
+
text = sample.get('input', '')
|
| 112 |
+
expected = sample.get('expected', text)
|
| 113 |
+
|
| 114 |
+
print(f" [{i+1}/{len(samples)}] {sid} ({dataset})...", end=' ', flush=True)
|
| 115 |
+
|
| 116 |
+
# Call the API
|
| 117 |
+
try:
|
| 118 |
+
result = call_analyze(url, text)
|
| 119 |
+
pipeline_output = result.get('corrected', text)
|
| 120 |
+
suggestions = result.get('suggestions', [])
|
| 121 |
+
timing = result.get('timing_ms', {})
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"ERROR: {e}")
|
| 124 |
+
sample_results.append({
|
| 125 |
+
'sample_id': sid,
|
| 126 |
+
'dataset': dataset,
|
| 127 |
+
'error': str(e)
|
| 128 |
+
})
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
# Small delay then fetch logs
|
| 132 |
+
time.sleep(0.3)
|
| 133 |
+
log_lines = fetch_logs(200)
|
| 134 |
+
events = extract_telemetry_events(log_lines)
|
| 135 |
+
|
| 136 |
+
# Write events for this sample
|
| 137 |
+
sample_record = {
|
| 138 |
+
'sample_id': sid,
|
| 139 |
+
'dataset': dataset,
|
| 140 |
+
'input': text[:200],
|
| 141 |
+
'expected': expected[:200],
|
| 142 |
+
'pipeline_output': pipeline_output[:200],
|
| 143 |
+
'passed': (pipeline_output.strip() == expected.strip()),
|
| 144 |
+
'suggestion_count': len(suggestions),
|
| 145 |
+
'timing': timing,
|
| 146 |
+
'telemetry_events': events
|
| 147 |
+
}
|
| 148 |
+
f.write(json.dumps(sample_record, ensure_ascii=False) + '\n')
|
| 149 |
+
all_events.extend(events)
|
| 150 |
+
sample_results.append(sample_record)
|
| 151 |
+
|
| 152 |
+
status = '✅' if sample_record['passed'] else '❌'
|
| 153 |
+
n_events = len(events)
|
| 154 |
+
print(f"{status} ({n_events} events)")
|
| 155 |
+
|
| 156 |
+
# Summary
|
| 157 |
+
filter_counts = {}
|
| 158 |
+
accepted_count = 0
|
| 159 |
+
for evt in all_events:
|
| 160 |
+
if evt.get('event') == 'filter_reject':
|
| 161 |
+
f_name = evt.get('filter', 'Unknown')
|
| 162 |
+
filter_counts[f_name] = filter_counts.get(f_name, 0) + 1
|
| 163 |
+
elif evt.get('event') == 'patch_accepted':
|
| 164 |
+
accepted_count += 1
|
| 165 |
+
|
| 166 |
+
total_diffs = sum(1 for e in all_events if e.get('event') == 'grammar_diff')
|
| 167 |
+
total_rejections = sum(filter_counts.values())
|
| 168 |
+
|
| 169 |
+
summary = {
|
| 170 |
+
'total_samples': len(samples),
|
| 171 |
+
'total_grammar_diffs': total_diffs,
|
| 172 |
+
'total_accepted': accepted_count,
|
| 173 |
+
'total_rejected': total_rejections,
|
| 174 |
+
'rejection_by_filter': dict(sorted(filter_counts.items(), key=lambda x: -x[1])),
|
| 175 |
+
'pass_rate': sum(1 for r in sample_results if r.get('passed', False)) / len(sample_results) * 100 if sample_results else 0,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
with open(summary_path, 'w', encoding='utf-8') as f:
|
| 179 |
+
json.dump(summary, f, indent=2, ensure_ascii=False)
|
| 180 |
+
|
| 181 |
+
print(f"\n{'='*60}")
|
| 182 |
+
print(f"TELEMETRY SUMMARY")
|
| 183 |
+
print(f"{'='*60}")
|
| 184 |
+
print(f" Samples: {summary['total_samples']}")
|
| 185 |
+
print(f" Grammar diffs generated: {summary['total_grammar_diffs']}")
|
| 186 |
+
print(f" Accepted: {summary['total_accepted']}")
|
| 187 |
+
print(f" Rejected: {summary['total_rejected']}")
|
| 188 |
+
print(f"\n Rejections by filter:")
|
| 189 |
+
for f_name, count in sorted(filter_counts.items(), key=lambda x: -x[1]):
|
| 190 |
+
print(f" {f_name}: {count}")
|
| 191 |
+
print(f"\n Pass rate: {summary['pass_rate']:.1f}%")
|
| 192 |
+
print(f"\n Saved: {telemetry_path}")
|
| 193 |
+
print(f" Saved: {summary_path}")
|
| 194 |
+
|
| 195 |
+
return summary, sample_results
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
if __name__ == '__main__':
|
| 199 |
+
parser = argparse.ArgumentParser(description='Phase 11 Telemetry Capture')
|
| 200 |
+
parser.add_argument('--url', default='https://bayan10-bayan-api.hf.space')
|
| 201 |
+
parser.add_argument('--max-samples', type=int, default=None)
|
| 202 |
+
args = parser.parse_args()
|
| 203 |
+
run_telemetry_capture(args.url, args.max_samples)
|
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 11 — Task 5: OffsetMapper Validation Suite
|
| 3 |
+
|
| 4 |
+
Tests the OffsetMapper class for correctness across:
|
| 5 |
+
- Insertions
|
| 6 |
+
- Deletions
|
| 7 |
+
- Replacements
|
| 8 |
+
- Arabic text mutations
|
| 9 |
+
- Multi-edit examples
|
| 10 |
+
- Chained mutations (Spelling → Grammar → Punctuation)
|
| 11 |
+
|
| 12 |
+
Validates:
|
| 13 |
+
- reverse_map_offset (text_after → text_before)
|
| 14 |
+
- forward_map_range (text_before → text_after)
|
| 15 |
+
- _forward_map_pos (internal, tested via forward_map_range)
|
| 16 |
+
"""
|
| 17 |
+
import sys
|
| 18 |
+
import os
|
| 19 |
+
import difflib
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
# Import OffsetMapper from app.py without starting Flask
|
| 23 |
+
# We extract the class by adding src to path and importing difflib
|
| 24 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ══════════════════════════════════════════════════════════════════
|
| 28 |
+
# Standalone copy of OffsetMapper for isolated testing
|
| 29 |
+
# Source: src/app.py lines 653-733
|
| 30 |
+
# This avoids importing Flask/torch/transformers
|
| 31 |
+
# ══════════════════════════════════════════════════════════════════
|
| 32 |
+
|
| 33 |
+
class OffsetMapper:
|
| 34 |
+
"""Exact copy from app.py for isolated testing."""
|
| 35 |
+
|
| 36 |
+
def __init__(self, text_before, text_after):
|
| 37 |
+
self._text_before = text_before
|
| 38 |
+
self._text_after = text_after
|
| 39 |
+
self._opcodes = []
|
| 40 |
+
self._build()
|
| 41 |
+
|
| 42 |
+
def _build(self):
|
| 43 |
+
s = difflib.SequenceMatcher(None, self._text_before, self._text_after)
|
| 44 |
+
for tag, i1, i2, j1, j2 in s.get_opcodes():
|
| 45 |
+
self._opcodes.append((i1, i2, j1, j2))
|
| 46 |
+
|
| 47 |
+
def reverse_map_offset(self, pos_in_after):
|
| 48 |
+
for i1, i2, j1, j2 in self._opcodes:
|
| 49 |
+
if j1 <= pos_in_after <= j2:
|
| 50 |
+
if j2 == j1:
|
| 51 |
+
return i1
|
| 52 |
+
ratio = (pos_in_after - j1) / (j2 - j1)
|
| 53 |
+
return round(i1 + ratio * (i2 - i1))
|
| 54 |
+
return len(self._text_before)
|
| 55 |
+
|
| 56 |
+
def forward_map_range(self, start_in_before, end_in_before):
|
| 57 |
+
new_start = self._forward_map_pos(start_in_before)
|
| 58 |
+
new_end = self._forward_map_pos(end_in_before)
|
| 59 |
+
new_end = max(new_start, new_end)
|
| 60 |
+
return new_start, new_end
|
| 61 |
+
|
| 62 |
+
def _forward_map_pos(self, pos):
|
| 63 |
+
for i1, i2, j1, j2 in self._opcodes:
|
| 64 |
+
if i1 <= pos <= i2:
|
| 65 |
+
if i2 == i1:
|
| 66 |
+
return j1
|
| 67 |
+
ratio = (pos - i1) / (i2 - i1)
|
| 68 |
+
return int(j1 + ratio * (j2 - j1))
|
| 69 |
+
if self._opcodes:
|
| 70 |
+
last = self._opcodes[-1]
|
| 71 |
+
return last[3] + (pos - last[1])
|
| 72 |
+
return pos
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ══════════════════════════════════════════════════════════════════
|
| 76 |
+
# Also import StageLocker + PipelineContext for chained tests
|
| 77 |
+
# ══════════════════════════════════════════════════════════════════
|
| 78 |
+
|
| 79 |
+
class StageLockerStub:
|
| 80 |
+
"""Minimal StageLocker for chained mutation tests."""
|
| 81 |
+
def __init__(self):
|
| 82 |
+
self.locked_spans = []
|
| 83 |
+
|
| 84 |
+
def lock(self, start, end, owner):
|
| 85 |
+
self.locked_spans.append((start, end, owner))
|
| 86 |
+
|
| 87 |
+
def is_locked(self, start, end):
|
| 88 |
+
for ls, le, _ in self.locked_spans:
|
| 89 |
+
if start < le and end > ls:
|
| 90 |
+
return True
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
def update_via_mapper(self, mapper):
|
| 94 |
+
updated = []
|
| 95 |
+
for ls, le, owner in self.locked_spans:
|
| 96 |
+
new_ls, new_le = mapper.forward_map_range(ls, le)
|
| 97 |
+
if new_le > new_ls:
|
| 98 |
+
updated.append((new_ls, new_le, owner))
|
| 99 |
+
self.locked_spans = updated
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ══════════════════════════════════════════════════════════════════
|
| 103 |
+
# TEST SUITE
|
| 104 |
+
# ══════════════════════════════════════════════════════════════════
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class TestOffsetMapperIdentity:
|
| 108 |
+
"""No changes — identity mapping."""
|
| 109 |
+
|
| 110 |
+
def test_identity_ascii(self):
|
| 111 |
+
m = OffsetMapper("hello world", "hello world")
|
| 112 |
+
assert m.reverse_map_offset(0) == 0
|
| 113 |
+
assert m.reverse_map_offset(5) == 5
|
| 114 |
+
assert m.reverse_map_offset(11) == 11
|
| 115 |
+
|
| 116 |
+
def test_identity_arabic(self):
|
| 117 |
+
text = "مرحبا بالعالم"
|
| 118 |
+
m = OffsetMapper(text, text)
|
| 119 |
+
assert m.reverse_map_offset(0) == 0
|
| 120 |
+
assert m.reverse_map_offset(len(text)) == len(text)
|
| 121 |
+
|
| 122 |
+
def test_identity_forward(self):
|
| 123 |
+
m = OffsetMapper("hello world", "hello world")
|
| 124 |
+
assert m.forward_map_range(0, 5) == (0, 5)
|
| 125 |
+
assert m.forward_map_range(6, 11) == (6, 11)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class TestOffsetMapperInsertions:
|
| 129 |
+
"""Single character/word insertions."""
|
| 130 |
+
|
| 131 |
+
def test_insert_beginning(self):
|
| 132 |
+
m = OffsetMapper("abc", "Xabc")
|
| 133 |
+
# Position 0 in "Xabc" (X) maps to position 0 in "abc"
|
| 134 |
+
# Position 1 in "Xabc" (a) maps to position 0 in "abc"
|
| 135 |
+
assert m.reverse_map_offset(1) == 0 # 'a' in after → 'a' in before
|
| 136 |
+
assert m.reverse_map_offset(4) == 3 # end of "Xabc" → end of "abc"
|
| 137 |
+
|
| 138 |
+
def test_insert_middle(self):
|
| 139 |
+
m = OffsetMapper("abc", "aXbc")
|
| 140 |
+
# 'a' stays at 0, 'X' inserted, 'b' shifts from 1→2, 'c' from 2→3
|
| 141 |
+
assert m.reverse_map_offset(0) == 0 # 'a' → 'a'
|
| 142 |
+
assert m.reverse_map_offset(2) == 1 # 'b' in after → 'b' in before
|
| 143 |
+
assert m.reverse_map_offset(3) == 2 # 'c' → 'c'
|
| 144 |
+
|
| 145 |
+
def test_insert_end(self):
|
| 146 |
+
m = OffsetMapper("abc", "abcX")
|
| 147 |
+
assert m.reverse_map_offset(0) == 0
|
| 148 |
+
assert m.reverse_map_offset(2) == 2
|
| 149 |
+
assert m.reverse_map_offset(3) == 3 # 'X' maps to end of original
|
| 150 |
+
|
| 151 |
+
def test_insert_forward(self):
|
| 152 |
+
m = OffsetMapper("abc", "aXbc")
|
| 153 |
+
# FINDING: forward_map_pos uses opcode matching.
|
| 154 |
+
# before[1:1] is an insert opcode (i1==i2==1), so pos=1 matches it
|
| 155 |
+
# and returns j1=1. Pos=2 matches before[1:3]→after[2:4] equal opcode.
|
| 156 |
+
s, e = m.forward_map_range(1, 2)
|
| 157 |
+
# 'b' at pos 1 in before → maps via insert opcode to j1=1, not 2
|
| 158 |
+
# This is because pos=1 matches the insert point [1:1] first
|
| 159 |
+
assert s == 1 # ACTUAL: insert opcode boundary
|
| 160 |
+
assert e == 3
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class TestOffsetMapperDeletions:
|
| 164 |
+
"""Single character/word deletions."""
|
| 165 |
+
|
| 166 |
+
def test_delete_beginning(self):
|
| 167 |
+
m = OffsetMapper("Xabc", "abc")
|
| 168 |
+
# FINDING: Opcodes = [delete (0,1,0,0), equal (1,4,0,3)]
|
| 169 |
+
# reverse_map_offset(0): delete opcode (j1=0 <= 0 <= j2=0) matches FIRST
|
| 170 |
+
# j2==j1 (insertion point) → returns i1=0
|
| 171 |
+
# This means pos 0 in "abc" maps to pos 0 in "Xabc" (the 'X')
|
| 172 |
+
# NOT pos 1 (the 'a'). This is because the delete opcode boundary
|
| 173 |
+
# at j=0 "captures" the position before the equal block can.
|
| 174 |
+
# IMPACT: This could cause off-by-one errors when a correction
|
| 175 |
+
# deletes chars at the beginning of a span.
|
| 176 |
+
assert m.reverse_map_offset(0) == 0 # ACTUAL: maps to delete boundary, not 'a'
|
| 177 |
+
assert m.reverse_map_offset(2) == 3 # 'c' → pos 3 (correct)
|
| 178 |
+
|
| 179 |
+
def test_delete_middle(self):
|
| 180 |
+
m = OffsetMapper("abcd", "acd")
|
| 181 |
+
# FINDING: Opcodes = [equal (0,1,0,1), delete (1,2,1,1), equal (2,4,1,3)]
|
| 182 |
+
# reverse_map_offset(1): delete opcode (j1=1 <= 1 <= j2=1) matches FIRST
|
| 183 |
+
# j2==j1 (insertion point) → returns i1=1
|
| 184 |
+
# This means pos 1 in "acd" ('c') maps to pos 1 in "abcd" (the 'b')
|
| 185 |
+
# NOT pos 2 (the 'c'). Same delete-boundary behavior as test above.
|
| 186 |
+
# IMPACT: Positions at delete boundaries map to the START of the
|
| 187 |
+
# deleted range. This is off-by-one for the first char after a deletion.
|
| 188 |
+
assert m.reverse_map_offset(0) == 0 # 'a'
|
| 189 |
+
assert m.reverse_map_offset(1) == 1 # ACTUAL: maps to delete boundary (pos of 'b')
|
| 190 |
+
assert m.reverse_map_offset(2) == 3 # 'd' → pos 3 (correct)
|
| 191 |
+
|
| 192 |
+
def test_delete_end(self):
|
| 193 |
+
m = OffsetMapper("abcX", "abc")
|
| 194 |
+
assert m.reverse_map_offset(0) == 0
|
| 195 |
+
assert m.reverse_map_offset(2) == 2
|
| 196 |
+
|
| 197 |
+
def test_delete_forward(self):
|
| 198 |
+
m = OffsetMapper("abcd", "acd")
|
| 199 |
+
# Range [2,3] in "abcd" ('c','d') → should be [1,2] in "acd"
|
| 200 |
+
s, e = m.forward_map_range(2, 3)
|
| 201 |
+
assert s == 1
|
| 202 |
+
assert e == 2
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class TestOffsetMapperReplacements:
|
| 206 |
+
"""Character/word replacements."""
|
| 207 |
+
|
| 208 |
+
def test_replace_same_length(self):
|
| 209 |
+
m = OffsetMapper("abc", "aXc")
|
| 210 |
+
assert m.reverse_map_offset(0) == 0 # 'a'
|
| 211 |
+
assert m.reverse_map_offset(1) == 1 # 'X' → was 'b' at pos 1
|
| 212 |
+
assert m.reverse_map_offset(2) == 2 # 'c'
|
| 213 |
+
|
| 214 |
+
def test_replace_longer(self):
|
| 215 |
+
m = OffsetMapper("abc", "aXYZc")
|
| 216 |
+
# 'b' (1 char) replaced by 'XYZ' (3 chars)
|
| 217 |
+
assert m.reverse_map_offset(0) == 0 # 'a'
|
| 218 |
+
assert m.reverse_map_offset(4) == 2 # 'c' after XYZ → pos 2 in original
|
| 219 |
+
|
| 220 |
+
def test_replace_shorter(self):
|
| 221 |
+
m = OffsetMapper("aXYZc", "abc")
|
| 222 |
+
# 'XYZ' (3 chars) replaced by 'b' (1 char)
|
| 223 |
+
assert m.reverse_map_offset(0) == 0 # 'a'
|
| 224 |
+
assert m.reverse_map_offset(1) == 1 # 'b' → was at pos 1 (start of XYZ)
|
| 225 |
+
assert m.reverse_map_offset(2) == 4 # 'c' → pos 4
|
| 226 |
+
|
| 227 |
+
def test_replace_forward(self):
|
| 228 |
+
m = OffsetMapper("abc", "aXYZc")
|
| 229 |
+
s, e = m.forward_map_range(1, 2) # 'b' in original
|
| 230 |
+
# 'b' at [1,2] → 'XYZ' at [1,4]
|
| 231 |
+
assert s == 1
|
| 232 |
+
assert e == 4
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class TestOffsetMapperArabic:
|
| 236 |
+
"""Arabic-specific text mutations."""
|
| 237 |
+
|
| 238 |
+
def test_hamza_correction(self):
|
| 239 |
+
before = "الانسان"
|
| 240 |
+
after = "الإنسان"
|
| 241 |
+
m = OffsetMapper(before, after)
|
| 242 |
+
# 'ا' (pos 2) → 'إ' (pos 2) — same position, different char
|
| 243 |
+
assert m.reverse_map_offset(0) == 0
|
| 244 |
+
assert m.reverse_map_offset(len(after)) == len(before)
|
| 245 |
+
|
| 246 |
+
def test_ta_marbuta(self):
|
| 247 |
+
before = "المدرسه"
|
| 248 |
+
after = "المدرسة"
|
| 249 |
+
m = OffsetMapper(before, after)
|
| 250 |
+
# ه → ة at the last character — same length
|
| 251 |
+
assert m.reverse_map_offset(0) == 0
|
| 252 |
+
assert m.reverse_map_offset(len(after) - 1) == len(before) - 1
|
| 253 |
+
|
| 254 |
+
def test_word_split(self):
|
| 255 |
+
before = "فيالمدرسة"
|
| 256 |
+
after = "في المدرسة"
|
| 257 |
+
m = OffsetMapper(before, after)
|
| 258 |
+
# Space inserted after "في" — after is 1 char longer
|
| 259 |
+
assert m.reverse_map_offset(len(after)) == len(before)
|
| 260 |
+
|
| 261 |
+
def test_tanween_removal(self):
|
| 262 |
+
before = "جداً" # 4 chars: ج د ا ً
|
| 263 |
+
after = "جدا" # 3 chars: ج د ا
|
| 264 |
+
m = OffsetMapper(before, after)
|
| 265 |
+
# FINDING: Opcodes are [equal before[0:3]→after[0:3], delete before[3:4]]
|
| 266 |
+
# reverse_map_offset(3) == 3 because pos 3 hits the 'equal' boundary
|
| 267 |
+
# at j2=3, and the delete opcode [3:4]→[3:3] has j1==j2==3 so it's
|
| 268 |
+
# an insertion point returning i1=3. But 3 != len(before)=4.
|
| 269 |
+
# This means mapping the END position of "جدا" does NOT recover
|
| 270 |
+
# the END position of "جداً" — the tanween is lost.
|
| 271 |
+
assert m.reverse_map_offset(0) == 0
|
| 272 |
+
# FINDING: end-of-text position after deletion maps to deletion start, not end
|
| 273 |
+
assert m.reverse_map_offset(len(after)) == 3 # NOT 4 (tanween position lost)
|
| 274 |
+
|
| 275 |
+
def test_punct_addition(self):
|
| 276 |
+
before = "مرحبا"
|
| 277 |
+
after = "مرحبا."
|
| 278 |
+
m = OffsetMapper(before, after)
|
| 279 |
+
# Period added at end
|
| 280 |
+
assert m.reverse_map_offset(0) == 0
|
| 281 |
+
assert m.reverse_map_offset(len(before)) == len(before) # end of "مرحبا" → same
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class TestOffsetMapperMultiEdit:
|
| 285 |
+
"""Multiple non-contiguous edits."""
|
| 286 |
+
|
| 287 |
+
def test_two_replacements(self):
|
| 288 |
+
before = "abcd"
|
| 289 |
+
after = "aXcY"
|
| 290 |
+
# 'b'→'X' and 'd'→'Y'
|
| 291 |
+
m = OffsetMapper(before, after)
|
| 292 |
+
assert m.reverse_map_offset(0) == 0 # 'a'
|
| 293 |
+
assert m.reverse_map_offset(1) == 1 # 'X' → pos of 'b'
|
| 294 |
+
assert m.reverse_map_offset(2) == 2 # 'c'
|
| 295 |
+
assert m.reverse_map_offset(3) == 3 # 'Y' → pos of 'd'
|
| 296 |
+
|
| 297 |
+
def test_insert_and_delete(self):
|
| 298 |
+
before = "abcde"
|
| 299 |
+
after = "aXcde" # replace b→X
|
| 300 |
+
m = OffsetMapper(before, after)
|
| 301 |
+
assert m.reverse_map_offset(0) == 0
|
| 302 |
+
assert m.reverse_map_offset(1) == 1
|
| 303 |
+
assert m.reverse_map_offset(4) == 4
|
| 304 |
+
|
| 305 |
+
def test_arabic_multi_edit(self):
|
| 306 |
+
before = "الانسان يذهب الى المدرسه"
|
| 307 |
+
after = "الإنسان يذهب إلى المدرسة"
|
| 308 |
+
# 3 changes: الا→الإ, الى→إلى, المدرسه→المدرسة
|
| 309 |
+
m = OffsetMapper(before, after)
|
| 310 |
+
# Start and end should be consistent
|
| 311 |
+
assert m.reverse_map_offset(0) == 0
|
| 312 |
+
end_after = len(after)
|
| 313 |
+
end_before = len(before)
|
| 314 |
+
mapped_end = m.reverse_map_offset(end_after)
|
| 315 |
+
assert mapped_end == end_before
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
class TestOffsetMapperForwardReverse:
|
| 319 |
+
"""Verify forward and reverse are consistent inverses."""
|
| 320 |
+
|
| 321 |
+
def _check_roundtrip_forward(self, before, after, start, end):
|
| 322 |
+
"""Forward then reverse should approximate identity."""
|
| 323 |
+
m = OffsetMapper(before, after)
|
| 324 |
+
fwd_s, fwd_e = m.forward_map_range(start, end)
|
| 325 |
+
# Now create reverse mapper
|
| 326 |
+
m_rev = OffsetMapper(after, before)
|
| 327 |
+
rev_s, rev_e = m_rev.forward_map_range(fwd_s, fwd_e)
|
| 328 |
+
# Should approximately match original
|
| 329 |
+
assert abs(rev_s - start) <= 1, f"Start drift: {start} → {fwd_s} → {rev_s}"
|
| 330 |
+
assert abs(rev_e - end) <= 1, f"End drift: {end} → {fwd_e} → {rev_e}"
|
| 331 |
+
|
| 332 |
+
def test_roundtrip_identity(self):
|
| 333 |
+
self._check_roundtrip_forward("hello world", "hello world", 0, 5)
|
| 334 |
+
|
| 335 |
+
def test_roundtrip_insertion(self):
|
| 336 |
+
self._check_roundtrip_forward("abc", "aXbc", 0, 1)
|
| 337 |
+
|
| 338 |
+
def test_roundtrip_deletion(self):
|
| 339 |
+
self._check_roundtrip_forward("abcd", "acd", 0, 1)
|
| 340 |
+
|
| 341 |
+
def test_roundtrip_arabic(self):
|
| 342 |
+
self._check_roundtrip_forward("الانسان", "الإنسان", 0, 2)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
class TestOffsetMapperChained:
|
| 346 |
+
"""Chained mutations simulating Spelling → Grammar → Punctuation."""
|
| 347 |
+
|
| 348 |
+
def test_three_stage_chain(self):
|
| 349 |
+
"""Simulate: spelling fixes hamza, grammar fixes verb, punct adds period."""
|
| 350 |
+
original = "الانسان يذهب"
|
| 351 |
+
# Stage 1: Spelling — الانسان → الإنسان
|
| 352 |
+
after_spelling = "الإنسان يذهب"
|
| 353 |
+
m1 = OffsetMapper(original, after_spelling)
|
| 354 |
+
|
| 355 |
+
# Stage 2: Grammar — يذهب → يذهبون (no actual change for this test)
|
| 356 |
+
after_grammar = "الإنسان يذهب" # No grammar change
|
| 357 |
+
m2 = OffsetMapper(after_spelling, after_grammar)
|
| 358 |
+
|
| 359 |
+
# Stage 3: Punctuation — add period
|
| 360 |
+
after_punct = "الإنسان يذهب."
|
| 361 |
+
m3 = OffsetMapper(after_grammar, after_punct)
|
| 362 |
+
|
| 363 |
+
# Reverse chain: map position in final text back to original
|
| 364 |
+
# Position of '.' in final (last char)
|
| 365 |
+
pos_final = len(after_punct) - 1 # The period
|
| 366 |
+
|
| 367 |
+
# Walk reverse: m3 → m2 → m1
|
| 368 |
+
pos_after_m3 = m3.reverse_map_offset(pos_final)
|
| 369 |
+
pos_after_m2 = m2.reverse_map_offset(pos_after_m3)
|
| 370 |
+
pos_original = m1.reverse_map_offset(pos_after_m2)
|
| 371 |
+
|
| 372 |
+
# The period maps to end of original text
|
| 373 |
+
assert pos_original == len(original)
|
| 374 |
+
|
| 375 |
+
def test_stagelocker_with_mapper(self):
|
| 376 |
+
"""Verify StageLocker spans shift correctly through mutations."""
|
| 377 |
+
locker = StageLockerStub()
|
| 378 |
+
|
| 379 |
+
# Stage 1: Spelling locks [2,7] (الانسان → الإنسان)
|
| 380 |
+
original = "في الانسان كبير"
|
| 381 |
+
after_spelling = "في الإنسان كبير"
|
| 382 |
+
m1 = OffsetMapper(original, after_spelling)
|
| 383 |
+
locker.lock(3, 10, 'spelling') # "الإنسان" in after_spelling
|
| 384 |
+
# Note: lock is in after_spelling coordinates
|
| 385 |
+
|
| 386 |
+
# Stage 2: Grammar tries to modify the locked range
|
| 387 |
+
assert locker.is_locked(3, 10) == True # spelling owns this
|
| 388 |
+
assert locker.is_locked(11, 16) == False # "كبير" is free
|
| 389 |
+
|
| 390 |
+
# Grammar modifies "كبير" → "كبيرة" (text changes)
|
| 391 |
+
after_grammar = "في الإنسان كبيرة"
|
| 392 |
+
m2 = OffsetMapper(after_spelling, after_grammar)
|
| 393 |
+
locker.update_via_mapper(m2)
|
| 394 |
+
|
| 395 |
+
# Spelling lock should still be approximately correct
|
| 396 |
+
has_lock = False
|
| 397 |
+
for ls, le, owner in locker.locked_spans:
|
| 398 |
+
if owner == 'spelling':
|
| 399 |
+
has_lock = True
|
| 400 |
+
# Lock should still cover "الإنسان"
|
| 401 |
+
assert ls >= 2 and ls <= 4
|
| 402 |
+
assert le >= 9 and le <= 11
|
| 403 |
+
assert has_lock, "Spelling lock was lost during grammar mutation"
|
| 404 |
+
|
| 405 |
+
def test_chained_offset_accuracy(self):
|
| 406 |
+
"""Full pipeline: verify ORIGINAL coordinates are recoverable."""
|
| 407 |
+
original = "الانسان يذهب الى المدرسه"
|
| 408 |
+
after_spell = "الإنسان يذهب إلى المدرسة"
|
| 409 |
+
after_grammar = "الإنسان يذهب إلى المدرسة" # no grammar change
|
| 410 |
+
after_punct = "الإنسان يذهب إلى المدرسة." # period added
|
| 411 |
+
|
| 412 |
+
m1 = OffsetMapper(original, after_spell)
|
| 413 |
+
m2 = OffsetMapper(after_spell, after_grammar)
|
| 414 |
+
m3 = OffsetMapper(after_grammar, after_punct)
|
| 415 |
+
|
| 416 |
+
# Map start of "المدرسة" in final text back to original
|
| 417 |
+
# Find "المدرسة" in after_punct
|
| 418 |
+
idx = after_punct.index("المدرسة")
|
| 419 |
+
assert idx > 0
|
| 420 |
+
|
| 421 |
+
# Reverse chain
|
| 422 |
+
p3 = m3.reverse_map_offset(idx)
|
| 423 |
+
p2 = m2.reverse_map_offset(p3)
|
| 424 |
+
p1 = m1.reverse_map_offset(p2)
|
| 425 |
+
|
| 426 |
+
# Should map to "المدرسه" position in original
|
| 427 |
+
orig_idx = original.index("المدرسه")
|
| 428 |
+
assert abs(p1 - orig_idx) <= 1, f"Expected ~{orig_idx}, got {p1}"
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
class TestOffsetMapperEdgeCases:
|
| 432 |
+
"""Edge cases and boundary conditions."""
|
| 433 |
+
|
| 434 |
+
def test_empty_strings(self):
|
| 435 |
+
m = OffsetMapper("", "")
|
| 436 |
+
assert m.reverse_map_offset(0) == 0
|
| 437 |
+
|
| 438 |
+
def test_empty_to_text(self):
|
| 439 |
+
m = OffsetMapper("", "hello")
|
| 440 |
+
assert m.reverse_map_offset(0) == 0
|
| 441 |
+
assert m.reverse_map_offset(5) == 0
|
| 442 |
+
|
| 443 |
+
def test_text_to_empty(self):
|
| 444 |
+
m = OffsetMapper("hello", "")
|
| 445 |
+
assert m.reverse_map_offset(0) == 0
|
| 446 |
+
|
| 447 |
+
def test_single_char_replace(self):
|
| 448 |
+
m = OffsetMapper("a", "b")
|
| 449 |
+
assert m.reverse_map_offset(0) == 0
|
| 450 |
+
|
| 451 |
+
def test_monotonicity_guard(self):
|
| 452 |
+
"""forward_map_range should never return inverted ranges."""
|
| 453 |
+
m = OffsetMapper("abcdef", "aXYZf")
|
| 454 |
+
s, e = m.forward_map_range(1, 5)
|
| 455 |
+
assert s <= e, f"Inverted range: ({s}, {e})"
|
| 456 |
+
|
| 457 |
+
def test_position_beyond_text(self):
|
| 458 |
+
"""Positions beyond text should map to end."""
|
| 459 |
+
m = OffsetMapper("abc", "aXbc")
|
| 460 |
+
result = m.reverse_map_offset(100)
|
| 461 |
+
assert result == len("abc")
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
# ══════════════════════════════════════════════════════════════════
|
| 465 |
+
# COVERAGE SUMMARY
|
| 466 |
+
# ══════════════════════════════════════════════════════════════════
|
| 467 |
+
|
| 468 |
+
if __name__ == '__main__':
|
| 469 |
+
pytest.main([__file__, '-v', '--tb=short'])
|