CoolFace
Apppublic

TuwaiqAcademy/AISA-ArabicFC-Shared-Task

sourceHugging Faceapache-2.0updated 20d agoView on Hugging Face
13likes
normalize.py554 linesDownload Raw Back to eval
1"""2AISA-ArabicFC — argument normalization & fair matching for ArgEM.3=================================================================4Applied IDENTICALLY to predictions and gold, deterministic, and published so5participants know the rules. It only treats different *writings of the same6answer* as equal — never two different answers.7 8Layers9  0. Numbers      Arabic-Indic/Persian digits -> ASCII; whole-number int==float10                  (quantity fields only; identifier fields stay strict).11  1. Orthography  strip Arabic diacritics + tatweel; unify alef أإآٱ->ا,12                  alef-maqsura ى->ي, ta-marbuta ة->ه, hamza seats.13  2. Number words Arabic cardinals (واحد..ألف) -> digits.14  3. List fields  split on ، , ; & / و and ⏎ -> compare as an unordered SET.15  4. Aliases      closed classes: countries, cities, currencies, languages,16                  zakat-type, quran search-type  (bilingual / ISO).17 18`termination_type`: the dominant, unambiguous values are aliased bilingually19(استقالة=resignation, فصل=dismissal, …) per organizer approval; the rare free-text20long tail is left to orthographic matching. The Arabic definite article «ال» is21normalised away token-wise (البروفين == بروفين) identically on both sides.22"""23from __future__ import annotations24import re25import unicodedata26 27# ────────────────────────── Layer 0: digits / numbers ──────────────────────28_DIGITS = {**{ord("٠") + i: str(i) for i in range(10)},29           **{ord("۰") + i: str(i) for i in range(10)}}30 31def to_ascii_digits(s: str) -> str:32    return s.translate(_DIGITS)33 34# Identifier-like fields: keep strict (leading zeros & exact form matter).35ID_FIELDS = {36    "id_number", "iqama_number", "visa_number", "recipient_iban", "iban",37    "insurance_number", "passport_number", "phone", "phone_number",38    "national_id", "account_number", "reference_number",39}40 41def _as_number(v):42    try:43        return float(to_ascii_digits(str(v)).replace(",", "").strip())44    except (ValueError, TypeError):45        return None46 47# ────────────────────────── Layer 1: Arabic orthography ─────────────────────48_DIAC = re.compile(r"[ؐ-ًؚ-ٰٟۖ-ۭـ]")49 50def arabic_ortho(s: str) -> str:51    s = unicodedata.normalize("NFKC", s)52    s = _DIAC.sub("", s)               # harakat, tanwin, shadda, sukun, dagger-alef, tatweel53    s = re.sub("[إأآٱ]", "ا", s)       # alef variants54    s = s.replace("ى", "ي").replace("ة", "ه")55    s = s.replace("ؤ", "و").replace("ئ", "ي").replace("ء", "")56    return s57 58# ────────────────────────── Layer 2: number words ──────────────────────────59# keys already in orthographically-normalised form (no ة, bare alef, …)60_NUMWORDS = {61    "صفر": "0",62    "واحد": "1", "واحده": "1", "احد": "1", "احدي": "1",63    "اثنين": "2", "اثنان": "2", "اثنتين": "2", "اثنتان": "2",64    "ثلاث": "3", "ثلاثه": "3",65    "اربع": "4", "اربعه": "4",66    "خمس": "5", "خمسه": "5",67    "ست": "6", "سته": "6",68    "سبع": "7", "سبعه": "7",69    "ثمان": "8", "ثمانيه": "8", "ثماني": "8",70    "تسع": "9", "تسعه": "9",71    "عشر": "10", "عشره": "10",72    "عشرين": "20", "ثلاثين": "30", "اربعين": "40", "خمسين": "50",73    "ستين": "60", "سبعين": "70", "ثمانين": "80", "تسعين": "90",74    "مايه": "100", "مئه": "100", "ميه": "100", "الف": "1000",75}76 77def words_to_digits(s: str) -> str:78    return " ".join(_NUMWORDS.get(t, t) for t in s.split())79 80def _strip_al(s: str) -> str:81    """Token-wise strip of the Arabic definite article «ال» so البروفين == بروفين,82    الإسكندرية == اسكندرية. Applied identically to prediction and gold; short tokens83    (≤3 chars) are left intact to avoid mangling words where «ال» isn't an article."""84    return " ".join(t[2:] if t.startswith("ال") and len(t) > 3 else t for t in s.split())85 86# ────────────────────────── Layer 4: closed-class aliases ───────────────────87# canonical -> list of surface forms (Arabic + English/ISO). Normalised on load.88_COUNTRIES = {89    "saudi": ["السعودية", "المملكة العربية السعودية", "المملكة", "Saudi Arabia", "KSA", "Saudi"],90    "uae": ["الإمارات", "الامارات", "الإمارات العربية المتحدة", "UAE", "United Arab Emirates", "Emirates"],91    "egypt": ["مصر", "Egypt"],92    "jordan": ["الأردن", "Jordan"],93    "kuwait": ["الكويت", "Kuwait"],94    "qatar": ["قطر", "Qatar"],95    "bahrain": ["البحرين", "Bahrain"],96    "oman": ["عمان", "سلطنة عمان", "Oman"],          # country sense (city Amman handled in _CITIES)97    "yemen": ["اليمن", "Yemen"],98    "syria": ["سوريا", "سورية", "الشام", "Syria"],99    "lebanon": ["لبنان", "Lebanon"],100    "iraq": ["العراق", "Iraq"],101    "palestine": ["فلسطين", "Palestine"],102    "tunisia": ["تونس", "Tunisia"],103    "algeria": ["الجزائر", "Algeria"],104    "morocco": ["المغرب", "Morocco"],105    "libya": ["ليبيا", "Libya"],106    "sudan": ["السودان", "Sudan"],107    "china": ["الصين", "China"],108    "usa": ["أمريكا", "امريكا", "الولايات المتحدة", "USA", "US", "United States", "America"],109    "uk": ["بريطانيا", "المملكة المتحدة", "UK", "United Kingdom", "Britain"],110    "germany": ["ألمانيا", "Germany"],111    "france": ["فرنسا", "France"],112    "turkey": ["تركيا", "Turkey", "Türkiye"],113    "spain": ["إسبانيا", "اسبانيا", "Spain"],114    "italy": ["إيطاليا", "ايطاليا", "Italy"],115    "canada": ["كندا", "Canada"],116    "india": ["الهند", "India"],117    "japan": ["اليابان", "Japan"],118    "switzerland": ["سويسرا", "Switzerland"],119    "russia": ["روسيا", "Russia"],120}121_CITIES = {122    "amman": ["عمّان", "عمان", "Amman"],             # city sense123    "dubai": ["دبي", "Dubai"],124    "beirut": ["بيروت", "Beirut"],125    "damascus": ["دمشق", "Damascus"],126    "cairo": ["القاهرة", "Cairo"],127    "ankara": ["أنقرة", "Ankara"],128    "jerusalem": ["القدس", "Jerusalem"],129    "irbid": ["إربد", "Irbid"],130    "homs": ["حمص", "Homs"],131    "tripoli": ["طرابلس", "Tripoli"],132    "riyadh": ["الرياض", "Riyadh"],133    "london": ["لندن", "London"],134    "ceuta": ["سبتة", "Ceuta"],135    "mecca": ["مكة", "مكة المكرمة", "Mecca", "Makkah"],136    "medina": ["المدينة", "المدينة المنورة", "Medina", "Madinah"],137    "jeddah": ["جدة", "Jeddah"],138}139# currency: ISO canonical. Only ISO + *qualified* Arabic forms — bare ambiguous140# words (ريال / دينار / درهم / ليرة / جنيه) are deliberately NOT aliased.141_CURRENCIES = {142    "sar": ["SAR", "ريال سعودي", "سعودي ريال", "ريال"],143    "aed": ["AED", "درهم إماراتي", "درهم"],144    "egp": ["EGP", "جنيه مصري", "الجنيه المصري", "جنيه"],145    "kwd": ["KWD", "دينار كويتي"],146    "bhd": ["BHD", "دينار بحريني"],147    "qar": ["QAR", "ريال قطري"],148    "omr": ["OMR", "ريال عماني"],149    "yer": ["YER", "ريال يمني"],150    "usd": ["USD", "دولار أمريكي", "دولار امريكي", "دولار"],151    "eur": ["EUR", "يورو", "أورو"],152    "gbp": ["GBP", "جنيه استرليني"],153    "syp": ["SYP", "ليرة سورية", "ليرة سوري", "الليرة السورية"],154    "lbp": ["LBP", "ليرة لبنانية"],155    "mad": ["MAD", "درهم مغربي"],156    "tnd": ["TND", "دينار تونسي"],157    "jod": ["JOD", "دينار أردني"],158    "dzd": ["DZD", "دينار جزائري"],159    "lyd": ["LYD", "دينار ليبي"],160    "cad": ["CAD", "دولار كندي"],161    "jpy": ["JPY"], "try": ["TRY", "ليرة تركية"], "ils": ["ILS"],162    "iqd": ["IQD"], "inr": ["INR"], "pkr": ["PKR"],163    "gram": ["g", "gram", "grams", "جرام", "غرام", "جم"],164    "kg": ["kg", "كيلو", "كجم", "كيلوجرام", "كيلوغرام"],165}166_LANGUAGES = {167    "en": ["en", "english", "الإنجليزية", "الانجليزية", "انجليزية", "انجليزي", "الانجليزي"],168    "ar": ["ar", "arabic", "العربية", "العربي", "عربي"],169    "fr": ["fr", "french", "الفرنسية", "الفرنسي", "فرنسية", "فرنسي"],170    "es": ["es", "spanish", "الإسبانية", "الاسبانية", "اسباني"],171    "de": ["de", "german", "الألمانية", "الالمانية", "الماني"],172    "it": ["it", "italian", "الإيطالية", "الايطالية"],173    "tr": ["tr", "turkish", "التركية"],174    "zh": ["zh", "chinese", "الصينية"],175    "ja": ["ja", "japanese", "اليابانية"],176    "ko": ["ko", "korean", "الكورية"],177    "fa": ["fa", "persian", "الفارسية"],178    "ur": ["ur", "urdu", "الأردية"],179    "hi": ["hi", "hindi", "الهندية"],180    "ru": ["ru", "russian", "الروسية"],181    "pt": ["pt", "portuguese", "البرتغالية"],182}183# zakat asset type — clean enough to alias184_ZTYPE = {185    "gold": ["gold", "ذهب", "الذهب"],186    "silver": ["silver", "فضة", "الفضة"],187    "cash": ["cash", "money", "maal", "cash_in_bank", "نقد", "النقد", "نقود", "النقود",188             "مال", "المال", "أموال", "الأموال", "أموال نقدية", "المال النقدي", "مدخرات", "المدخرات"],189    "trade": ["trade", "goods", "عروض تجارية", "تجارة", "التجارة", "بضائع"],190    "crops": ["crops", "زرع", "زروع", "الزروع", "محاصيل", "المحاصيل الزراعية"],191    "livestock": ["livestock", "أغنام", "ماشية", "الماشية", "أنعام"],192    "salary": ["salary", "income", "راتب", "دخل"],193    "shares": ["shares", "stocks", "أسهم"],194    "realestate": ["عقارات", "land", "أرض"],195    "fitr": ["زكاة الفطر", "فطر", "الفطر", "fitr"],196}197# quran search type — clean enough to alias198_SEARCHTYPE = {199    "verse": ["verse", "آية", "آيات", "اية", "ايات"],200    "surah": ["surah", "سورة", "سوره"],201    "tafseer": ["tafseer", "interpretation", "تفسير"],202    "meaning": ["meaning", "معنى", "تقصي"],203    "word": ["word", "كلمة", "كلمه"],204    "topic": ["topic", "موضوع"],205    "exact": ["exact", "بداية", "start", "starts_with", "beginning"],206    "any": ["any"],207}208 209# termination_type — end-of-service reason. The long tail (إنهاء بعذر مشروع,210# فصل تأديبي…) is genuinely free-text and left to orthographic matching, but the211# dominant, unambiguous values are aliased bilingually (organizer-approved) so a212# model answering in English isn't penalised: استقالة=resignation, فصل=dismissal.213# Coarsened to the legally-meaningful buckets (they change the end-of-service214# amount): the fine-grained Arabic reasons and the English enums teams emit are215# folded into resignation / dismissal / layoff. The genuinely ambiguous one-offs216# (إنهاء بعذر مشروع, إنهاء عادي/غير عادي, إنهاء طارئ, end_of_contract) are left217# unmapped rather than force a subjective bucket.218_TERMTYPE = {219    "resignation": ["استقالة", "استقاله", "استقالة طوعية", "إنهاء طوعي", "انهاء طوعي",220                    "تسريح طوعي", "resignation", "resign", "resigned", "voluntary",221                    "voluntary resignation", "voluntary termination", "quit"],222    "dismissal": ["فصل", "فصل تأديبي", "فصل تاديبي", "فصل بدون سبب", "فصل بدون سبب مشروع",223                  "فصل غير مشروع", "فصل بدون مبرر", "إقالة", "اقاله", "إنهاء غير مشروع",224                  "إنهاء غير طوعي", "dismissal", "dismissed", "fired", "terminated",225                  "termination", "termination for cause", "unfair_dismissal", "unfair dismissal",226                  "wrongful dismissal", "wrongful_dismissal", "disciplinary", "involuntary",227                  "involuntary termination"],228    "layoff": ["تسريح", "تسريح عمالة", "إنهاء بسبب ظروف الشركة", "إنهاء بسبب إعادة هيكلة الشركة",229               "إنهاء بطلب من الشركة", "layoff", "laid off", "redundancy", "let go",230               "economic", "restructuring", "reorganization"],231}232 233def _build(*tables):234    out = {}235    for table in tables:236        for canon, forms in table.items():237            for f in forms:238                k = arabic_ortho(to_ascii_digits(f)).strip().casefold()239                out[k] = canon240                ks = _strip_al(k)             # definite-article-insensitive key241                if ks != k:242                    out.setdefault(ks, canon)243    return out244 245_ALIAS = {246    "country": _build(_COUNTRIES, _CITIES),247    "city": _build(_CITIES, _COUNTRIES),248    "currency": _build(_CURRENCIES),249    "language": _build(_LANGUAGES),250    "ztype": _build(_ZTYPE),251    "searchtype": _build(_SEARCHTYPE),252    "termtype": _build(_TERMTYPE),253}254FIELD_CLASS = {255    "country": "country", "destination_country": "country", "departure_country": "country",256    "nationality": "country",257    "city": "city", "departure_city": "city", "destination_city": "city", "arrival_city": "city",258    "currency": "currency", "from_currency": "currency", "to_currency": "currency",259    "target_language": "language", "source_language": "language", "language": "language",260    "type": "ztype",261    "search_type": "searchtype",262    "termination_type": "termtype",263}264 265LIST_FIELDS = {"items", "country", "destination_country"}266# quantity fields where 5000 == 5000.0 (everything castable, except ID_FIELDS)267_SEP = re.compile(r"\s*(،|,|;|&|/|\bو\b|\band\b|\n)\s*")268_WAW_ATTACHED = re.compile(r"\sو(?=[ء-ي])")   # "مصر والإمارات" / "لبنان وسوريا" -> split (list fields only)269 270# ── Open / semi-closed free-text fields ─────────────────────────────────────271# product_name: strip generic device/carrier words + the definite article, map272# common brand transliterations to a canonical latin token, keep model numbers273# (so iPhone 13 != iPhone 14). Unifies آيفون / الآيفون / هاتف آيفون / iPhone /274# آيفون ١٣ etc. without merging distinct products.275PRODUCT_FIELDS = {"product_name"}276_CARRIER = {arabic_ortho(w) for w in277            ["هاتف", "هواتف", "موبايل", "موبايلات", "جوال", "جوالات",278             "تليفون", "تلفون", "تيليفون", "جهاز", "اجهزة", "محمول"]}279_PROD_BRAND = {arabic_ortho(k): v for k, v in {280    "ايفون": "iphone", "ابل": "apple", "ايباد": "ipad", "جالكسي": "galaxy",281    "سامسونج": "samsung", "سامسونغ": "samsung", "هواوي": "huawei", "شاومي": "xiaomi",282    "ريدمي": "redmi", "ديل": "dell", "لينوفو": "lenovo", "اسوس": "asus", "ايسر": "acer",283    "سوني": "sony", "نوكيا": "nokia", "شارب": "sharp", "بلايستيشن": "playstation",284    "اكسبوكس": "xbox", "نينتندو": "nintendo", "ابو": "oppo",285}.items()}286 287def _norm_product(s: str) -> str:288    out = []289    for t in s.split():290        stripped = t[2:] if t.startswith("ال") and len(t) > 4 else t291        if t in _CARRIER or stripped in _CARRIER:292            continue293        out.append(_PROD_BRAND.get(stripped, t))294    return " ".join(out).strip() or s295 296# specialty: drop honorific/qualifier prefixes (طبيب/دكتور/طب/أمراض…) + article,297# then map the medical-specialty core (Arabic + English) to a canonical token.298SPECIALTY_FIELDS = {"specialty"}299_SPEC_PREFIX = {arabic_ortho(w) for w in300                ["طبيب", "دكتور", "اخصائي", "اختصاصي", "استشاري", "طب", "امراض", "قسم", "عيادة"]}301_SPEC_ALIAS = {arabic_ortho(k): v for k, v in {302    "اطفال": "pediatrics", "قلب": "cardiology", "عيون": "ophthalmology",303    "اسنان": "dentistry", "جلدية": "dermatology", "جلد": "dermatology",304    "انف واذن وحنجرة": "ent", "انف واذن": "ent", "عظام": "orthopedics",305    "اعصاب": "neurology", "مخ واعصاب": "neurology", "باطنية": "internal", "باطنة": "internal",306    "نساء": "gynecology", "نسائية": "gynecology", "نساء وتوليد": "gynecology",307    "نساء وولادة": "gynecology", "نساء ولادة": "gynecology", "نفسي": "psychiatry",308    "نفسية": "psychiatry", "عام": "general", "اورام": "oncology",309    "جهاز هضمي": "gastroenterology", "جهاز تنفسي": "pulmonology", "غدد صماء": "endocrinology",310}.items()}311_SPEC_ALIAS.update({  # English forms312    "pediatrician": "pediatrics", "pediatrics": "pediatrics", "cardiology": "cardiology",313    "cardiologist": "cardiology", "dermatology": "dermatology", "dermatologist": "dermatology",314    "dentistry": "dentistry", "dentist": "dentistry", "ophthalmology": "ophthalmology",315    "neurologist": "neurology", "gynecologist": "gynecology", "obstetrician-gynecologist": "gynecology",316    "general practitioner": "general",317})318 319def _norm_specialty(s: str) -> str:320    toks = [t[2:] if t.startswith("ال") and len(t) > 3 else t for t in s.split()]321    toks = [t for t in toks if t not in _SPEC_PREFIX]322    core = " ".join(toks).strip()323    return _SPEC_ALIAS.get(core, core or s)324 325# category: map the dense, high-frequency core (Arabic spellings + English) to a326# canonical token. Genuinely distinct concepts stay distinct (watch != smartwatch,327# phone != smartphone); the long tail of one-offs is left unmapped (no merge).328CATEGORY_FIELDS = {"category"}329_CATEGORY = {330    "laptop": ["لابتوب", "لاب توب", "اللاب توب", "كمبيوتر محمول", "حاسوب محمول",331               "كومبيوتر محمول", "جهاز لابتوب", "ابتوب", "laptop"],332    "computer": ["كمبيوتر", "كومبيوتر", "حاسوب", "كمبيوتر مكتبي", "جهاز كمبيوتر", "computer"],333    "electronics": ["الكترونيات", "اجهزة الكترونية", "جهاز الكتروني", "اجهزة الكترونيه",334                    "electronics", "electronic device", "device"],335    "clothes": ["ملابس", "لبس", "لبسة", "حوايج", "clothes"],336    "fashion": ["ازياء", "موضة", "fashion"],337    "camera": ["كاميرا", "كاميرات", "الات التصوير", "اجهزة تصوير", "camera"],338    "watch": ["ساعة", "ساعة يد", "ساعات", "watch", "watches"],339    "smartwatch": ["ساعة ذكية", "smartwatch"],340    "phone": ["موبايل", "جوال", "جوالات", "هاتف", "هواتف", "هاتف محمول", "phone", "mobile phone"],341    "smartphone": ["هاتف ذكي", "smartphone"],342    "tablet": ["تابلت", "جهاز لوحي", "tablet"],343    "tv": ["تلفزيون", "تليفزيون", "تلفاز", "tv"],344    "gaming": ["جهاز العاب", "بلايستيشن", "كونسول", "playstation", "xbox"],345    "bag": ["حقيبة", "حقيبة يد", "شنطة", "bag"],346    "jewelry": ["مجوهرات", "jewelry"],347    "accessories": ["اكسسوارات", "اكسسوار"],348}349CAT_ALIAS = {}350for _c, _forms in _CATEGORY.items():351    for _f in _forms:352        _k = arabic_ortho(_f).casefold()353        CAT_ALIAS[_k] = _c354        _ks = _strip_al(_k)                   # so اللابتوب == لابتوب355        if _ks != _k:356            CAT_ALIAS.setdefault(_ks, _c)357 358# date fields: normalize named days + relative terms + month names across Arabic359# and English so "الخميس"="يوم الخميس"="Friday", "بكرة"="tomorrow",360# "الأسبوع الجاي"="next week"="next_week". Absolute ISO dates (2023-11-10) are361# left exact. Does NOT reconcile a natural date vs a resolved ISO date.362DATE_FIELDS = {"date", "check_in", "check_out", "departure_date", "return_date", "appointment_date"}363_DAY = {364    "sunday": ["الاحد", "sunday"], "monday": ["الاثنين", "monday"], "tuesday": ["الثلاثاء", "الثلاثا", "tuesday"],365    "wednesday": ["الاربعاء", "wednesday"], "thursday": ["الخميس", "thursday"],366    "friday": ["الجمعة", "friday"], "saturday": ["السبت", "saturday"],367}368_REL = {369    "today": ["اليوم", "today"],370    "tomorrow": ["غدا", "بكرة", "باكر", "باجر", "بكره", "tomorrow"],371    "day_after_tomorrow": ["بعد غد", "بعد غدا", "بعد بكرة", "بعد بكره", "بعد باجر", "بعد باكر",372                            "the day after tomorrow", "day after tomorrow", "after tomorrow"],373    "next_week": ["الاسبوع القادم", "الاسبوع الجاي", "الاسبوع المقبل", "next week", "coming week"],374    "next_month": ["الشهر القادم", "الشهر الجاي", "الشهر المقبل", "next month"],375    "weekend": ["نهاية الاسبوع", "عطلة نهاية الاسبوع", "weekend", "the weekend"],376}377_MONTH = {m: [m, m[:3]] + ars for m, ars in {378    "january": ["يناير"], "february": ["فبراير"], "march": ["مارس"], "april": ["ابريل", "إبريل"],379    "may": ["مايو", "ماي"], "june": ["يونيو", "يونيه"], "july": ["يوليو", "يوليوز", "يوليه"],380    "august": ["اغسطس", "غشت"], "september": ["سبتمبر", "شتنبر"], "october": ["اكتوبر"],381    "november": ["نوفمبر", "نونبر"], "december": ["ديسمبر", "دجنبر"]}.items()}382_norm = lambda s: arabic_ortho(to_ascii_digits(s)).strip().casefold()383DATE_ALIAS = {}384for _t in (_DAY, _REL):385    for _c, _fs in _t.items():386        for _f in _fs: DATE_ALIAS[_norm(_f)] = _c387_DAY_TOK = {_norm(f): c for c, fs in _DAY.items() for f in fs}388_MONTH_TOK = {_norm(f): c for c, fs in _MONTH.items() for f in fs}389_QUAL = re.compile(r"\s+(القادم|القادمه|الجاي|الجايه|المقبل|المقبله|القادمة|الجاية|المقبلة)$")390 391def _norm_date(s: str) -> str:392    s = s.replace("_", " ").strip()393    if s in DATE_ALIAS: return DATE_ALIAS[s]394    s = re.sub(r"^يوم\s+", "", s)395    s2 = _QUAL.sub("", s)396    if s2 in DATE_ALIAS: return DATE_ALIAS[s2]397    if s2 in _DAY_TOK: return _DAY_TOK[s2]398    return " ".join(_MONTH_TOK.get(t, _DAY_TOK.get(t, t)) for t in s2.split()).strip() or s399 400# name fields: map common Arabic personal names to/from their English401# transliterations so "أحمد" = "Ahmed", "صديقي" = "my friend". Token-wise, so402# compound names ("فاطمة حسين" = "Fatima Hussein") work.403NAME_FIELDS = {"recipient_name", "doctor_name"}404_NAMES = {405    "احمد": ["ahmed", "ahmad"], "محمد": ["mohammed", "mohamed", "muhammad", "mohammad", "mohamad"],406    "علي": ["ali"], "عبدالله": ["abdullah", "abdallah", "abdulla"], "حسن": ["hassan", "hasan"],407    "حسين": ["hussein", "hussain", "husain"], "فاطمه": ["fatima", "fatimah"], "عمر": ["omar", "umar"],408    "خالد": ["khalid", "khaled"], "ساره": ["sara", "sarah"], "مريم": ["maryam", "mariam"],409    "ماريا": ["maria"], "نوره": ["noura", "nora"], "يوسف": ["youssef", "yusuf", "yousef"],410    "ابراهيم": ["ibrahim"], "سعد": ["saad"], "فهد": ["fahd", "fahad"], "عبدالرحمن": ["abdulrahman", "abdelrahman"],411}412_NAME_TOK = {}413for _c, _fs in _NAMES.items():414    key = arabic_ortho(_c).casefold()415    _NAME_TOK[key] = key416    for _f in _fs: _NAME_TOK[_f.casefold()] = key417_NAME_PHRASE = {"my friend": "صديقي", "my brother": "اخي", "my sister": "اختي", "my father": "والدي",418                "my mother": "والدتي", "my wife": "زوجتي", "my husband": "زوجي", "my son": "ابني"}419_NAME_PHRASE = {k: arabic_ortho(v).casefold() for k, v in _NAME_PHRASE.items()}420 421def _norm_name(s: str) -> str:422    if s in _NAME_PHRASE: return _NAME_PHRASE[s]423    return " ".join(_NAME_TOK.get(t, t) for t in s.split()).strip() or s424 425# ────────────────────────── public matching API ────────────────────────────426def canon_value(v, field: str = "") -> str:427    s = to_ascii_digits(str(v))428    s = arabic_ortho(s)429    s = words_to_digits(s)430    s = re.sub(r"\s+", " ", s).strip().casefold()431    cls = FIELD_CLASS.get(field)432    s_da = _strip_al(s)                        # definite-article-insensitive form433    if cls:434        if s in _ALIAS[cls]:435            return _ALIAS[cls][s]436        if s_da in _ALIAS[cls]:437            return _ALIAS[cls][s_da]438    if field in PRODUCT_FIELDS:439        return _norm_product(s)440    if field in SPECIALTY_FIELDS:441        return _norm_specialty(s)442    if field in CATEGORY_FIELDS:443        return CAT_ALIAS.get(s) or CAT_ALIAS.get(s_da) or s_da444    if field in DATE_FIELDS:445        return _norm_date(s)                   # dates keep their own «ال»-aware logic446    if field in NAME_FIELDS:447        return _norm_name(s)448    return s_da449 450def _split_set(v, field: str) -> frozenset:451    s = to_ascii_digits(str(v))452    s = _WAW_ATTACHED.sub(" ، ", s)453    parts = [p for p in _SEP.split(s) if p and p not in ("،", ",", ";", "&", "/", "و", "and")]454    if not parts:455        parts = [s]456    return frozenset(canon_value(p, field) for p in parts if p.strip())457 458def value_match(pred, gold, field: str = "") -> bool:459    """True iff pred and gold are the same answer under the normalisation rules."""460    # numeric equivalence (quantity fields only)461    if field not in ID_FIELDS:462        pn, gn = _as_number(pred), _as_number(gold)463        if pn is not None and gn is not None:464            return pn == gn465    if field in LIST_FIELDS:466        return _split_set(pred, field) == _split_set(gold, field)467    return canon_value(pred, field) == canon_value(gold, field)468 469# ── Optional parameters that are declared in a tool's schema but never appear in470# any gold answer (derived from the released train+dev gold). They are neither471# required nor scored: stripped from BOTH prediction and gold before matching, so472# a model may freely emit OR omit them (e.g. a model that infers `source_language`473# for translate_text is not penalised). Exact-set matching is unchanged for every474# real argument, and this is tool-scoped — a key ignored for one tool can still be475# a scored argument for another (e.g. `country` is ignored for get_weather but476# scored for compare_prices). NOT a blanket "ignore all extras" rule.477OPTIONAL_IGNORE: dict[str, set[str]] = {478    "translate_text":           {"source_language"},479    "check_traffic_violations": {"plate_number"},480    "get_qibla_direction":      {"latitude", "longitude"},481    # `days` is annotated inconsistently in gold (the same word "today" appears as482    # 1 / None / 2), so it is unlearnable and not scored — same rationale as483    # source_language. `country` is inferred from the city, also not scored.484    "get_weather":              {"country", "days"},485    # country is inferable from the city and almost never stated by the user;486    # gold carries it inconsistently, so it is not scored (same as get_weather).487    "get_air_quality":          {"country"},488    "search_medications":       {"country"},489    "calculate_end_of_service": {"country"},490    # currency is annotated in only ~70% of rows and can't be disambiguated from491    # context (bare "ريال" in Qatar resolves to QAR in gold but SAR by alias), so492    # it is unlearnable and not scored here (convert_currency still scores it).493    "calculate_customs":        {"currency"},494    "calculate_zakat":          {"weight_unit"},495    "check_iqama_status":       {"border_number"},496    "order_food":               {"delivery_address"},497    "search_hotels":            {"stars"},498    # search_type: annotated in <5% of search_quran gold (inconsistently) while a499    # type-word (آية/سورة/تفسير…) appears in most queries — unlearnable either way,500    # so it is not scored (same rationale as source_language).501    "search_quran":             {"surah_number", "search_type"},502    "search_umrah_packages":    {"duration_days", "hotel_rating"},503}504 505def args_match(pred_args: dict, gold_args: dict, tool: str | None = None) -> bool:506    """All-or-nothing ArgEM over a row, with per-value normalised matching.507 508    Optional parameters in OPTIONAL_IGNORE[tool] (declared in schema but never in509    gold) are dropped from both sides first, so emitting them is neither rewarded510    nor penalised. All other keys must match exactly (set equality + value_match).511    """512    ignore = OPTIONAL_IGNORE.get(tool or "", set())513    pg = {k: v for k, v in (pred_args or {}).items()514          if v is not None and str(v).strip() != "" and k not in ignore}515    gg = {k: v for k, v in (gold_args or {}).items()516          if v is not None and str(v).strip() != "" and k not in ignore}517    if tool == "compare_prices":518        return _compare_prices_match(pg, gg)519    if set(pg.keys()) != set(gg.keys()):520        return False521    return all(value_match(pg[k], gg[k], k) for k in gg)522 523 524# compare_prices declares BOTH `category` (generic type) and `product_name`525# (specific model), and the distinction is too fine for a generic item (لابتوب,526# سماعات) — models split ~evenly on which slot to use. So the two are treated as527# ONE product/category slot: the non-product keys must still match exactly, and528# the set of product/category values must match (under either normaliser). A team529# that DROPS a distinct value (e.g. category=هاتف alongside product_name=آيفون 13)530# is still penalised — only the slot *name* is forgiven, never a missing value.531_PC_KEYS = ("category", "product_name")532 533def _compare_prices_match(pg: dict, gg: dict) -> bool:534    def split(d):535        rest = {k: v for k, v in d.items() if k not in _PC_KEYS}536        pc = [v for k, v in d.items() if k in _PC_KEYS]537        return rest, pc538    pr, ppc = split(pg); gr, gpc = split(gg)539    if set(pr.keys()) != set(gr.keys()):540        return False541    if not all(value_match(pr[k], gr[k], k) for k in gr):542        return False543    if len(ppc) != len(gpc):544        return False545    used = [False] * len(gpc)546    for pv in ppc:                              # greedy multiset match, either slot's normaliser547        for i, gv in enumerate(gpc):548            if not used[i] and (value_match(pv, gv, "product_name") or value_match(pv, gv, "category")):549                used[i] = True550                break551        else:552            return False553    return True554