File size: 9,261 Bytes
c3efe57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Unified request/prediction schema + answer extraction for external eval.

A *request* (what an adapter emits, consumed by multivar/eval/run_ts_align.py) is
a dict with at least:

    request_id : str   unique
    id         : any   sample id within the benchmark
    task_type  : str   one of TASK_TYPES (also mirrored into meta["task_type"])
    variant    : str   "normal" (kept for run_ts_align compatibility)
    raw_ts     : list[list[float]]   [n_channels, seq_len]  -> MOMENT
    prompt     : str   user prompt, contains "<ts></ts>" placeholder
    system_prompt : str   output-format instruction chosen by task_type
    gt         : number | str | list   typed by task_type
    meta       : dict  {benchmark, task, task_type, metric, options?, split, lang, ...}

A *prediction* (what run_ts_align.py writes) carries every request field through
plus "prediction" (raw model text, or None on error). Scoring reads predictions.

This module is dependency-free (stdlib only) so it imports anywhere.
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Iterable

# ----------------------------------------------------------------------------
# task types (routing keys)
# ----------------------------------------------------------------------------
NUMERIC = "numeric"      # single scalar  -> relative-accuracy / abs-err
CHOICE = "choice"        # MCQ / T-F / label -> exact-match Acc (+ F1)
TEXT = "text"            # free-form -> Rouge-L / BLEU (or LLM-judge)
SEQUENCE = "sequence"    # numeric series -> MSE / MAE

TASK_TYPES = (NUMERIC, CHOICE, TEXT, SEQUENCE)

# default output-format system prompts per task_type (English; language deferred).
SYSTEM_PROMPTS = {
    NUMERIC: (
        "You are a time-series analysis assistant. Use only the given time series "
        "to answer. Output the final answer strictly as <answer>\\boxed{NUMBER}</answer> "
        "with a single number and nothing else."
    ),
    CHOICE: (
        "You are a time-series analysis assistant. Use only the given time series "
        "to answer. Choose exactly one option. Output strictly as "
        "<answer>\\boxed{LETTER}</answer> with a single option letter and nothing else."
    ),
    TEXT: (
        "You are a time-series analysis assistant. Use only the given time series "
        "to answer. Put your final answer inside <answer>...</answer>."
    ),
    SEQUENCE: (
        "You are a time-series analysis assistant. Forecast the requested values. "
        "Output strictly as <answer>v1, v2, v3, ...</answer>: comma-separated numbers "
        "and nothing else."
    ),
}

TS_PLACEHOLDER = "<ts></ts>"


# ----------------------------------------------------------------------------
# jsonl io
# ----------------------------------------------------------------------------
def read_jsonl(path: str | Path) -> list[dict]:
    rows: list[dict] = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


def write_jsonl(path: str | Path, rows: Iterable[dict]) -> int:
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    n = 0
    with open(path, "w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
            n += 1
    return n


# ----------------------------------------------------------------------------
# request builder (adapters call this to stay schema-correct)
# ----------------------------------------------------------------------------
def build_request(
    *,
    request_id: str,
    sample_id: Any,
    task_type: str,
    raw_ts: list[list[float]],
    question: str,
    gt: Any,
    benchmark: str,
    task: str,
    metric: str,
    options: list[str] | None = None,
    split: str = "test",
    lang: str = "en",
    extra_meta: dict | None = None,
    system_prompt: str | None = None,
    reference_block: str | None = None,
    prompt_override: str | None = None,
) -> dict:
    if task_type not in TASK_TYPES:
        raise ValueError(f"unknown task_type {task_type!r}; expected one of {TASK_TYPES}")
    # Compose the user prompt. If prompt_override is given (benchmark already has a
    # complete question text, e.g. TimeOmni's `problem`), use it verbatim and only
    # ensure a <ts></ts> slot exists for MOMENT injection.
    if prompt_override is not None:
        prompt = prompt_override.strip()
        if TS_PLACEHOLDER not in prompt:
            prompt = f"{prompt}\nTime series: {TS_PLACEHOLDER}"
    else:
        parts = []
        if reference_block:
            parts.append(reference_block.strip())
        parts.append(question.strip())
        if options:
            parts.append("Options:\n" + "\n".join(options))
        parts.append(f"Time series: {TS_PLACEHOLDER}")
        prompt = "\n".join(parts)

    meta = {
        "benchmark": benchmark,
        "task": task,
        "task_type": task_type,
        "metric": metric,
        "split": split,
        "lang": lang,
        "n_channels": len(raw_ts),
        "seq_len": len(raw_ts[0]) if raw_ts else 0,
    }
    if options is not None:
        meta["options"] = options
    if extra_meta:
        meta.update(extra_meta)

    return {
        "request_id": request_id,
        "id": sample_id,
        "task_type": task_type,
        "variant": "normal",
        "raw_ts": raw_ts,
        "prompt": prompt,
        "system_prompt": system_prompt or SYSTEM_PROMPTS[task_type],
        "gt": gt,
        "meta": meta,
    }


# ----------------------------------------------------------------------------
# answer extraction (from raw model text)
# ----------------------------------------------------------------------------
_BOXED_RE = re.compile(r"\\boxed\s*\{([^{}]*)\}")
_ANSWER_RE = re.compile(r"<answer>\s*(.*?)\s*</answer>", re.DOTALL | re.IGNORECASE)
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_NUM_RE = re.compile(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")
_LETTER_RE = re.compile(r"\b([A-Ea-e])\b")  # MCQ up to 5 options (EngineMT uses a-e)


def answer_block(text: str) -> str:
    """Content of the last <answer>..</answer>, else the text minus <think>..</think>.

    Loose: used for TEXT tasks where the whole reply is the answer.
    """
    if not text:
        return ""
    matches = _ANSWER_RE.findall(text)
    if matches:
        return matches[-1].strip()
    return _THINK_RE.sub("", text).strip()


def answer_block_strict(text: str) -> str | None:
    """Content of the last <answer>..</answer>, or None if no answer tag present.

    Strict: used for choice/numeric/sequence so that SR (format validity) only
    counts answers emitted in a structured location, never scraped from free
    prose. Matches TimeOmni's own extract_answer semantics.
    """
    if not text:
        return None
    matches = _ANSWER_RE.findall(text)
    return matches[-1].strip() if matches else None


def extract_boxed(text: str) -> str | None:
    """Content of the last \\boxed{...}, or None."""
    if not text:
        return None
    m = _BOXED_RE.findall(text)
    return m[-1].strip() if m else None


def _to_float(token: str) -> float | None:
    token = token.strip().replace(",", "")
    is_pct = token.endswith("%")
    if is_pct:
        token = token[:-1]
    m = _NUM_RE.search(token)
    if not m:
        return None
    try:
        val = float(m.group(0))
    except ValueError:
        return None
    return val


def parse_number(text: str) -> float | None:
    """Strict: \\boxed{} content, else the <answer> block. No prose fallback.

    Returning None means "format-invalid" and counts against SR — we do NOT
    scrape a number from free reasoning text, which would inflate SR with
    accidental matches.
    """
    boxed = extract_boxed(text)
    if boxed is not None:
        v = _to_float(boxed)
        if v is not None:
            return v
    blk = answer_block_strict(text)
    if blk is not None:
        return _to_float(blk)
    return None


def parse_choice(text: str, options: list[str] | None = None) -> str | None:
    """Strict choice extraction: \\boxed{} or <answer> tag only (no prose scraping).

    Order: \\boxed{} letter/option -> letter/option inside <answer>..</answer>.
    Returns normalized letter (upper) or matched option text, else None.
    """
    boxed = extract_boxed(text)
    if boxed:
        m = _LETTER_RE.search(boxed)
        if m:
            return m.group(1).upper()
        if options and boxed in options:
            return boxed
    blk = answer_block_strict(text)
    if blk:
        m = _LETTER_RE.search(blk)
        if m:
            return m.group(1).upper()
        if options:
            low = blk.lower()
            for opt in options:
                if opt and opt.lower() in low:
                    return opt
    return None


def parse_sequence(text: str) -> list[float]:
    """Strict: floats from \\boxed{} or the <answer> block only (no whole-text)."""
    src = extract_boxed(text)
    if not src:
        src = answer_block_strict(text)
    if not src:
        return []
    return [float(m) for m in _NUM_RE.findall(src)]