File size: 10,388 Bytes
75b4f2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
loader.py β€” Load and filter G-MASS probe JSONL files.
Owner: D  |  MediSafe-GH Β· Africa AI Safety Prize 2026
"""

from core.utils import load_jsonl
from core.logger import get_logger

logger = get_logger(__name__)

# Default probe file locations per language
PROBE_PATHS = {
    "english":      "data/probes/probes_en.jsonl",
    "twi":          "data/probes/probes_twi.jsonl",
    "ghanaian_en":  "data/probes/probes_gh_en.jsonl",
}


def load_probes(language: str = "english") -> list[dict]:
    """
    Load the full probe set for a given language condition.

    Args:
        language : "english", "twi", or "ghanaian_en"

    Returns:
        List of probe dicts with fields:
        probe_id, disease_domain, failure_category, prompt,
        language, validator, validation_status, notes
    """
    path = PROBE_PATHS.get(language)
    if not path:
        raise ValueError(
            f"Unknown language: '{language}'. "
            f"Valid options: {list(PROBE_PATHS.keys())}"
        )
    probes = load_jsonl(path)
    logger.info(f"Loaded {len(probes)} probes [{language}] from {path}")
    return probes


def load_probes_from_path(path: str) -> list[dict]:
    """Load probes from a custom JSONL path (e.g. pilot set)."""
    probes = load_jsonl(path)
    logger.info(f"Loaded {len(probes)} probes from {path}")
    return probes


def load_bilingual_probes(path: str) -> list[dict]:
    """
    Load probes that contain both english_prompt and twi_prompt fields
    side by side (e.g. GMASS_150-A_probes_twi.jsonl).

    Returns the raw bilingual records, unchanged.
    Each record has: probe_id, disease_domain, failure_category,
    english_prompt, twi_prompt, validation_status, notes.
    """
    probes = load_jsonl(path)
    logger.info(f"Loaded {len(probes)} bilingual probes from {path}")
    return probes


def expand_bilingual_probes(bilingual_probes: list[dict]) -> dict[str, list[dict]]:
    """
    Split a bilingual probe list into two separate single-language probe lists
    that share the same probe_id β€” needed for SDS comparison.

    IMPORTANT: every expanded record carries BOTH `prompt` (the language-specific
    text actually sent to the model) AND `english_prompt` (always English).
    This is required because GMassScorer.score_one() needs probe_prompt_en for
    LlamaGuard3 context even when scoring a Twi response β€” LlamaGuard3 judges
    response safety IN THE CONTEXT of what was asked, and that context prompt
    must be English regardless of which language the model was actually queried in.

    Per Β§1 of GMASS_Team_Clarifications.md: the Twi text used for `prompt` is
    resolved via resolve_twi_prompt() β€” preferring the human-validated version
    when available, falling back to the machine-translated draft otherwise.
    Both raw fields (`prompt_twi_draft`, `prompt_twi_validated`) are preserved
    unchanged on the expanded record for reproducibility, even though only
    one of them is selected into `prompt`.

    Backward compatible: if a record only has the older flat `twi_prompt`
    field (pre-clarifications schema), that value is used directly.

    Args:
        bilingual_probes : records with english_prompt + twi fields
                           (either the new prompt_twi_draft/prompt_twi_validated
                           pair, or the older flat twi_prompt field)

    Returns:
        {
            "english": [ {probe_id, disease_domain, failure_category,
                           prompt, english_prompt, language: "english"}, ... ],
            "twi":     [ {probe_id, disease_domain, failure_category,
                           prompt, english_prompt, language: "twi",
                           prompt_twi_draft, prompt_twi_validated,
                           translation_status}, ... ],
        }
    """
    english_probes = []
    twi_probes     = []

    for p in bilingual_probes:
        base = {
            "probe_id":         p["probe_id"],
            "disease_domain":   p["disease_domain"],
            "failure_category": p["failure_category"],
            "english_prompt":   p["english_prompt"],  # always carried, for scorer context
        }
        english_probes.append({**base, "language": "english", "prompt": p["english_prompt"]})

        twi_resolved, twi_status = resolve_twi_prompt(p)
        twi_probes.append({
            **base,
            "language":             "twi",
            "prompt":               twi_resolved,
            "prompt_twi_draft":     p.get("prompt_twi_draft", p.get("twi_prompt")),
            "prompt_twi_validated": p.get("prompt_twi_validated"),
            "translation_status":   twi_status,
        })

    logger.info(
        f"Expanded {len(bilingual_probes)} bilingual probes β†’ "
        f"{len(english_probes)} english + {len(twi_probes)} twi"
    )
    return {"english": english_probes, "twi": twi_probes}


def resolve_twi_prompt(probe: dict) -> tuple[str, str]:
    """
    Resolve which Twi text to actually send to a model, per Β§1 of
    GMASS_Team_Clarifications.md.

    Resolution order:
        1. prompt_twi_validated  β€” human-corrected, used if present and non-empty
        2. prompt_twi_draft      β€” raw GhanaNLP/Khaya machine translation
        3. twi_prompt            β€” backward-compat flat field (older schema,
                                   e.g. GMASS_150-A_probes_twi.jsonl), treated
                                   as an unvalidated draft

    CRITICAL per Β§1: this function NEVER mutates or overwrites the source
    fields β€” it only SELECTS which one to use for the model call. Both the
    draft and the validated correction remain intact on the probe record as
    separate, permanent data points (lets the team measure how often machine
    translation needed correction, and keeps the dataset reproducible).

    Args:
        probe : a single bilingual probe record

    Returns:
        (resolved_text, status) where status is one of:
            "validated"        β€” human-corrected version was used
            "draft_unreviewed" β€” no validated version yet, draft used as-is
            "legacy_flat"      β€” older schema with only twi_prompt, no
                                  draft/validated distinction recorded

    Example:
        text, status = resolve_twi_prompt(probe)
        if status == "draft_unreviewed":
            logger.warning(f"{probe['probe_id']}: using unreviewed Twi draft")
    """
    validated = probe.get("prompt_twi_validated")
    if validated:
        return validated, "validated"

    draft = probe.get("prompt_twi_draft")
    if draft:
        return draft, "draft_unreviewed"

    # Backward compatibility with the pre-clarifications flat schema
    legacy = probe.get("twi_prompt")
    if legacy:
        return legacy, "legacy_flat"

    raise KeyError(
        f"Probe {probe.get('probe_id', '?')} has no usable Twi text β€” "
        f"expected one of: prompt_twi_validated, prompt_twi_draft, twi_prompt"
    )


def translation_correction_rate(bilingual_probes: list[dict]) -> dict:
    """
    Per Β§1: measure how often human validators needed to correct the
    machine-translated draft. This is itself a methodology data point for
    the submission ("X% of GhanaNLP/Khaya drafts required human correction").

    Only meaningful for probes that HAVE both a draft and a validated
    field recorded (new schema) β€” legacy flat-field probes are excluded
    from the denominator since no draft/correction distinction exists for them.

    Returns:
        {
            "total_with_draft_and_validation": int,
            "corrected_count":                  int,   # validated != draft
            "unchanged_count":                  int,   # validated == draft
            "correction_rate_pct":               float,
            "still_unreviewed_count":            int,   # has draft, no validated yet
        }
    """
    total_dual   = 0
    corrected    = 0
    unchanged    = 0
    unreviewed   = 0

    for p in bilingual_probes:
        draft     = p.get("prompt_twi_draft")
        validated = p.get("prompt_twi_validated")

        if draft is None:
            continue  # legacy schema, no draft recorded β€” not comparable

        if validated is None:
            unreviewed += 1
            continue

        total_dual += 1
        if validated.strip() != draft.strip():
            corrected += 1
        else:
            unchanged += 1

    rate = round(corrected / total_dual * 100, 2) if total_dual else 0.0

    summary = {
        "total_with_draft_and_validation": total_dual,
        "corrected_count":                  corrected,
        "unchanged_count":                  unchanged,
        "correction_rate_pct":              rate,
        "still_unreviewed_count":           unreviewed,
    }
    logger.info(
        f"Translation correction rate: {rate}% "
        f"({corrected}/{total_dual} drafts corrected, "
        f"{unreviewed} still unreviewed)"
    )
    return summary


def filter_by_domain(probes: list[dict], domain: str) -> list[dict]:
    """Return only probes matching the given disease domain."""
    return [p for p in probes if p.get("disease_domain") == domain]


def filter_by_category(probes: list[dict], category: str) -> list[dict]:
    """Return only probes matching the given failure category."""
    return [p for p in probes if p.get("failure_category") == category]


def filter_approved(probes: list[dict]) -> list[dict]:
    """Return only probes with validation_status == 'Approved'."""
    return [p for p in probes if p.get("validation_status") == "Approved"]


def build_pilot_set(probes: list[dict], per_domain: int = 5) -> list[dict]:
    """
    Build a balanced pilot set with `per_domain` probes from each disease domain.

    Args:
        probes     : full probe list
        per_domain : how many probes to take from each domain

    Returns:
        Balanced pilot list.
    """
    domain_counts: dict[str, int] = {}
    pilot: list[dict] = []
    for probe in probes:
        domain = probe.get("disease_domain", "")
        if domain_counts.get(domain, 0) < per_domain:
            pilot.append(probe)
            domain_counts[domain] = domain_counts.get(domain, 0) + 1
    logger.info(
        f"Built pilot set: {len(pilot)} probes "
        f"({per_domain}/domain across {len(domain_counts)} domains)"
    )
    return pilot