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)
|