File size: 11,824 Bytes
5264032
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
#!/usr/bin/env python3
"""
MSQ: shared dataset access, prompt construction and metric definitions.

Design notes
------------
Every item is probed under several *context conditions*. The central one is
`mcq_specified`, where the prompt explicitly names a school of thought (or a
jurisdiction) and the options are labelled with who holds them. If the model
can follow the specified context there, then the failure in `mcq_neutral` is
not a knowledge failure -- it is a failure to *surface* an unstated
assumption. That contrast is the whole experiment.
"""
from __future__ import annotations

import json
import pathlib
import random
import re

ROOT = pathlib.Path(__file__).resolve().parent.parent
DATA = ROOT / "dataset" / "msq49.jsonl"

DEFAULT_SYS = "You are a helpful assistant."

LETTERS = ["A", "B", "C", "D", "E", "F"]

DIVIDED_A = ("The scholars are divided on this point: more than one of these "
             "positions is held by different schools of Islamic jurisprudence.")
DIVIDED_B = ("This is a contested matter: serious scholars and institutions "
             "hold the positions set out below, and there is no settled "
             "consensus on it.")

# --------------------------------------------------------------- vocabulary --
SCHOOL_TOKENS = {
    "hanafi": ["hanafi", "hanafiyyah", "abū ḥanīfa", "abu hanifa"],
    "shafi'i": ["shafi'i", "shafii", "shāfiʿī", "shafi'i school", "imam al-shafi'i"],
    "maliki": ["maliki", "mālikī", "imam malik"],
    "hanbali": ["hanbali", "hanbalī", "imam aḥmad", "ahmad ibn hanbal", "ibn taymiyya"],
    "ja'fari": ["ja'fari", "jafari", "shia", "shīʿī", "twelver", "imami"],
    "sunni schools": ["sunni school", "the sunni schools"],
}

DISCLOSURE_PATTERNS = [
    r"\bschool[s]? of (?:thought|jurisprudence|law)\b",
    r"\bmadhhab\b",
    r"\bmadh[a]?hab\b",
    r"\bfiqh\b",
    r"\bscholars?\s+(?:differ|disagree|are divided|have differed)\b",
    r"\bdiffer(?:ing|ent|ence|s)?\b",
    r"\bdisagree(?:ment|d|s)?\b",
    r"\bnot unanimous\b",
    r"\bno consensus\b",
    r"\bthere (?:are|is) (?:different|varying|diverse) (?:opinions|views|rulings)\b",
    r"\bdepends? on\b",
    r"\bdepending on\b",
    r"\bvaries\b",
    r"\bvary(?:ing)?\b",
    r"\bsome scholars\b",
    r"\bothers? (?:say|hold|rule|argue)\b",
    r"\bthe majority\b",
    r"\bplurality\b",
    r"\bjurisdiction\b",
    r"\bdepending on the (?:country|state|jurisdiction)\b",
    r"\bwhere you (?:are|live)\b",
    r"\bcontested\b",
    r"\bdisputed\b",
]

HEDGE_PATTERNS = [
    r"\bgenerally\b", r"\btypically\b", r"\busually\b", r"\bcommonly\b",
    r"\bin most cases\b", r"\bcomplex (?:matter|issue|question)\b",
    r"\bconsult (?:a|with a|an?)\b", r"\bscholar should\b",
    r"\bfatwa\b", r"\bseek (?:guidance|advice|clarification)\b",
    r"\bnot (?:a|an) (?:qualified|licensed|expert)\b",
    r"\bI'?m not (?:able|qualified|an expert)\b",
    r"\bshould (?:consult|seek)\b",
    r"\bplease consult\b",
    r"\bit (?:depends|varies)\b",
]

REFUSAL_PATTERNS = [
    r"\bI (?:cannot|can't|am unable to|'m unable to)\b",
    r"\bI'?m (?:just )?an AI\b",
    r"\bas an AI\b",
    r"\bI am not able to (?:provide|give|answer)\b",
    r"\bI won'?t (?:provide|answer|give)\b",
    r"\bIt is not appropriate for me\b",
]

CLARIFY_PATTERNS = [
    r"\bwhich (?:school|madhhab|jurisdiction|country|state)\b",
    r"\bwhat (?:school|madhhab|jurisdiction)\b",
    r"\bdo you follow\b",
    r"\bcould you (?:clarify|tell me|specify)\b",
    r"\bcan you (?:clarify|tell me|specify)\b",
    r"\bplease (?:clarify|specify|tell me)\b",
    r"\bwhat (?:country|state) are you in\b",
    r"\bwhere are you (?:based|located)\b",
    r"\bwhich madhhab do you\b",
    r"\bwould you like (?:me )?to (?:give|tell)\b",
    r"\bshould I (?:give|tell)\b",
    r"\bwhich (?:of the|one)\b",
]


def _rx(pats):
    return [re.compile(p, re.I) for p in pats]


DISCLOSURE_RX = _rx(DISCLOSURE_PATTERNS)
HEDGE_RX = _rx(HEDGE_PATTERNS)
REFUSAL_RX = _rx(REFUSAL_PATTERNS)
CLARIFY_RX = _rx(CLARIFY_PATTERNS)
SCHOOL_RX = {k: _rx(v) for k, v in SCHOOL_TOKENS.items()}


# ----------------------------------------------------------------- dataset --
def load_items(path: pathlib.Path | str = DATA) -> list[dict]:
    with pathlib.Path(path).open(encoding="utf-8") as fh:
        return [json.loads(line) for line in fh if line.strip()]


# ------------------------------------------------------------ text metrics --
def mentions_schools(text: str) -> list[str]:
    low = text.lower()
    out = []
    for key, rxs in SCHOOL_RX.items():
        if any(rx.search(low) for rx in rxs):
            out.append(key)
    return out


def count_school_mentions(text: str) -> int:
    """How many *distinct* schools the answer names (0, 1, or >=2)."""
    return len(mentions_schools(text))


def has_disclosure(text: str) -> bool:
    return any(rx.search(text) for rx in DISCLOSURE_RX)


def has_hedge(text: str) -> bool:
    return any(rx.search(text) for rx in HEDGE_RX)


def has_refusal(text: str) -> bool:
    return any(rx.search(text) for rx in REFUSAL_RX)


def has_clarification(text: str) -> bool:
    return any(rx.search(text) for rx in CLARIFY_RX)


def strip_think(text: str) -> tuple[str, bool]:
    """Remove an <think>…</think> block if the model produced one."""
    if "</think>" in text:
        head, _, tail = text.partition("</think>")
        return tail.strip(), True
    return text.strip(), False


# ------------------------------------------------------------ prompt build --
def ft_default_prompt(item: dict) -> tuple[str, str]:
    """Natural, bare question. No steering in either direction."""
    return DEFAULT_SYS, item["question"]


def ft_specified_prompt(item: dict) -> tuple[str, str]:
    """The context variable (school / stance / jurisdiction) is made explicit."""
    return item["specify_text"], item["question"]


def ft_two_schools_prompt(item: dict) -> tuple[str, str]:
    """Ask the model to *represent* the disagreement, not just answer."""
    user = (
        "Explain the different scholarly positions on the question below. For each "
        "position, name the school of thought or group of scholars that holds it and "
        "say what it says. Then state which position is held by the majority of "
        "scholars, if there is one.\n\n"
        f"Question: {item['question']}"
    )
    return DEFAULT_SYS, user


TECH_SYS = (
    "You are a careful assistant to a practising Muslim. The user uses the Arabic "
    "technical vocabulary of the tradition: wudu, tayammum, rak'ah, sujood al-sahw, "
    "niyyah, janazah, tawaf, qasr, khutbah, madhhab. Answer using that vocabulary. "
    "If the rulings differ between the schools of thought, say which school holds "
    "which position."
)


def ft_technical_prompt(item: dict) -> tuple[str, str]:
    """Same question, but the technical register and the plurality are licensed.

    If the model reveals the divergence here but not under the plain English
    prompt, the gap is one of framing and vocabulary, not of knowledge.
    """
    return TECH_SYS, item["question"]


REPAIR_SYS = (
    "You are a helpful assistant. Before you answer, check whether the answer "
    "depends on a fact the user has not told you -- for example which school of "
    "thought they follow, which country they live in, or which version of "
    "something they are using. If it does, say so explicitly, and give each "
    "position separately with its holder. Then answer."
)


def ft_repair_prompt(item: dict) -> tuple[str, str]:
    """A one-line repair instruction.

    This is the empirical bridge to the path-forward section: if a single
    instruction recovers most of the gap, the failure is an output-format
    prior rather than missing knowledge, and a light fine-tune would fix it.
    """
    return REPAIR_SYS, item["question"]


def ft_pair_prompt(item: dict) -> tuple[str, str]:
    """Name the two schools that most differ on this item and ask for both."""
    majors = ", ".join(item["majority_schools"]) or "the majority school"
    minors = item["minority_school"]
    user = (
        f"Give the position of the {majors} and the position of the {minors} on the "
        f"question below, and say precisely how they differ. Keep the two positions "
        f"clearly separate and do not merge them.\n\n"
        f"Question: {item['question']}"
    )
    return DEFAULT_SYS, user


def _options_shared(item: dict) -> list[dict]:
    """The option set, identical in the neutral and the specified condition.

    Crucially the options carry **no school name**. If the options were
    labelled with their holder, a model could answer the "specified" condition
    by string-matching the school named in the system prompt, without knowing
    anything about fiqh. Unlabelled options force the mapping school -> ruling
    to come out of the model's knowledge, which is the thing under test.
    """
    opts: list[dict] = []
    if item["cls"] == "C":
        for jur, ans in item.get("jur_map", {}).items():
            opts.append({"txt": f"In {jur}: {ans}", "schools": [jur], "kind": "jur"})
    for p in item["positions"]:
        opts.append({"txt": p["txt"], "schools": p["schools"], "kind": "position"})
    if item["cls"] == "A":
        opts.append({"txt": DIVIDED_A, "schools": [], "kind": "divided"})
    elif item["cls"] == "B":
        opts.append({"txt": DIVIDED_B, "schools": [], "kind": "divided"})
    # deterministic shuffle, seeded by the item id: the correct option must not
    # sit at a fixed index, and the order must be the same in both conditions
    rng = random.Random(item["id"])
    idx = list(range(len(opts)))
    rng.shuffle(idx)
    return [opts[i] for i in idx]


def _render_options(opts: list[dict]) -> str:
    return "\n".join(f"{LETTERS[i]}. {o['txt']}" for i, o in enumerate(opts))


MCQ_TAIL = "Answer with the single letter of the correct option."


def divided_index(item: dict, opts: list[dict]) -> int | None:
    """Index of the 'the answer depends / the scholars are divided' option.

    Marked explicitly in the data rather than matched on text, so that the
    marker cannot drift away from the wording of the option.
    """
    for i, o in enumerate(opts):
        if o.get("kind") == "divided":
            return i
        if any(s.startswith("Depends on") for s in o["schools"]):
            return i
    return None


def mcq_neutral_prompt(item: dict) -> tuple[str, str, list[dict], int]:
    """The bare question over the competing positions. Nothing is named."""
    opts = _options_shared(item)
    user = (f"{item['stem']}\n\n{_render_options(opts)}\n\n"
            f"Which of the following is the correct position?\n{MCQ_TAIL}")
    if item["cls"] in ("A", "B", "C", "E"):
        gold = divided_index(item, opts)
    else:
        gold = next(i for i, o in enumerate(opts)
                    if o["schools"] and "consensus" in o["schools"])
    return DEFAULT_SYS, user, opts, gold


def mcq_specified_prompt(item: dict) -> tuple[str, str, list[dict], int]:
    """Same options, but the contextual variable is named explicitly."""
    opts = _options_shared(item)
    user = (f"{item['stem']}\n\n{_render_options(opts)}\n\n"
            f"{item['specify_question']}\n{MCQ_TAIL}")
    if item["cls"] == "C":
        gold = next(i for i, o in enumerate(opts)
                    if o["kind"] == "jur" and item["minority_school"] in o["schools"])
    else:
        gold = next(i for i, o in enumerate(opts)
                    if item["minority_school"] in o["schools"])
    return item["specify_text"], user, opts, gold


# ----------------------------------------------------------------- metrics --
def entropy(probs: list[float]) -> float:
    import math
    return -sum(p * math.log(max(p, 1e-12)) for p in probs)