Spaces:
Sleeping
Sleeping
Upload structural_patterns.py
Browse files- structural_patterns.py +388 -0
structural_patterns.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MACI — Structural Pattern Layer (Phase 4)
|
| 3 |
+
-------------------------------------------
|
| 4 |
+
Built in direct response to SRB (Shariyah Review Bureau) review, 2026-07-31.
|
| 5 |
+
Arabic coverage added 2026-08-07, ahead of SRB follow-up meeting.
|
| 6 |
+
|
| 7 |
+
WHY THIS EXISTS:
|
| 8 |
+
The core XLM-R classifier is a language-pattern signal layer — it flags
|
| 9 |
+
text that reads like a known violation category (Riba, Gharar, etc).
|
| 10 |
+
SRB's review showed it correctly catches overt language-level issues
|
| 11 |
+
but misses violations that are STRUCTURAL: they only become apparent
|
| 12 |
+
by recognizing a known prohibited contract *pattern*, not a single
|
| 13 |
+
alarming phrase.
|
| 14 |
+
|
| 15 |
+
This module is a separate, deterministic, rule-based layer that runs
|
| 16 |
+
ALONGSIDE the ML classifier — not instead of it. It targets five
|
| 17 |
+
specific structural patterns, each modeled directly on a real clause
|
| 18 |
+
SRB identified as missed:
|
| 19 |
+
|
| 20 |
+
1. Bay' al-Inah / mandatory buy-back structure (Schedule 4)
|
| 21 |
+
2. Disguised Riba via "late payment as income" (Clause 10.6)
|
| 22 |
+
3. Unilateral post-execution price variation (Clause 10.7)
|
| 23 |
+
4. Risk/ownership decoupling (absolute payment
|
| 24 |
+
obligation regardless of asset condition) (Clause 10.5)
|
| 25 |
+
5. Total liability waiver regardless of defect (Clause 7.3)
|
| 26 |
+
|
| 27 |
+
LANGUAGE COVERAGE:
|
| 28 |
+
Each pattern now matches both English and Arabic phrasing. Arabic
|
| 29 |
+
regexes were built by translating the five real clauses, then
|
| 30 |
+
validated against reworded/paraphrased Arabic (not just the direct
|
| 31 |
+
translations) to reduce overfitting to exact wording.
|
| 32 |
+
|
| 33 |
+
HONESTY NOTE — READ BEFORE PRESENTING:
|
| 34 |
+
- This is pattern-matching on KNOWN structural red flags, not general
|
| 35 |
+
reasoning. It will NOT catch every possible structural violation.
|
| 36 |
+
- The Arabic patterns were built and tested by a non-native-Arabic-
|
| 37 |
+
speaking developer (with AI assistance) using standard Islamic
|
| 38 |
+
finance terminology. They are NOT yet reviewed by a native Arabic
|
| 39 |
+
speaker or Shari'a-trained Arabic legal reviewer. Treat Arabic
|
| 40 |
+
coverage as a good-faith first pass, not certified/validated
|
| 41 |
+
coverage, until a native/scholar review has happened.
|
| 42 |
+
- Arabic morphology (verb conjugation, attached prefixes/suffixes)
|
| 43 |
+
makes literal phrase matching more fragile than in English. Recall
|
| 44 |
+
on genuinely novel Arabic phrasing (beyond what's been tested here)
|
| 45 |
+
is unverified and likely lower than English recall.
|
| 46 |
+
- Present this as: "targeted structural pattern detection, validated
|
| 47 |
+
against real SRB findings in English, with a first-pass Arabic
|
| 48 |
+
extension tested against paraphrase variants — not yet
|
| 49 |
+
scholar-reviewed for Arabic."
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
import re
|
| 53 |
+
from dataclasses import dataclass, field
|
| 54 |
+
from typing import List
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class StructuralFlag:
|
| 59 |
+
pattern_id: str
|
| 60 |
+
name: str
|
| 61 |
+
violation_type: str
|
| 62 |
+
aaoifi_standard: str
|
| 63 |
+
severity: str
|
| 64 |
+
matched_text: str
|
| 65 |
+
explanation: str
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class StructuralPattern:
|
| 70 |
+
pattern_id: str
|
| 71 |
+
name: str
|
| 72 |
+
violation_type: str
|
| 73 |
+
aaoifi_standard: str
|
| 74 |
+
severity: str
|
| 75 |
+
explanation: str
|
| 76 |
+
# A match requires ALL regexes in `all_of` to hit within a proximity
|
| 77 |
+
# window (order-independent), which avoids false positives from any
|
| 78 |
+
# single generic phrase appearing anywhere in a long document.
|
| 79 |
+
all_of: List[str] = field(default_factory=list)
|
| 80 |
+
# If ANY of these regexes also matches within the same window, the
|
| 81 |
+
# match is suppressed. Used to rule out genuine exceptions/negations
|
| 82 |
+
# that would otherwise trip the all_of conditions (e.g. a clause that
|
| 83 |
+
# explicitly says a price CANNOT be changed, discussing that fact,
|
| 84 |
+
# will still contain the words "price"/"changed"/"discretion" nearby).
|
| 85 |
+
none_of: List[str] = field(default_factory=list)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
STRUCTURAL_PATTERNS: List[StructuralPattern] = [
|
| 89 |
+
|
| 90 |
+
StructuralPattern(
|
| 91 |
+
pattern_id="SP-01",
|
| 92 |
+
name="Bay' al-Inah / Mandatory Buy-Back Structure",
|
| 93 |
+
violation_type="Structural — disguised loan via buy-back",
|
| 94 |
+
aaoifi_standard="AAOIFI Shari'a Standard No. 8 (Murabaha), §3/1/2 — prohibition on Bay' al-Inah",
|
| 95 |
+
severity="CRITICAL",
|
| 96 |
+
explanation=(
|
| 97 |
+
"Clause requires the buyer to resell the same goods back to the "
|
| 98 |
+
"seller (or its affiliate) at a pre-agreed price. This is the "
|
| 99 |
+
"classic Bay' al-Inah structure — economically equivalent to an "
|
| 100 |
+
"interest-bearing loan disguised as two sales, and is prohibited "
|
| 101 |
+
"under mainstream AAOIFI-referenced Shari'a standards regardless "
|
| 102 |
+
"of the language used to describe it as a genuine sale."
|
| 103 |
+
),
|
| 104 |
+
all_of=[
|
| 105 |
+
r"(resell|sell\s*back|re-?purchase|buy[-\s]?back|"
|
| 106 |
+
r"يعيد\s*بيع|إعادة\s*بيع|بيع\s*نفس|يشتري\s*مرة\s*أخرى|إعادة\s*الشراء|"
|
| 107 |
+
r"(?:بيع|شراء).{0,15}مرة\s*أخرى|مرة\s*أخرى.{0,15}(?:بيع|شراء)|"
|
| 108 |
+
r"دوبارہ\s*فروخت|واپس\s*فروخت|دوبارہ.{0,30}فروخت|فروخت.{0,30}دوبارہ)",
|
| 109 |
+
r"((the\s+)?(same\s+|identical\s+)?(goods|commodit(?:y|ies)|asset)s?|"
|
| 110 |
+
r"البضاعة|السلعة|الأصل|سامان|اثاثہ|اثاثے)",
|
| 111 |
+
r"(to\s+the\s+seller|to\s+the\s+bank|to\s+the\s+financier|"
|
| 112 |
+
r"its\s+designated\s+affiliate|to\s+the\s+original|"
|
| 113 |
+
r"إلى\s*البائع|للبائع|إلى\s*البنك|للبنك|الجهة\s*التابعة|"
|
| 114 |
+
r"بيچنے\s*والے\s*کو|بيچنے\s*والے\s*یا|بینک\s*کو)",
|
| 115 |
+
r"((pre[-\s]?agreed|fixed\s+in\s+advance|predetermined|agreed[-\s]?upon)\s+price|"
|
| 116 |
+
r"price\s+(?:is\s+|shall\s+be\s+|was\s+)?"
|
| 117 |
+
r"(pre[-\s]?agreed|fixed\s+in\s+advance|predetermined|agreed[-\s]?upon)|"
|
| 118 |
+
r"سعر\s*متفق\s*عليه\s*مسبقاً|بسعر\s*محدد\s*مسبقاً|سعر\s*ثابت\s*مسبقاً|"
|
| 119 |
+
r"پہلے\s*سے\s*طے\s*شدہ\s*قیمت|طے\s*شدہ\s*قیمت|مقررہ\s*قیمت)",
|
| 120 |
+
],
|
| 121 |
+
),
|
| 122 |
+
|
| 123 |
+
StructuralPattern(
|
| 124 |
+
pattern_id="SP-02",
|
| 125 |
+
name="Disguised Riba via Late-Payment-as-Income",
|
| 126 |
+
violation_type="Structural — interest disguised as penalty/fee income",
|
| 127 |
+
aaoifi_standard="AAOIFI Shari'a Standard No. 3 (Default in Payment by a Debtor), §2 — late payment charges must go to charity, not seller income",
|
| 128 |
+
severity="CRITICAL",
|
| 129 |
+
explanation=(
|
| 130 |
+
"Clause imposes a recurring percentage-based late payment charge "
|
| 131 |
+
"that explicitly becomes part of the seller's own income/profit. "
|
| 132 |
+
"Under AAOIFI SS3, a compliant late-payment clause can only "
|
| 133 |
+
"impose a charge that is donated to charity — retaining it as "
|
| 134 |
+
"seller income is functionally identical to compound interest "
|
| 135 |
+
"on a defaulted debt, i.e. disguised Riba."
|
| 136 |
+
),
|
| 137 |
+
all_of=[
|
| 138 |
+
r"(late\s+payment|overdue|delay(?:ed)?\s+in\s+payment|"
|
| 139 |
+
r"تأخر|تأخير|تاخیر|تاخیری)",
|
| 140 |
+
r"(charge|fee|penalty|surcharge|رسوم|غرامة|چارج|جرمانہ)",
|
| 141 |
+
r"(\d+(\.\d+)?\s*%\s*per\s*(week|month|day|annum)|"
|
| 142 |
+
r"(?:نسبة\s*)?\d+(?:\.\d+)?\s*%\s*(?:أسبوعياً|شهرياً|يومياً|سنوياً)|"
|
| 143 |
+
r"\d+(?:\.\d+)?\s*%\s*(?:ہفتہ\s*وار|ماہانہ|یومیہ|سالانہ))",
|
| 144 |
+
r"((seller'?s?|bank'?s?|financier'?s?)\s+(income|profit|revenue)|"
|
| 145 |
+
r"(retain(?:ed)?|form\s+part\s+of).{0,25}(income|profit|revenue)|"
|
| 146 |
+
r"دخل\s*البائع|دخل\s*البنك|جزءاً\s*من\s*دخل|"
|
| 147 |
+
r"آمدنی\s*کا\s*حصہ|آمدنی)",
|
| 148 |
+
],
|
| 149 |
+
),
|
| 150 |
+
|
| 151 |
+
StructuralPattern(
|
| 152 |
+
pattern_id="SP-03",
|
| 153 |
+
name="Unilateral Post-Execution Price Variation",
|
| 154 |
+
violation_type="Structural — Gharar via undisclosed/variable price",
|
| 155 |
+
aaoifi_standard="AAOIFI Shari'a Standard No. 8 (Murabaha), §2/2 — Murabaha price must be fixed and disclosed at contract execution",
|
| 156 |
+
severity="HIGH",
|
| 157 |
+
explanation=(
|
| 158 |
+
"Clause allows the seller to adjust the Murabaha sale price "
|
| 159 |
+
"after the agreement has been executed, at its sole discretion. "
|
| 160 |
+
"A valid Murabaha requires the cost and profit margin to be "
|
| 161 |
+
"fixed and disclosed to the buyer at the point of sale — "
|
| 162 |
+
"allowing unilateral post-execution adjustment reintroduces "
|
| 163 |
+
"exactly the price uncertainty (Gharar) that Murabaha's "
|
| 164 |
+
"disclosure requirement exists to prevent."
|
| 165 |
+
),
|
| 166 |
+
all_of=[
|
| 167 |
+
r"(adjust(?:ed)?|vary|varied|amend(?:ed)?|chang(?:ed|e)|revis(?:e|ed)|"
|
| 168 |
+
r"تعديل|يعدل|تغيير|يغير|تبدیل)",
|
| 169 |
+
r"((sole|unfettered|absolute|unilateral)\s+discretion|"
|
| 170 |
+
r"تقديره\s*المطلق|تقديره\s*المنفرد|وفقاً\s*لتقديره|"
|
| 171 |
+
r"مطلق\s*صوابدید|اپنی\s*صوابدید)",
|
| 172 |
+
r"(price|murabaha\s+sale\s+price|sale\s+price|السعر|سعر\s*بيع\s*المرابحة|"
|
| 173 |
+
r"قیمت|مرابحہ\s*(?:فروخت\s*کی\s*)?قیمت)",
|
| 174 |
+
],
|
| 175 |
+
none_of=[
|
| 176 |
+
r"cannot\s+be\s+chang(?:ed)?",
|
| 177 |
+
r"(shall|may|will)\s+not\s+be\s+chang(?:ed)?",
|
| 178 |
+
r"discretion\s+may\s+or\s+may\s+not\s+give\s+a\s+rebate",
|
| 179 |
+
r"\brebate\b",
|
| 180 |
+
r"لا\s*يجوز\s*تعديله",
|
| 181 |
+
r"\bخصم\s*تعجيل\b|\bخصم\s*مبكر\b", # early-settlement rebate carve-out
|
| 182 |
+
r"تبدیل\s*نہیں\s*کیا\s*جا\s*سکے\s*گا",
|
| 183 |
+
],
|
| 184 |
+
),
|
| 185 |
+
|
| 186 |
+
StructuralPattern(
|
| 187 |
+
pattern_id="SP-04",
|
| 188 |
+
name="Risk/Ownership Decoupling with Absolute Payment Obligation",
|
| 189 |
+
violation_type="Structural — Gharar/unjust risk allocation without ownership",
|
| 190 |
+
aaoifi_standard="AAOIFI Shari'a Standard No. 8 (Murabaha), §5/1 — seller must bear ownership risk before/at point of sale; buyer's payment obligation cannot be wholly absolute regardless of asset condition",
|
| 191 |
+
severity="HIGH",
|
| 192 |
+
explanation=(
|
| 193 |
+
"Clause shifts all risk (damage, loss, defects) to the buyer "
|
| 194 |
+
"from before/at the point of purchase while explicitly stating "
|
| 195 |
+
"the seller bears no risk, and separately makes the buyer's "
|
| 196 |
+
"payment obligation absolute and unconditional. A core Murabaha "
|
| 197 |
+
"requirement is that the seller carries genuine ownership risk "
|
| 198 |
+
"for some period before resale — structuring the deal so the "
|
| 199 |
+
"'seller' bears zero risk while the buyer's payment is "
|
| 200 |
+
"unconditional resembles a financing arrangement dressed as a "
|
| 201 |
+
"sale, undermining the risk-sharing basis that makes Murabaha "
|
| 202 |
+
"distinct from an interest-bearing loan."
|
| 203 |
+
),
|
| 204 |
+
all_of=[
|
| 205 |
+
r"((seller|bank|financier)\s+shall\s+bear\s+(no|none\s+of\s+the)\s+risk|"
|
| 206 |
+
r"(bank|seller|financier)\s+shall\s+bear\s+none|"
|
| 207 |
+
r"لا\s*يتحمل\s*(?:البائع|البنك|الممول|الشركة)\s*(?:أي(?:ة)?)?\s*مخاطر|"
|
| 208 |
+
r"خطرہ.{0,20}(?:برداشت\s*نہیں\s*کرے|نہیں\s*اٹھائے)\s*گا)",
|
| 209 |
+
r"(absolute\s+and\s+unconditional|unconditional.{0,15}absolute|"
|
| 210 |
+
r"under\s+all\s+circumstances|"
|
| 211 |
+
r"مطلق(?:اً)?\s*وغير\s*مشروط|غير\s*مشروط.{0,15}مطلق|"
|
| 212 |
+
r"مطلق\s*اور\s*غیر\s*مشروط)",
|
| 213 |
+
],
|
| 214 |
+
),
|
| 215 |
+
|
| 216 |
+
StructuralPattern(
|
| 217 |
+
pattern_id="SP-05",
|
| 218 |
+
name="Total Liability Waiver Regardless of Defect/Title",
|
| 219 |
+
violation_type="Structural — waiver of Khiyar al-'Ayb (defect option) and title warranty",
|
| 220 |
+
aaoifi_standard="AAOIFI Shari'a Standard No. 8 (Murabaha), §4/6 — buyer retains recourse for defects the seller knew or should have known about",
|
| 221 |
+
severity="MEDIUM",
|
| 222 |
+
explanation=(
|
| 223 |
+
"Clause has the buyer waive all remedy against the seller for "
|
| 224 |
+
"defects in quality, condition, quantity, description, or even "
|
| 225 |
+
"title — deemed accepted 'unconditionally and without "
|
| 226 |
+
"reservation.' A total waiver of defect recourse, especially "
|
| 227 |
+
"including title defects, goes beyond normal 'as-is' sale terms "
|
| 228 |
+
"and can void the buyer's Shari'a-recognized right to remedy "
|
| 229 |
+
"for undisclosed defects (Khiyar al-'Ayb)."
|
| 230 |
+
),
|
| 231 |
+
all_of=[
|
| 232 |
+
r"(no\s+(further\s+)?remedy|لا\s*يحق\s*له\s*أي\s*(?:مطالبة|تعويض)\s*آخر|"
|
| 233 |
+
r"لن\s*يكون\s*له\s*أي\s*حق\s*آخر|"
|
| 234 |
+
r"کوئی\s*(?:مزید\s*)?چارہ\s*جوئی|کوئی\s*مزید\s*دعو[یٰ])",
|
| 235 |
+
r"(unconditionally\s+and\s+without\s+reservation|"
|
| 236 |
+
r"غير\s*مشروط\s*ودون\s*(?:أي\s*)?تحفظ|بشكل\s*غير\s*مشروط.{0,15}تحفظ|"
|
| 237 |
+
r"غیر\s*مشروط\s*اور\s*بلا\s*کسی\s*تحفظ)",
|
| 238 |
+
],
|
| 239 |
+
),
|
| 240 |
+
]
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def normalize_arabic_script(s: str) -> str:
|
| 244 |
+
"""
|
| 245 |
+
Normalizes Perso-Arabic script variants that look nearly identical
|
| 246 |
+
but are different Unicode codepoints, so a regex written with one
|
| 247 |
+
variant still matches text written with the other. This matters
|
| 248 |
+
because Arabic text conventionally uses U+064A/U+0643 while
|
| 249 |
+
Urdu/Farsi text conventionally uses U+06CC/U+06A9 for the same
|
| 250 |
+
visual letters — mixing them (even accidentally, e.g. when a
|
| 251 |
+
pattern is typed by hand) causes silent match failures that are
|
| 252 |
+
very hard to spot by eye, since the characters render identically.
|
| 253 |
+
"""
|
| 254 |
+
s = s.replace("\u064a", "\u06cc") # Arabic yeh -> Urdu/Farsi yeh
|
| 255 |
+
s = s.replace("\u0649", "\u06cc") # Arabic alef maksura -> yeh
|
| 256 |
+
s = s.replace("\u0643", "\u06a9") # Arabic kaf -> Urdu/Farsi keheh
|
| 257 |
+
return s
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def scan_structural_patterns(text: str) -> List[StructuralFlag]:
|
| 261 |
+
"""
|
| 262 |
+
Runs all structural patterns against the given text.
|
| 263 |
+
|
| 264 |
+
Uses WINDOWED proximity matching: all component regexes for a pattern
|
| 265 |
+
must match within a bounded character window of each other (not just
|
| 266 |
+
anywhere in the whole input), to avoid false positives when scanning
|
| 267 |
+
long documents where unrelated clauses can each contribute one
|
| 268 |
+
matching phrase. Window size is tuned to comfortably span one clause
|
| 269 |
+
(~2-4 sentences) without spanning into unrelated adjacent clauses.
|
| 270 |
+
"""
|
| 271 |
+
WINDOW = 350 # chars — roughly one clause's worth of context
|
| 272 |
+
|
| 273 |
+
flags = []
|
| 274 |
+
normalized = normalize_arabic_script(re.sub(r"\s+", " ", text))
|
| 275 |
+
|
| 276 |
+
for pattern in STRUCTURAL_PATTERNS:
|
| 277 |
+
# Find all match positions for each component regex. Regexes are
|
| 278 |
+
# also script-normalized so a pattern typed with one Arabic/Urdu
|
| 279 |
+
# letter variant still matches text using the other variant.
|
| 280 |
+
match_lists = []
|
| 281 |
+
ok = True
|
| 282 |
+
for rx in pattern.all_of:
|
| 283 |
+
rx_norm = normalize_arabic_script(rx)
|
| 284 |
+
matches = list(re.finditer(rx_norm, normalized, re.IGNORECASE))
|
| 285 |
+
if not matches:
|
| 286 |
+
ok = False
|
| 287 |
+
break
|
| 288 |
+
match_lists.append(matches)
|
| 289 |
+
if not ok:
|
| 290 |
+
continue
|
| 291 |
+
|
| 292 |
+
# Check whether there exists a combination of one match per regex
|
| 293 |
+
# such that all matches fall within WINDOW chars of each other.
|
| 294 |
+
# Anchor on the first regex's matches and check the others nearby.
|
| 295 |
+
found_cluster = None
|
| 296 |
+
for anchor in match_lists[0]:
|
| 297 |
+
center = (anchor.start() + anchor.end()) // 2
|
| 298 |
+
lo, hi = center - WINDOW, center + WINDOW
|
| 299 |
+
others_ok = True
|
| 300 |
+
cluster_positions = [anchor]
|
| 301 |
+
for other_matches in match_lists[1:]:
|
| 302 |
+
nearby = [m for m in other_matches if lo <= m.start() <= hi]
|
| 303 |
+
if not nearby:
|
| 304 |
+
others_ok = False
|
| 305 |
+
break
|
| 306 |
+
cluster_positions.append(nearby[0])
|
| 307 |
+
if others_ok:
|
| 308 |
+
found_cluster = cluster_positions
|
| 309 |
+
break
|
| 310 |
+
|
| 311 |
+
if found_cluster is None:
|
| 312 |
+
continue
|
| 313 |
+
|
| 314 |
+
# Build snippet around the cluster
|
| 315 |
+
starts = [m.start() for m in found_cluster]
|
| 316 |
+
ends = [m.end() for m in found_cluster]
|
| 317 |
+
snip_start = max(0, min(starts) - 40)
|
| 318 |
+
snip_end = min(len(normalized), max(ends) + 60)
|
| 319 |
+
snippet = normalized[snip_start:snip_end].strip()
|
| 320 |
+
|
| 321 |
+
# Exclusion check: if any none_of regex matches within the same
|
| 322 |
+
# window, this is a known exception/negation — suppress the flag.
|
| 323 |
+
window_text = normalized[max(0, snip_start - 100):min(len(normalized), snip_end + 100)]
|
| 324 |
+
if pattern.none_of and any(
|
| 325 |
+
re.search(normalize_arabic_script(rx), window_text, re.IGNORECASE)
|
| 326 |
+
for rx in pattern.none_of
|
| 327 |
+
):
|
| 328 |
+
continue
|
| 329 |
+
|
| 330 |
+
flags.append(StructuralFlag(
|
| 331 |
+
pattern_id=pattern.pattern_id,
|
| 332 |
+
name=pattern.name,
|
| 333 |
+
violation_type=pattern.violation_type,
|
| 334 |
+
aaoifi_standard=pattern.aaoifi_standard,
|
| 335 |
+
severity=pattern.severity,
|
| 336 |
+
matched_text=f"...{snippet}...",
|
| 337 |
+
explanation=pattern.explanation,
|
| 338 |
+
))
|
| 339 |
+
|
| 340 |
+
return flags
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def structural_flags_to_dict(flags: List[StructuralFlag]) -> list:
|
| 344 |
+
return [
|
| 345 |
+
{
|
| 346 |
+
"pattern_id": f.pattern_id,
|
| 347 |
+
"name": f.name,
|
| 348 |
+
"violation_type": f.violation_type,
|
| 349 |
+
"aaoifi_standard": f.aaoifi_standard,
|
| 350 |
+
"severity": f.severity,
|
| 351 |
+
"matched_text": f.matched_text,
|
| 352 |
+
"explanation": f.explanation,
|
| 353 |
+
}
|
| 354 |
+
for f in flags
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
import json
|
| 360 |
+
import sys
|
| 361 |
+
sys.path.insert(0, ".")
|
| 362 |
+
from test_clauses import (
|
| 363 |
+
CLAUSE_10_5, CLAUSE_10_6, CLAUSE_10_7, SCHEDULE_4, CLAUSE_7_3_DOC2,
|
| 364 |
+
CLAUSE_135092_COMPLIANT, CLAUSE_135096_COMPLIANT, CLAUSE_135093_COMPLIANT,
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
test_cases = [
|
| 368 |
+
("10.5 — Risk/Ownership decoupling (SHOULD FLAG)", CLAUSE_10_5),
|
| 369 |
+
("10.6 — Disguised Riba late payment (SHOULD FLAG)", CLAUSE_10_6),
|
| 370 |
+
("10.7 — Unilateral price variation (SHOULD FLAG)", CLAUSE_10_7),
|
| 371 |
+
("Schedule 4 — Bay' al-Inah buyback (SHOULD FLAG)", SCHEDULE_4),
|
| 372 |
+
("Clause 7.3 — Total liability waiver (SHOULD FLAG)", CLAUSE_7_3_DOC2),
|
| 373 |
+
("135092 — Actual-loss indemnity (SHOULD NOT FLAG)", CLAUSE_135092_COMPLIANT),
|
| 374 |
+
("135096 — Actual-loss indemnity (SHOULD NOT FLAG)", CLAUSE_135096_COMPLIANT),
|
| 375 |
+
("135093 — Tax definition (SHOULD NOT FLAG)", CLAUSE_135093_COMPLIANT),
|
| 376 |
+
]
|
| 377 |
+
|
| 378 |
+
print("=" * 80)
|
| 379 |
+
for label, text in test_cases:
|
| 380 |
+
flags = scan_structural_patterns(text)
|
| 381 |
+
print(f"\n{label}")
|
| 382 |
+
if flags:
|
| 383 |
+
for f in flags:
|
| 384 |
+
print(f" ✅ FLAGGED [{f.pattern_id}] {f.name} — {f.severity}")
|
| 385 |
+
print(f" Standard: {f.aaoifi_standard}")
|
| 386 |
+
else:
|
| 387 |
+
print(" — no structural pattern matched —")
|
| 388 |
+
print("\n" + "=" * 80)
|