File size: 8,108 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
"""
core.utils β€” Shared I/O, caching, and environment helpers.
Owner: D (Engineering Lead)  |  MediSafe-GH Β· Africa AI Safety Prize 2026

Unified from two parallel implementations (Team D scratch work + the
GMASS_Coding_Standard.md reference repo). Function names from BOTH
versions are kept as aliases so nothing else in the codebase breaks:

    load_jsonl()         β€” returns [] on missing file (does not raise)
    append_jsonl()        \\__ same function, two names
    save_jsonl_line()     /
    load_completed_ids()  β€” works with either function name above

Key cost-saving utility: load_completed_ids() enables crash-safe resumption
of API batches β€” never re-pay for a probe already evaluated.
"""

import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path

from core.logger import get_logger

logger = get_logger(__name__)


# ══════════════════════════════════════════════════════════════════════════════
# JSONL I/O
# ══════════════════════════════════════════════════════════════════════════════

def load_jsonl(path: str | Path) -> list[dict]:
    """
    Load all records from a JSONL file.

    Returns [] if the file is missing (does not raise) β€” this matches the
    coding-standard reference implementation. Batch scripts can call this
    on an output file that doesn't exist yet without wrapping in try/except.
    """
    p = Path(path)
    if not p.exists():
        logger.warning(f"JSONL not found: {p} β€” returning []")
        return []

    records, errors = [], 0
    with open(p, encoding="utf-8") as f:
        for i, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                records.append(json.loads(line))
            except json.JSONDecodeError as e:
                logger.error(f"JSON error line {i} of {p}: {e}")
                errors += 1

    if errors:
        logger.warning(f"Loaded {len(records)} records with {errors} parse errors from {p}")
    else:
        logger.debug(f"Loaded {len(records)} records from {p}")
    return records


def append_jsonl(record: dict, path: str | Path) -> None:
    """
    Append ONE record to a JSONL file (creates file + parent dirs if missing).

    Always append β€” never overwrite β€” during batch runs. A crashed run loses
    at most one in-flight record, not the entire batch.
    """
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    with open(p, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")


# Alias β€” earlier pipeline.py / scorer.py scripts call this name.
# Keeping both names means neither version of the codebase needs editing.
save_jsonl_line = append_jsonl


def load_completed_ids(output_path: str | Path, id_field: str = "probe_id") -> set[str]:
    """
    Return the set of probe_ids already present in an output JSONL.

    Use this at the start of every batch run to skip already-evaluated probes.
    This is the primary API cost-saving mechanism: zero re-calls on resume.

    Example:
        done   = load_completed_ids("data/eval_outputs/raw/gpt-4o.jsonl")
        probes = [p for p in all_probes if p["probe_id"] not in done]
        logger.info(f"Resuming: {len(probes)} probes remaining")
    """
    records = load_jsonl(output_path)
    ids = {r[id_field] for r in records if id_field in r}
    if ids:
        logger.info(f"Resume: {len(ids)} probes already done in {Path(output_path).name}")
    return ids


# ══════════════════════════════════════════════════════════════════════════════
# ENVIRONMENT DETECTION
# ══════════════════════════════════════════════════════════════════════════════

def is_kaggle() -> bool:
    """True when running inside a Kaggle kernel."""
    return os.getenv("KAGGLE_KERNEL_RUN_TYPE") is not None


def is_cuda_available() -> bool:
    """True when a CUDA GPU (RTX) is available."""
    try:
        import torch
        return torch.cuda.is_available()
    except ImportError:
        return False


def get_device() -> str:
    """Return 'cuda' on RTX, else 'cpu'. Used by local model loaders (LlamaGuard3, RoBERTa)."""
    return "cuda" if is_cuda_available() else "cpu"


def log_environment(logger_instance) -> None:
    """Log a one-line environment summary at run start."""
    if is_kaggle():
        env = "Kaggle (T4 GPU)" if is_cuda_available() else "Kaggle (CPU)"
    elif is_cuda_available():
        try:
            import torch
            name = torch.cuda.get_device_name(0)
            env  = f"Local CUDA β€” {name}"
        except Exception:
            env = "Local CUDA"
    else:
        env = "CPU only"
    logger_instance.info(f"Environment: {env}")


# ══════════════════════════════════════════════════════════════════════════════
# API HELPERS
# ══════════════════════════════════════════════════════════════════════════════

def get_api_key(env_var: str) -> str:
    """
    Retrieve API key from environment. Raises a clear ValueError if missing.
    Never hardcode keys in scripts β€” always use .env + dotenv.
    """
    key = os.getenv(env_var)
    if not key:
        raise ValueError(
            f"'{env_var}' not set. Add it to your .env file and call load_dotenv()."
        )
    return key


def retry_with_backoff(fn, retries: int = 3, base_wait: float = 1.0):
    """
    Call fn() with exponential backoff on RateLimitError.
    Returns None on final failure β€” never crashes the batch.

    Args:
        fn        : zero-argument callable (lambda wrapping the API call)
        retries   : max attempts
        base_wait : initial wait in seconds (doubles each retry)
    """
    for attempt in range(retries):
        try:
            return fn()
        except Exception as e:
            err_str = str(e).lower()
            is_rate = any(x in err_str for x in ["rate limit", "429", "quota"])
            wait = base_wait * (2 ** attempt)
            if is_rate:
                logger.warning(f"Rate limit hit (attempt {attempt+1}/{retries}). Waiting {wait}s.")
                time.sleep(wait)
            else:
                logger.error(f"API error (attempt {attempt+1}/{retries}): {e}")
                if attempt == retries - 1:
                    return None
                time.sleep(wait)
    return None


# ══════════════════════════════════════════════════════════════════════════════
# MISC
# ══════════════════════════════════════════════════════════════════════════════

def utc_now() -> str:
    """Return current UTC time as ISO-8601 string."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def ensure_dirs(*paths: str) -> None:
    """Create one or more directories if they don't exist."""
    for path in paths:
        os.makedirs(path, exist_ok=True)
        logger.debug(f"Directory ensured: {path}")