Add TF009 Docker integration references and fixed runtime
Browse files- pipelines/Tr006_V2/docker/segment-algorithm/inference.py +210 -14
- pipelines/Tr006_V2/tf009/test_cfff_docker_runtime.py +221 -0
- pipelines/Tr006_V2/tf009/test_tf009_self_consistency.py +151 -0
- pipelines/Tr006_V2/tf009/tf009_self_consistency.py +790 -0
- pipelines/Tr006_V2/tf009/training_free_postprocess.py +406 -0
pipelines/Tr006_V2/docker/segment-algorithm/inference.py
CHANGED
|
@@ -97,6 +97,7 @@ fully local and does not download model files.
|
|
| 97 |
|
| 98 |
import json
|
| 99 |
import logging
|
|
|
|
| 100 |
import sys
|
| 101 |
import time
|
| 102 |
from pathlib import Path
|
|
@@ -175,6 +176,156 @@ SYSTEM_PROMPT = (
|
|
| 175 |
)
|
| 176 |
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
def log_environment(device: torch.device) -> None:
|
| 179 |
"""Report the CUDA stack this container ended up with — once, at startup.
|
| 180 |
|
|
@@ -321,7 +472,7 @@ def your_preprocessing(video_path: Path, req: Request) -> tuple[np.ndarray, dict
|
|
| 321 |
return frames, metadata
|
| 322 |
|
| 323 |
|
| 324 |
-
def answer_one(model, processor, req: Request) -> str:
|
| 325 |
"""Run the merged Qwen3.5 checkpoint on one official request."""
|
| 326 |
frames, metadata = your_preprocessing(clip_path_for(req), req)
|
| 327 |
messages = [
|
|
@@ -340,18 +491,41 @@ def answer_one(model, processor, req: Request) -> str:
|
|
| 340 |
)
|
| 341 |
model_device = next(model.parameters()).device
|
| 342 |
inputs = inputs.to(model_device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
with torch.inference_mode():
|
| 344 |
-
generated = model.generate(**inputs,
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
|
| 357 |
def run() -> int:
|
|
@@ -415,6 +589,7 @@ def run() -> int:
|
|
| 415 |
# ── Inference — one pass per question ──────────────────────────────────────
|
| 416 |
log.info("--- Running inference over %d question(s) ---", len(requests))
|
| 417 |
responses = []
|
|
|
|
| 418 |
n_failed = 0
|
| 419 |
t_batch = time.monotonic()
|
| 420 |
|
|
@@ -425,19 +600,37 @@ def run() -> int:
|
|
| 425 |
)
|
| 426 |
t0 = time.monotonic()
|
| 427 |
try:
|
| 428 |
-
answer = answer_one(model, processor, req)
|
| 429 |
except Exception:
|
| 430 |
# Keep going: one failed question should not cost you the rest of the
|
| 431 |
# batch. An empty answer is simply scored incorrect.
|
| 432 |
n_failed += 1
|
| 433 |
log.exception("[%d/%d] qID=%s failed; emitting empty answer", i, len(requests), req.qID)
|
| 434 |
answer = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
latency = time.monotonic() - t0
|
| 436 |
|
| 437 |
# Informational only. The platform times the run itself and scores against
|
| 438 |
# that measurement — see "The latency budget" in the module docstring.
|
| 439 |
responses.append(Response(qID=req.qID, content=answer, latency=latency))
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
batch_seconds = time.monotonic() - t_batch
|
| 443 |
log.info(
|
|
@@ -455,6 +648,9 @@ def run() -> int:
|
|
| 455 |
output_path = OUTPUT_PATH / "answer.json"
|
| 456 |
save_items(responses, output_path)
|
| 457 |
log.info("Wrote %d response(s) to %s", len(responses), output_path)
|
|
|
|
|
|
|
|
|
|
| 458 |
log.info("=== inference done in %.2f s total ===", time.monotonic() - t_start)
|
| 459 |
return 0
|
| 460 |
|
|
|
|
| 97 |
|
| 98 |
import json
|
| 99 |
import logging
|
| 100 |
+
import re
|
| 101 |
import sys
|
| 102 |
import time
|
| 103 |
from pathlib import Path
|
|
|
|
| 176 |
)
|
| 177 |
|
| 178 |
|
| 179 |
+
def _flatten_token_ids(value) -> list[int]:
|
| 180 |
+
"""Flatten tokenizer/config token-id values while dropping invalid entries."""
|
| 181 |
+
if value is None or isinstance(value, bool):
|
| 182 |
+
return []
|
| 183 |
+
if isinstance(value, (int, np.integer)):
|
| 184 |
+
return [int(value)] if int(value) >= 0 else []
|
| 185 |
+
if hasattr(value, "detach"):
|
| 186 |
+
value = value.detach().cpu().tolist()
|
| 187 |
+
elif hasattr(value, "tolist") and not isinstance(value, (str, bytes)):
|
| 188 |
+
value = value.tolist()
|
| 189 |
+
if isinstance(value, (list, tuple, set)):
|
| 190 |
+
out = []
|
| 191 |
+
for item in value:
|
| 192 |
+
out.extend(_flatten_token_ids(item))
|
| 193 |
+
return out
|
| 194 |
+
return []
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _ordered_unique(values: list[int]) -> list[int]:
|
| 198 |
+
return list(dict.fromkeys(int(value) for value in values if int(value) >= 0))
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def resolve_eos_token_ids(model, tokenizer) -> list[int]:
|
| 202 |
+
"""Merge model and tokenizer EOS values, preserving order and removing duplicates."""
|
| 203 |
+
values = []
|
| 204 |
+
for owner in (
|
| 205 |
+
getattr(model, "generation_config", None),
|
| 206 |
+
getattr(model, "config", None),
|
| 207 |
+
tokenizer,
|
| 208 |
+
):
|
| 209 |
+
values.extend(_flatten_token_ids(getattr(owner, "eos_token_id", None)))
|
| 210 |
+
return _ordered_unique(values)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def resolve_im_start_token_ids(tokenizer) -> list[int]:
|
| 214 |
+
"""Return the tokenizer's (possibly multi-token) ``<|im_start|>`` sequence."""
|
| 215 |
+
try:
|
| 216 |
+
values = _flatten_token_ids(tokenizer.encode("<|im_start|>", add_special_tokens=False))
|
| 217 |
+
except Exception:
|
| 218 |
+
values = []
|
| 219 |
+
if values:
|
| 220 |
+
return values
|
| 221 |
+
try:
|
| 222 |
+
value = tokenizer.convert_tokens_to_ids("<|im_start|>")
|
| 223 |
+
except Exception:
|
| 224 |
+
value = None
|
| 225 |
+
return _flatten_token_ids(value)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _token_rows(value) -> list[list[int]]:
|
| 229 |
+
if hasattr(value, "detach"):
|
| 230 |
+
value = value.detach().cpu().tolist()
|
| 231 |
+
elif hasattr(value, "tolist") and not isinstance(value, (str, bytes)):
|
| 232 |
+
value = value.tolist()
|
| 233 |
+
if not isinstance(value, list):
|
| 234 |
+
value = [value]
|
| 235 |
+
if value and not isinstance(value[0], list):
|
| 236 |
+
value = [value]
|
| 237 |
+
return [[int(token) for token in row] for row in value]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def extract_new_token_ids(generated, input_ids, attention_mask=None) -> list[list[int]]:
|
| 241 |
+
"""Extract only generated IDs, accounting for padded prompt layouts."""
|
| 242 |
+
outputs = _token_rows(generated)
|
| 243 |
+
prompts = _token_rows(input_ids)
|
| 244 |
+
masks = _token_rows(attention_mask) if attention_mask is not None else []
|
| 245 |
+
if not prompts:
|
| 246 |
+
return outputs
|
| 247 |
+
extracted = []
|
| 248 |
+
for index, output in enumerate(outputs):
|
| 249 |
+
prompt = prompts[min(index, len(prompts) - 1)]
|
| 250 |
+
mask = masks[min(index, len(masks) - 1)] if masks else [1] * len(prompt)
|
| 251 |
+
active_prompt = [token for token, flag in zip(prompt, mask) if flag]
|
| 252 |
+
if len(output) >= len(prompt) and output[: len(prompt)] == prompt:
|
| 253 |
+
offset = len(prompt)
|
| 254 |
+
elif active_prompt and len(output) >= len(active_prompt) and output[: len(active_prompt)] == active_prompt:
|
| 255 |
+
offset = len(active_prompt)
|
| 256 |
+
else:
|
| 257 |
+
offset = 0
|
| 258 |
+
extracted.append(output[offset:])
|
| 259 |
+
return extracted
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _first_subsequence(values: list[int], needle: list[int]) -> int | None:
|
| 263 |
+
if not needle or len(needle) > len(values):
|
| 264 |
+
return None
|
| 265 |
+
width = len(needle)
|
| 266 |
+
for index in range(len(values) - width + 1):
|
| 267 |
+
if values[index : index + width] == needle:
|
| 268 |
+
return index
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def trim_generated_token_ids(
|
| 273 |
+
raw_token_ids,
|
| 274 |
+
*,
|
| 275 |
+
eos_token_ids,
|
| 276 |
+
im_start_token_ids,
|
| 277 |
+
max_new_tokens: int,
|
| 278 |
+
) -> dict:
|
| 279 |
+
"""Stop at the earliest EOS or im_start sequence and retain a raw-token audit."""
|
| 280 |
+
raw = _flatten_token_ids(raw_token_ids)
|
| 281 |
+
eos_ids = set(_flatten_token_ids(eos_token_ids))
|
| 282 |
+
im_start = _flatten_token_ids(im_start_token_ids)
|
| 283 |
+
eos_positions = [index for index, token in enumerate(raw) if token in eos_ids]
|
| 284 |
+
eos_index = eos_positions[0] if eos_positions else None
|
| 285 |
+
im_start_index = _first_subsequence(raw, im_start)
|
| 286 |
+
candidates = [(index, "eos") for index in (eos_index,) if index is not None]
|
| 287 |
+
candidates.extend((index, "im_start") for index in (im_start_index,) if index is not None)
|
| 288 |
+
stop_index, stop_reason = min(candidates, default=(None, None), key=lambda item: (item[0], 0 if item[1] == "eos" else 1))
|
| 289 |
+
hit_max = stop_index is None and len(raw) >= int(max_new_tokens)
|
| 290 |
+
if stop_index is None:
|
| 291 |
+
stop_reason = "max_new_tokens" if hit_max else "completed"
|
| 292 |
+
token_ids = raw
|
| 293 |
+
else:
|
| 294 |
+
token_ids = raw[:stop_index]
|
| 295 |
+
return {
|
| 296 |
+
"raw_token_ids": raw,
|
| 297 |
+
"token_ids": token_ids,
|
| 298 |
+
"stop_index": stop_index,
|
| 299 |
+
"eos_position": eos_index,
|
| 300 |
+
"eos_positions": eos_positions,
|
| 301 |
+
"stop_reason": stop_reason,
|
| 302 |
+
"hit_max_new_tokens": hit_max,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
_LEADING_THINK = re.compile(r"\A\s*<think>.*?</think>\s*", re.IGNORECASE | re.DOTALL)
|
| 307 |
+
_TRAILING_SPECIAL = re.compile(r"(?:<\|[^>\n]+\|>|</think>|<think>)", re.IGNORECASE)
|
| 308 |
+
_ROLE_MARKER = re.compile(r"\A\s*(?:assistant|user|system)\s*(?::|\Z)", re.IGNORECASE)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def clean_decoded_answer(text: str) -> str:
|
| 312 |
+
"""Remove only a complete leading think block and trailing template pollution."""
|
| 313 |
+
value = str(text or "").strip()
|
| 314 |
+
leading = _LEADING_THINK.match(value)
|
| 315 |
+
if leading:
|
| 316 |
+
value = value[leading.end() :].strip()
|
| 317 |
+
elif value.lower().startswith("<think>"):
|
| 318 |
+
return value
|
| 319 |
+
value = value.splitlines()[0].strip() if value else ""
|
| 320 |
+
marker = _TRAILING_SPECIAL.search(value)
|
| 321 |
+
if marker:
|
| 322 |
+
value = value[: marker.start()].rstrip()
|
| 323 |
+
role = _ROLE_MARKER.match(value)
|
| 324 |
+
if role:
|
| 325 |
+
value = ""
|
| 326 |
+
return value
|
| 327 |
+
|
| 328 |
+
|
| 329 |
def log_environment(device: torch.device) -> None:
|
| 330 |
"""Report the CUDA stack this container ended up with — once, at startup.
|
| 331 |
|
|
|
|
| 472 |
return frames, metadata
|
| 473 |
|
| 474 |
|
| 475 |
+
def answer_one(model, processor, req: Request) -> tuple[str, dict]:
|
| 476 |
"""Run the merged Qwen3.5 checkpoint on one official request."""
|
| 477 |
frames, metadata = your_preprocessing(clip_path_for(req), req)
|
| 478 |
messages = [
|
|
|
|
| 491 |
)
|
| 492 |
model_device = next(model.parameters()).device
|
| 493 |
inputs = inputs.to(model_device)
|
| 494 |
+
tokenizer = getattr(processor, "tokenizer", processor)
|
| 495 |
+
eos_token_ids = resolve_eos_token_ids(model, tokenizer)
|
| 496 |
+
im_start_token_ids = resolve_im_start_token_ids(tokenizer)
|
| 497 |
+
generation_kwargs = {"max_new_tokens": MAX_NEW_TOKENS, "do_sample": False}
|
| 498 |
+
if eos_token_ids:
|
| 499 |
+
generation_kwargs["eos_token_id"] = eos_token_ids if len(eos_token_ids) > 1 else eos_token_ids[0]
|
| 500 |
with torch.inference_mode():
|
| 501 |
+
generated = model.generate(**inputs, **generation_kwargs)
|
| 502 |
+
if hasattr(generated, "sequences"):
|
| 503 |
+
generated = generated.sequences
|
| 504 |
+
new_token_rows = extract_new_token_ids(
|
| 505 |
+
generated,
|
| 506 |
+
inputs["input_ids"],
|
| 507 |
+
inputs.get("attention_mask"),
|
| 508 |
+
)
|
| 509 |
+
trimmed = trim_generated_token_ids(
|
| 510 |
+
new_token_rows[0] if new_token_rows else [],
|
| 511 |
+
eos_token_ids=eos_token_ids,
|
| 512 |
+
im_start_token_ids=im_start_token_ids,
|
| 513 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 514 |
+
)
|
| 515 |
+
decoded = processor.batch_decode(
|
| 516 |
+
[trimmed["token_ids"]], skip_special_tokens=False, clean_up_tokenization_spaces=False
|
| 517 |
+
)[0]
|
| 518 |
+
answer = clean_decoded_answer(decoded)
|
| 519 |
+
audit = {
|
| 520 |
+
"raw_token_ids": trimmed["raw_token_ids"],
|
| 521 |
+
"stop_index": trimmed["stop_index"],
|
| 522 |
+
"eos_position": trimmed["eos_position"],
|
| 523 |
+
"eos_positions": trimmed["eos_positions"],
|
| 524 |
+
"stop_reason": trimmed["stop_reason"],
|
| 525 |
+
"hit_max_new_tokens": trimmed["hit_max_new_tokens"],
|
| 526 |
+
"new_token_count": len(trimmed["raw_token_ids"]),
|
| 527 |
+
}
|
| 528 |
+
return answer, audit
|
| 529 |
|
| 530 |
|
| 531 |
def run() -> int:
|
|
|
|
| 589 |
# ── Inference — one pass per question ──────────────────────────────────────
|
| 590 |
log.info("--- Running inference over %d question(s) ---", len(requests))
|
| 591 |
responses = []
|
| 592 |
+
generation_audits = []
|
| 593 |
n_failed = 0
|
| 594 |
t_batch = time.monotonic()
|
| 595 |
|
|
|
|
| 600 |
)
|
| 601 |
t0 = time.monotonic()
|
| 602 |
try:
|
| 603 |
+
answer, audit = answer_one(model, processor, req)
|
| 604 |
except Exception:
|
| 605 |
# Keep going: one failed question should not cost you the rest of the
|
| 606 |
# batch. An empty answer is simply scored incorrect.
|
| 607 |
n_failed += 1
|
| 608 |
log.exception("[%d/%d] qID=%s failed; emitting empty answer", i, len(requests), req.qID)
|
| 609 |
answer = ""
|
| 610 |
+
audit = {
|
| 611 |
+
"raw_token_ids": [],
|
| 612 |
+
"stop_index": None,
|
| 613 |
+
"eos_position": None,
|
| 614 |
+
"eos_positions": [],
|
| 615 |
+
"stop_reason": "error",
|
| 616 |
+
"hit_max_new_tokens": False,
|
| 617 |
+
"new_token_count": 0,
|
| 618 |
+
}
|
| 619 |
latency = time.monotonic() - t0
|
| 620 |
|
| 621 |
# Informational only. The platform times the run itself and scores against
|
| 622 |
# that measurement — see "The latency budget" in the module docstring.
|
| 623 |
responses.append(Response(qID=req.qID, content=answer, latency=latency))
|
| 624 |
+
generation_audits.append({"qID": req.qID, **audit})
|
| 625 |
+
log.info(
|
| 626 |
+
"[%d/%d] qID=%s answered in %.4f s answer_len=%d stop_reason=%s",
|
| 627 |
+
i,
|
| 628 |
+
len(requests),
|
| 629 |
+
req.qID,
|
| 630 |
+
latency,
|
| 631 |
+
len(answer),
|
| 632 |
+
audit["stop_reason"],
|
| 633 |
+
)
|
| 634 |
|
| 635 |
batch_seconds = time.monotonic() - t_batch
|
| 636 |
log.info(
|
|
|
|
| 648 |
output_path = OUTPUT_PATH / "answer.json"
|
| 649 |
save_items(responses, output_path)
|
| 650 |
log.info("Wrote %d response(s) to %s", len(responses), output_path)
|
| 651 |
+
audit_path = OUTPUT_PATH / "generation_audit.json"
|
| 652 |
+
audit_path.write_text(json.dumps(generation_audits, ensure_ascii=False, indent=2) + "\n")
|
| 653 |
+
log.info("Wrote generation audit for %d response(s) to %s", len(generation_audits), audit_path)
|
| 654 |
log.info("=== inference done in %.2f s total ===", time.monotonic() - t_start)
|
| 655 |
return 0
|
| 656 |
|
pipelines/Tr006_V2/tf009/test_cfff_docker_runtime.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic CPU regression gate for the CFFF Docker generation runtime."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib.util
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import types
|
| 9 |
+
import unittest
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
LOCAL_RUNTIME = (
|
| 16 |
+
ROOT
|
| 17 |
+
/ "experiments/Qwen3.5-9B/debug/debug022_segment_v2_online_preprocess_hf_sync_20260902"
|
| 18 |
+
/ "results/staging/pipelines/Tr006_V2/docker/segment-algorithm/inference.py"
|
| 19 |
+
)
|
| 20 |
+
HUB_RUNTIME = ROOT / "docker/segment-algorithm/inference.py"
|
| 21 |
+
RUNTIME = Path(os.environ["CFFF_RUNTIME"]) if os.environ.get("CFFF_RUNTIME") else (
|
| 22 |
+
HUB_RUNTIME if HUB_RUNTIME.is_file() else LOCAL_RUNTIME
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _load_runtime():
|
| 27 |
+
# The package's ``focus`` dependency is supplied by the official image. Stub
|
| 28 |
+
# its import-only symbols so this gate remains a seconds-level CPU test.
|
| 29 |
+
focus = types.ModuleType("focus")
|
| 30 |
+
focus.Request = object
|
| 31 |
+
focus.Response = object
|
| 32 |
+
focus.load_requests = lambda _: []
|
| 33 |
+
focus.save_items = lambda *_: None
|
| 34 |
+
old_focus = sys.modules.get("focus")
|
| 35 |
+
sys.modules["focus"] = focus
|
| 36 |
+
try:
|
| 37 |
+
spec = importlib.util.spec_from_file_location("cfff_runtime_under_test", RUNTIME)
|
| 38 |
+
module = importlib.util.module_from_spec(spec)
|
| 39 |
+
assert spec and spec.loader
|
| 40 |
+
spec.loader.exec_module(module)
|
| 41 |
+
return module
|
| 42 |
+
finally:
|
| 43 |
+
if old_focus is None:
|
| 44 |
+
sys.modules.pop("focus", None)
|
| 45 |
+
else:
|
| 46 |
+
sys.modules["focus"] = old_focus
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FakeTokenizer:
|
| 50 |
+
eos_token_id = 248046
|
| 51 |
+
all_special_ids = [248044, 248046, 151645, 777, 778]
|
| 52 |
+
|
| 53 |
+
def encode(self, value, add_special_tokens=False):
|
| 54 |
+
assert not add_special_tokens
|
| 55 |
+
if value == "<|im_start|>":
|
| 56 |
+
return [777, 778]
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class SingleImStartTokenizer(FakeTokenizer):
|
| 61 |
+
def encode(self, value, add_special_tokens=False):
|
| 62 |
+
assert not add_special_tokens
|
| 63 |
+
return [777] if value == "<|im_start|>" else []
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class FakeModel:
|
| 67 |
+
class Config:
|
| 68 |
+
eos_token_id = 248044
|
| 69 |
+
|
| 70 |
+
class GenerationConfig:
|
| 71 |
+
eos_token_id = 248044
|
| 72 |
+
|
| 73 |
+
config = Config()
|
| 74 |
+
generation_config = GenerationConfig()
|
| 75 |
+
|
| 76 |
+
def __init__(self):
|
| 77 |
+
self.generate_kwargs = None
|
| 78 |
+
|
| 79 |
+
def parameters(self):
|
| 80 |
+
yield types.SimpleNamespace(device=torch.device("cpu"))
|
| 81 |
+
|
| 82 |
+
def generate(self, **kwargs):
|
| 83 |
+
self.generate_kwargs = kwargs
|
| 84 |
+
return [[9, 10, 30, 248046, 40]]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class FakeInputs(dict):
|
| 88 |
+
def to(self, _device):
|
| 89 |
+
return self
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class FakeProcessor(FakeTokenizer):
|
| 93 |
+
def __init__(self):
|
| 94 |
+
self.tokenizer = self
|
| 95 |
+
self.decode_input = None
|
| 96 |
+
|
| 97 |
+
def apply_chat_template(self, *args, **kwargs):
|
| 98 |
+
return "prompt"
|
| 99 |
+
|
| 100 |
+
def __call__(self, **kwargs):
|
| 101 |
+
return FakeInputs(input_ids=[[9, 10]], attention_mask=[[1, 1]])
|
| 102 |
+
|
| 103 |
+
def batch_decode(self, token_ids, **kwargs):
|
| 104 |
+
self.decode_input = token_ids
|
| 105 |
+
return ["yes"]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class RuntimeTests(unittest.TestCase):
|
| 109 |
+
def test_dynamic_eos_merge_and_im_start_pattern(self):
|
| 110 |
+
runtime = _load_runtime()
|
| 111 |
+
tokenizer = FakeTokenizer()
|
| 112 |
+
model = FakeModel()
|
| 113 |
+
|
| 114 |
+
assert runtime.resolve_eos_token_ids(model, tokenizer) == [248044, 248046]
|
| 115 |
+
assert runtime.resolve_im_start_token_ids(tokenizer) == [777, 778]
|
| 116 |
+
assert runtime.resolve_eos_token_ids(
|
| 117 |
+
type("M", (), {"generation_config": type("G", (), {"eos_token_id": [248044, None, 248044]})(), "config": type("C", (), {"eos_token_id": None})()})(),
|
| 118 |
+
type("T", (), {"eos_token_id": None})(),
|
| 119 |
+
) == [248044]
|
| 120 |
+
assert runtime.resolve_im_start_token_ids(SingleImStartTokenizer()) == [777]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_only_new_tokens_with_padded_batch_layout(self):
|
| 124 |
+
runtime = _load_runtime()
|
| 125 |
+
generated = [
|
| 126 |
+
[9, 10, 11, 0, 40, 41],
|
| 127 |
+
[0, 20, 21, 22, 50, 51],
|
| 128 |
+
]
|
| 129 |
+
input_ids = [[9, 10, 11, 0], [0, 20, 21, 22]]
|
| 130 |
+
attention_mask = [[1, 1, 1, 0], [0, 1, 1, 1]]
|
| 131 |
+
assert runtime.extract_new_token_ids(generated, input_ids, attention_mask) == [[40, 41], [50, 51]]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_token_stop_and_saturation_audit(self):
|
| 135 |
+
runtime = _load_runtime()
|
| 136 |
+
for raw, expected, reason, eos_positions in (
|
| 137 |
+
([10, 248046, 11], [10], "eos", [1]),
|
| 138 |
+
([10, 777, 778, 11], [10], "im_start", []),
|
| 139 |
+
([10, 11, 12], [10, 11, 12], "max_new_tokens", []),
|
| 140 |
+
):
|
| 141 |
+
with self.subTest(reason=reason):
|
| 142 |
+
audit = runtime.trim_generated_token_ids(
|
| 143 |
+
raw,
|
| 144 |
+
eos_token_ids=[248044, 248046],
|
| 145 |
+
im_start_token_ids=[777, 778],
|
| 146 |
+
max_new_tokens=3,
|
| 147 |
+
)
|
| 148 |
+
self.assertEqual(audit["raw_token_ids"], raw)
|
| 149 |
+
self.assertEqual(audit["token_ids"], expected)
|
| 150 |
+
self.assertEqual(audit["stop_reason"], reason)
|
| 151 |
+
self.assertEqual(audit["eos_positions"], eos_positions)
|
| 152 |
+
self.assertEqual(audit["hit_max_new_tokens"], reason == "max_new_tokens")
|
| 153 |
+
earlier_eos = runtime.trim_generated_token_ids(
|
| 154 |
+
[10, 248046, 777, 778, 11],
|
| 155 |
+
eos_token_ids=[248044, 248046],
|
| 156 |
+
im_start_token_ids=[777, 778],
|
| 157 |
+
max_new_tokens=8,
|
| 158 |
+
)
|
| 159 |
+
earlier_im_start = runtime.trim_generated_token_ids(
|
| 160 |
+
[10, 777, 778, 248046, 11],
|
| 161 |
+
eos_token_ids=[248044, 248046],
|
| 162 |
+
im_start_token_ids=[777, 778],
|
| 163 |
+
max_new_tokens=8,
|
| 164 |
+
)
|
| 165 |
+
self.assertEqual((earlier_eos["stop_index"], earlier_eos["stop_reason"]), (1, "eos"))
|
| 166 |
+
self.assertEqual((earlier_im_start["stop_index"], earlier_im_start["stop_reason"]), (1, "im_start"))
|
| 167 |
+
completed = runtime.trim_generated_token_ids(
|
| 168 |
+
[10, 11], eos_token_ids=[248044], im_start_token_ids=[777, 778], max_new_tokens=8
|
| 169 |
+
)
|
| 170 |
+
self.assertEqual(completed["stop_reason"], "completed")
|
| 171 |
+
self.assertFalse(completed["hit_max_new_tokens"])
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_text_cleanup_only_leading_complete_think_and_trailing_pollution(self):
|
| 175 |
+
runtime = _load_runtime()
|
| 176 |
+
assert runtime.clean_decoded_answer(" <think>draft</think>\nyes\nassistant\nnoise") == "yes"
|
| 177 |
+
assert runtime.clean_decoded_answer("yes\n<think>late reasoning</think>\nassistant\nnoise") == "yes"
|
| 178 |
+
assert runtime.clean_decoded_answer("yes <|im_end|> assistant") == "yes"
|
| 179 |
+
assert runtime.clean_decoded_answer("yes\nsecond line") == "yes"
|
| 180 |
+
assert runtime.clean_decoded_answer("The assistant observes anatomy") == "The assistant observes anatomy"
|
| 181 |
+
assert runtime.clean_decoded_answer("<think>unfinished") == "<think>unfinished"
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_public_gt_smoke_strict_matching_without_test_references(self):
|
| 185 |
+
runtime = _load_runtime()
|
| 186 |
+
cases = [
|
| 187 |
+
([101, 248046, 202], "<think>draft</think>\nyes\nassistant", "yes"),
|
| 188 |
+
([101, 777, 778, 202], "no <|im_end|>\nuser", "no"),
|
| 189 |
+
([101, 202], "Clip\nassistant\nnoise", "Clip"),
|
| 190 |
+
]
|
| 191 |
+
for raw, decoded, expected in cases:
|
| 192 |
+
audit = runtime.trim_generated_token_ids(
|
| 193 |
+
raw,
|
| 194 |
+
eos_token_ids=[248044, 248046],
|
| 195 |
+
im_start_token_ids=[777, 778],
|
| 196 |
+
max_new_tokens=8,
|
| 197 |
+
)
|
| 198 |
+
# This tiny public/synthetic batch checks exact decoded answer behavior;
|
| 199 |
+
# no held-out/test references are read by the gate.
|
| 200 |
+
text = runtime.clean_decoded_answer(decoded)
|
| 201 |
+
self.assertEqual(text, expected)
|
| 202 |
+
self.assertEqual(audit["raw_token_ids"], raw)
|
| 203 |
+
|
| 204 |
+
def test_answer_one_passes_eos_and_decodes_trimmed_new_tokens(self):
|
| 205 |
+
runtime = _load_runtime()
|
| 206 |
+
model = FakeModel()
|
| 207 |
+
processor = FakeProcessor()
|
| 208 |
+
runtime.clip_path_for = lambda _req: Path("unused.mp4")
|
| 209 |
+
runtime.your_preprocessing = lambda _path, _req: (None, {})
|
| 210 |
+
answer, audit = runtime.answer_one(model, processor, types.SimpleNamespace(question="Q"))
|
| 211 |
+
self.assertEqual(model.generate_kwargs["eos_token_id"], [248044, 248046])
|
| 212 |
+
self.assertEqual(processor.decode_input, [[30]])
|
| 213 |
+
self.assertEqual(answer, "yes")
|
| 214 |
+
self.assertEqual(audit["raw_token_ids"], [30, 248046, 40])
|
| 215 |
+
self.assertEqual(audit["eos_positions"], [1])
|
| 216 |
+
self.assertEqual(audit["stop_reason"], "eos")
|
| 217 |
+
self.assertFalse(audit["hit_max_new_tokens"])
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
unittest.main()
|
pipelines/Tr006_V2/tf009/test_tf009_self_consistency.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import sys
|
| 3 |
+
import unittest
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 9 |
+
|
| 10 |
+
from tf009_self_consistency import ( # noqa: E402
|
| 11 |
+
FORMAT_ORDER,
|
| 12 |
+
MAX_NEW_TOKENS,
|
| 13 |
+
SEEDS,
|
| 14 |
+
TEMPERATURE,
|
| 15 |
+
TOP_P,
|
| 16 |
+
_prepare_request,
|
| 17 |
+
aggregate_numeric,
|
| 18 |
+
aggregate_prediction,
|
| 19 |
+
aggregate_time,
|
| 20 |
+
merge_predictions,
|
| 21 |
+
normalize_for_majority,
|
| 22 |
+
parse_strict_number,
|
| 23 |
+
parse_strict_time_list,
|
| 24 |
+
select_target_rows,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def row(fmt: str, rid: str, question: str = "Question") -> dict:
|
| 29 |
+
return {
|
| 30 |
+
"id": rid,
|
| 31 |
+
"answer_format": fmt,
|
| 32 |
+
"question": question,
|
| 33 |
+
"messages": [
|
| 34 |
+
{"role": "system", "content": "system"},
|
| 35 |
+
{"role": "user", "content": "<video>\n" + question},
|
| 36 |
+
],
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TF009SelfConsistencyTest(unittest.TestCase):
|
| 41 |
+
def test_prepared_request_packs_video_metadata(self):
|
| 42 |
+
frames = object()
|
| 43 |
+
metadata = {
|
| 44 |
+
"fps": 5.0,
|
| 45 |
+
"frames_indices": list(range(80)),
|
| 46 |
+
"video_backend": "precomputed_absolute_pts",
|
| 47 |
+
"total_num_frames": 80,
|
| 48 |
+
}
|
| 49 |
+
request_row = {
|
| 50 |
+
"id": "video-row",
|
| 51 |
+
"videos": ["clip.mp4"],
|
| 52 |
+
"messages": [{"role": "user", "content": "<video>"}],
|
| 53 |
+
"chat_template_kwargs": {"video_metadata": metadata},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
class Tokenizer:
|
| 57 |
+
def apply_chat_template(self, *args, **kwargs):
|
| 58 |
+
return "prompt"
|
| 59 |
+
|
| 60 |
+
with patch(
|
| 61 |
+
"eval_segmentqa_vllm.fixed_absmeta_frames", return_value=(frames, metadata)
|
| 62 |
+
), patch("eval_segmentqa_vllm.make_messages", return_value=[]):
|
| 63 |
+
request = _prepare_request(request_row, Tokenizer(), 80)
|
| 64 |
+
|
| 65 |
+
video = request["multi_modal_data"]["video"]
|
| 66 |
+
self.assertIsInstance(video, tuple)
|
| 67 |
+
self.assertIs(video[0], frames)
|
| 68 |
+
self.assertEqual(video[1], metadata)
|
| 69 |
+
|
| 70 |
+
def test_frozen_sampling_policy_and_format_stratified_smoke(self):
|
| 71 |
+
self.assertEqual(SEEDS, (42, 43, 44))
|
| 72 |
+
self.assertEqual(TEMPERATURE, 0.2)
|
| 73 |
+
self.assertEqual(TOP_P, 0.95)
|
| 74 |
+
self.assertEqual(MAX_NEW_TOKENS, 64)
|
| 75 |
+
rows = [row(fmt, str(index)) for index, fmt in enumerate(FORMAT_ORDER)]
|
| 76 |
+
rows.append(row("binary", "extra"))
|
| 77 |
+
selected = select_target_rows(rows, smoke=7)
|
| 78 |
+
self.assertEqual({item["answer_format"] for item in selected}, set(FORMAT_ORDER))
|
| 79 |
+
self.assertEqual(len(selected), 7)
|
| 80 |
+
with self.assertRaises(ValueError):
|
| 81 |
+
select_target_rows(rows, smoke=6)
|
| 82 |
+
|
| 83 |
+
def test_strict_numeric_and_percentage_parse(self):
|
| 84 |
+
self.assertEqual(parse_strict_number(" 12.50 "), 12.50)
|
| 85 |
+
self.assertEqual(parse_strict_number("42%", percentage=True), 42)
|
| 86 |
+
self.assertIsNone(parse_strict_number("12 items"))
|
| 87 |
+
self.assertIsNone(parse_strict_number("1,000"))
|
| 88 |
+
self.assertIsNone(parse_strict_number("nan"))
|
| 89 |
+
number = row("number", "n")
|
| 90 |
+
final, audit = aggregate_numeric(number, ["1", "3", "2"])
|
| 91 |
+
self.assertEqual(final, "2")
|
| 92 |
+
self.assertEqual(audit["aggregation"], "median")
|
| 93 |
+
pct = row("percentage", "p")
|
| 94 |
+
self.assertEqual(aggregate_prediction(pct, ["10%", "30", "20%"], "99")[0], "20")
|
| 95 |
+
fallback, fallback_audit = aggregate_prediction(number, ["1", "bad", "3"], "8")
|
| 96 |
+
self.assertEqual(fallback, "8")
|
| 97 |
+
self.assertTrue(fallback_audit["fallback"])
|
| 98 |
+
|
| 99 |
+
def test_time_requires_three_valid_equal_length_lists_and_medians(self):
|
| 100 |
+
self.assertEqual(parse_strict_time_list("1:02:03, 00:00:04"), [3723, 4])
|
| 101 |
+
self.assertIsNone(parse_strict_time_list("01:60:00"))
|
| 102 |
+
self.assertIsNone(parse_strict_time_list("00:00:01 because"))
|
| 103 |
+
final, audit = aggregate_time(["00:00:01, 00:00:10", "00:00:03, 00:00:08", "00:00:02, 00:00:12"])
|
| 104 |
+
self.assertEqual(final, "00:00:02, 00:00:10")
|
| 105 |
+
self.assertFalse(audit["fallback"])
|
| 106 |
+
mismatch, mismatch_audit = aggregate_time(["00:00:01", "00:00:02, 00:00:03", "00:00:02"])
|
| 107 |
+
self.assertIsNone(mismatch)
|
| 108 |
+
self.assertEqual(mismatch_audit["fallback_reason"], "time_point_count_mismatch")
|
| 109 |
+
time_row = row("time", "t")
|
| 110 |
+
self.assertEqual(aggregate_prediction(time_row, ["00:00:01", "bad", "00:00:03"], "00:00:09")[0], "00:00:09")
|
| 111 |
+
|
| 112 |
+
def test_majority_normalizes_non_numeric_formats_and_falls_back_on_tie(self):
|
| 113 |
+
binary = row("binary", "b")
|
| 114 |
+
self.assertEqual(normalize_for_majority(binary, "Final answer: YES."), "yes")
|
| 115 |
+
final, audit = aggregate_prediction(binary, ["yes", "no", "yes"], "no")
|
| 116 |
+
self.assertEqual(final, "yes")
|
| 117 |
+
self.assertEqual(audit["selected"], "sample_majority")
|
| 118 |
+
tie, tie_audit = aggregate_prediction(binary, ["yes", "no", "maybe"], "no")
|
| 119 |
+
self.assertEqual(tie, "no")
|
| 120 |
+
self.assertTrue(tie_audit["fallback"])
|
| 121 |
+
|
| 122 |
+
mc = row("multiple_choice", "mc", "Please select one answer: top/left; top/right; bottom/left; bottom/right")
|
| 123 |
+
self.assertEqual(normalize_for_majority(mc, "top/left"), "top/left")
|
| 124 |
+
self.assertEqual(aggregate_prediction(mc, ["top/left", "top/left", "bottom/right"], "bottom/right")[0], "top/left")
|
| 125 |
+
open_ended = row("open_ended", "o")
|
| 126 |
+
self.assertEqual(aggregate_prediction(open_ended, ["short", "short", "other"], "baseline")[0], "short")
|
| 127 |
+
|
| 128 |
+
def test_merge_is_complete_and_outside_target_is_frozen(self):
|
| 129 |
+
rows = [row(fmt, str(index)) for index, fmt in enumerate(FORMAT_ORDER)]
|
| 130 |
+
baseline = {item["id"]: {"id": item["id"], "prediction": "baseline", "latency": 0.1} for item in rows}
|
| 131 |
+
generated = {
|
| 132 |
+
"0": {
|
| 133 |
+
"id": "0",
|
| 134 |
+
"prediction": "yes",
|
| 135 |
+
"raw_predictions": ["yes", "yes", "no"],
|
| 136 |
+
"audit": {"views": {"A": {}, "B": {}, "C": {}}, "selected": "sample_majority"},
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
before = copy.deepcopy(baseline)
|
| 140 |
+
merged, audit = merge_predictions(rows, baseline, generated, {"0"})
|
| 141 |
+
self.assertEqual(len(merged), 7)
|
| 142 |
+
self.assertEqual(len(audit), 7)
|
| 143 |
+
self.assertEqual(merged[0]["prediction"], "yes")
|
| 144 |
+
self.assertEqual(merged[0]["generation_seeds"], list(SEEDS))
|
| 145 |
+
self.assertEqual(merged[1], before["1"])
|
| 146 |
+
self.assertEqual(sum(item["target"] for item in audit), 1)
|
| 147 |
+
self.assertEqual(Counter(item["id"] for item in audit), Counter(item["id"] for item in rows))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
unittest.main()
|
pipelines/Tr006_V2/tf009/tf009_self_consistency.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Reference-free TF009 self-consistency runner.
|
| 3 |
+
|
| 4 |
+
Each selected fixed-80 request is generated three times from the same merged
|
| 5 |
+
Tr006 checkpoint with seeds 42/43/44, temperature 0.2, top-p 0.95, and 64
|
| 6 |
+
new-token budget. A format-aware, reference-free reducer selects a result or
|
| 7 |
+
keeps the frozen baseline. Rows outside the selected (or smoke) target set
|
| 8 |
+
are copied byte-for-byte from the baseline prediction file.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import copy
|
| 15 |
+
import json
|
| 16 |
+
import math
|
| 17 |
+
import os
|
| 18 |
+
import platform
|
| 19 |
+
import re
|
| 20 |
+
import shlex
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
from collections import Counter
|
| 25 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 26 |
+
from decimal import Decimal, InvalidOperation
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Any, Mapping, Sequence
|
| 29 |
+
|
| 30 |
+
from training_free_postprocess import (
|
| 31 |
+
FOREIGN_OBJECT_CLASSES,
|
| 32 |
+
SUPPORTED_FORMATS,
|
| 33 |
+
normalize_prediction,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 38 |
+
DEFAULT_NFRAMES = 80
|
| 39 |
+
EXPECTED_OFFICIAL_COUNT = 6254
|
| 40 |
+
FORMAT_ORDER = (
|
| 41 |
+
"binary",
|
| 42 |
+
"number",
|
| 43 |
+
"percentage",
|
| 44 |
+
"time",
|
| 45 |
+
"multiple_choice",
|
| 46 |
+
"fo_class",
|
| 47 |
+
"open_ended",
|
| 48 |
+
)
|
| 49 |
+
SEEDS = (42, 43, 44)
|
| 50 |
+
TEMPERATURE = 0.2
|
| 51 |
+
TOP_P = 0.95
|
| 52 |
+
MAX_NEW_TOKENS = 64
|
| 53 |
+
|
| 54 |
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.I | re.S)
|
| 55 |
+
_NUMBER_RE = re.compile(r"[+-]?(?:\d+(?:\.\d+)?|\.\d+)")
|
| 56 |
+
_PERCENT_RE = re.compile(r"[+-]?(?:\d+(?:\.\d+)?|\.\d+)%")
|
| 57 |
+
_HMS_RE = re.compile(r"\d{1,6}:\d{2}:\d{2}")
|
| 58 |
+
_ANSWER_PREFIX_RE = re.compile(
|
| 59 |
+
r"^(?:(?:the\s+)?(?:final\s+)?(?:answer|response|result)\s*(?:(?:is|=)\s*|:\s*)|(?:final\s+)?answer\s*-\s*)",
|
| 60 |
+
re.I,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 65 |
+
if not path.is_file():
|
| 66 |
+
raise FileNotFoundError(path)
|
| 67 |
+
rows: list[dict[str, Any]] = []
|
| 68 |
+
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
| 69 |
+
if not line.strip():
|
| 70 |
+
continue
|
| 71 |
+
value = json.loads(line)
|
| 72 |
+
if not isinstance(value, dict):
|
| 73 |
+
raise ValueError(f"{path}:{line_number} is not a JSON object")
|
| 74 |
+
rows.append(value)
|
| 75 |
+
return rows
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def write_json(path: Path, value: Any) -> None:
|
| 79 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def write_jsonl(path: Path, rows: Sequence[Mapping[str, Any]]) -> None:
|
| 84 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
path.write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), encoding="utf-8")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def resolve_path(value: str) -> Path:
|
| 89 |
+
path = Path(value)
|
| 90 |
+
return path if path.is_absolute() else PROJECT_ROOT / path
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def row_id(row: Mapping[str, Any]) -> str:
|
| 94 |
+
value = row.get("id")
|
| 95 |
+
if value is None:
|
| 96 |
+
nested = row.get("segment_metadata")
|
| 97 |
+
if isinstance(nested, Mapping):
|
| 98 |
+
value = nested.get("id")
|
| 99 |
+
if value is None:
|
| 100 |
+
raise ValueError("row is missing id and segment_metadata.id")
|
| 101 |
+
return str(value)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def answer_format(row: Mapping[str, Any]) -> str | None:
|
| 105 |
+
value = row.get("answer_format")
|
| 106 |
+
if value is None:
|
| 107 |
+
nested = row.get("segment_metadata")
|
| 108 |
+
if isinstance(nested, Mapping):
|
| 109 |
+
value = nested.get("answer_format")
|
| 110 |
+
if value is None:
|
| 111 |
+
return None
|
| 112 |
+
value = str(value).strip().lower()
|
| 113 |
+
return value if value in SUPPORTED_FORMATS else value
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def select_target_rows(
|
| 117 |
+
rows: list[dict[str, Any]], smoke: int | None = None
|
| 118 |
+
) -> list[dict[str, Any]]:
|
| 119 |
+
"""Select all formats, or one deterministic row per format plus fill."""
|
| 120 |
+
|
| 121 |
+
candidates = [row for row in rows if answer_format(row) in FORMAT_ORDER]
|
| 122 |
+
if smoke is None:
|
| 123 |
+
return candidates
|
| 124 |
+
if smoke < 1:
|
| 125 |
+
raise ValueError("--smoke must be positive")
|
| 126 |
+
if smoke < len(FORMAT_ORDER):
|
| 127 |
+
raise ValueError(f"--smoke={smoke} is smaller than seven format strata")
|
| 128 |
+
if smoke > len(candidates):
|
| 129 |
+
raise ValueError(f"--smoke={smoke} exceeds candidate rows={len(candidates)}")
|
| 130 |
+
selected: list[dict[str, Any]] = []
|
| 131 |
+
selected_ids: set[str] = set()
|
| 132 |
+
for fmt in FORMAT_ORDER:
|
| 133 |
+
for row in candidates:
|
| 134 |
+
if answer_format(row) == fmt:
|
| 135 |
+
selected.append(row)
|
| 136 |
+
selected_ids.add(row_id(row))
|
| 137 |
+
break
|
| 138 |
+
else:
|
| 139 |
+
raise ValueError(f"smoke target coverage missing answer_format={fmt}")
|
| 140 |
+
for row in candidates:
|
| 141 |
+
if len(selected) >= smoke:
|
| 142 |
+
break
|
| 143 |
+
if row_id(row) not in selected_ids:
|
| 144 |
+
selected.append(row)
|
| 145 |
+
selected_ids.add(row_id(row))
|
| 146 |
+
return selected
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _clean_text(value: Any, remove_prefix: bool = True) -> str:
|
| 150 |
+
text = _THINK_RE.sub("", str(value if value is not None else ""))
|
| 151 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 152 |
+
if remove_prefix:
|
| 153 |
+
for _ in range(3):
|
| 154 |
+
match = _ANSWER_PREFIX_RE.match(text)
|
| 155 |
+
if not match:
|
| 156 |
+
break
|
| 157 |
+
text = text[match.end() :].strip()
|
| 158 |
+
return text
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _canonical_decimal(value: Decimal) -> str:
|
| 162 |
+
if not value.is_finite():
|
| 163 |
+
raise ValueError("non-finite number")
|
| 164 |
+
if value == 0:
|
| 165 |
+
return "0"
|
| 166 |
+
rendered = format(value, "f")
|
| 167 |
+
if "." in rendered:
|
| 168 |
+
rendered = rendered.rstrip("0").rstrip(".")
|
| 169 |
+
if rendered.startswith("+"):
|
| 170 |
+
rendered = rendered[1:]
|
| 171 |
+
return rendered
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def parse_strict_number(value: Any, percentage: bool = False) -> Decimal | None:
|
| 175 |
+
"""Parse one complete numeric answer, rejecting prose and extra tokens."""
|
| 176 |
+
|
| 177 |
+
text = _clean_text(value)
|
| 178 |
+
if not text or " " in text or "," in text:
|
| 179 |
+
return None
|
| 180 |
+
if percentage:
|
| 181 |
+
if _PERCENT_RE.fullmatch(text):
|
| 182 |
+
text = text[:-1]
|
| 183 |
+
elif not _NUMBER_RE.fullmatch(text):
|
| 184 |
+
return None
|
| 185 |
+
elif not _NUMBER_RE.fullmatch(text) or "%" in text:
|
| 186 |
+
return None
|
| 187 |
+
try:
|
| 188 |
+
number = Decimal(text)
|
| 189 |
+
except (InvalidOperation, ValueError):
|
| 190 |
+
return None
|
| 191 |
+
return number if number.is_finite() else None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def parse_strict_time_list(value: Any) -> list[int] | None:
|
| 195 |
+
"""Parse an exact comma-separated HMS list into seconds."""
|
| 196 |
+
|
| 197 |
+
text = _clean_text(value)
|
| 198 |
+
if not text:
|
| 199 |
+
return None
|
| 200 |
+
parts = [part.strip() for part in text.split(",")]
|
| 201 |
+
if any(not part or _HMS_RE.fullmatch(part) is None for part in parts):
|
| 202 |
+
return None
|
| 203 |
+
seconds: list[int] = []
|
| 204 |
+
for part in parts:
|
| 205 |
+
match = _HMS_RE.fullmatch(part)
|
| 206 |
+
assert match is not None
|
| 207 |
+
hours, minutes, remainder = part.split(":")
|
| 208 |
+
minute, second = int(minutes), int(remainder)
|
| 209 |
+
if minute >= 60 or second >= 60:
|
| 210 |
+
return None
|
| 211 |
+
seconds.append(int(hours) * 3600 + minute * 60 + second)
|
| 212 |
+
return seconds
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def format_hms(seconds: int) -> str:
|
| 216 |
+
if seconds < 0:
|
| 217 |
+
raise ValueError("HMS seconds must be non-negative")
|
| 218 |
+
return f"{seconds // 3600:02d}:{(seconds % 3600) // 60:02d}:{seconds % 60:02d}"
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def normalize_for_majority(row: Mapping[str, Any], raw: Any) -> str | None:
|
| 222 |
+
"""Normalize one sample without using references; ``None`` means invalid."""
|
| 223 |
+
|
| 224 |
+
fmt = answer_format(row)
|
| 225 |
+
if fmt not in FORMAT_ORDER:
|
| 226 |
+
return None
|
| 227 |
+
if fmt == "binary":
|
| 228 |
+
text = _clean_text(raw).lower().strip(" .!?;,:")
|
| 229 |
+
return text if text in {"yes", "no"} else None
|
| 230 |
+
if fmt in {"number", "percentage"}:
|
| 231 |
+
parsed = parse_strict_number(raw, percentage=fmt == "percentage")
|
| 232 |
+
return _canonical_decimal(parsed) if parsed is not None else None
|
| 233 |
+
if fmt == "time":
|
| 234 |
+
parsed = parse_strict_time_list(raw)
|
| 235 |
+
return ", ".join(format_hms(value) for value in parsed) if parsed is not None else None
|
| 236 |
+
normalized = normalize_prediction(row, raw)
|
| 237 |
+
text = _clean_text(normalized, remove_prefix=False)
|
| 238 |
+
if not text:
|
| 239 |
+
return None
|
| 240 |
+
if fmt == "fo_class":
|
| 241 |
+
allowed = {label.casefold(): label for label in FOREIGN_OBJECT_CLASSES}
|
| 242 |
+
if text.casefold() == "none":
|
| 243 |
+
return "none"
|
| 244 |
+
values = [part.strip() for part in text.split(",") if part.strip()]
|
| 245 |
+
canonical: list[str] = []
|
| 246 |
+
for value in values:
|
| 247 |
+
label = allowed.get(value.casefold())
|
| 248 |
+
if label is None or label in canonical:
|
| 249 |
+
return None
|
| 250 |
+
canonical.append(label)
|
| 251 |
+
return ", ".join(canonical) if canonical else None
|
| 252 |
+
return text
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def aggregate_numeric(
|
| 256 |
+
row: Mapping[str, Any], raw_predictions: Sequence[Any]
|
| 257 |
+
) -> tuple[str | None, dict[str, Any]]:
|
| 258 |
+
fmt = answer_format(row)
|
| 259 |
+
if fmt not in {"number", "percentage"}:
|
| 260 |
+
raise ValueError(f"numeric aggregation requires number/percentage, got {fmt!r}")
|
| 261 |
+
parsed = [parse_strict_number(value, percentage=fmt == "percentage") for value in raw_predictions]
|
| 262 |
+
normalized = [None if value is None else _canonical_decimal(value) for value in parsed]
|
| 263 |
+
if len(parsed) != 3 or any(value is None for value in parsed):
|
| 264 |
+
return None, {"parse": normalized, "aggregation": None, "fallback": True, "fallback_reason": "strict_numeric_parse_failure"}
|
| 265 |
+
ordered = sorted(value for value in parsed if value is not None)
|
| 266 |
+
median = ordered[1]
|
| 267 |
+
return _canonical_decimal(median), {
|
| 268 |
+
"parse": normalized,
|
| 269 |
+
"aggregation": "median",
|
| 270 |
+
"aggregated_value": _canonical_decimal(median),
|
| 271 |
+
"fallback": False,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def aggregate_time(
|
| 276 |
+
raw_predictions: Sequence[Any],
|
| 277 |
+
) -> tuple[str | None, dict[str, Any]]:
|
| 278 |
+
parsed = [parse_strict_time_list(value) for value in raw_predictions]
|
| 279 |
+
normalized = [None if values is None else [format_hms(value) for value in values] for values in parsed]
|
| 280 |
+
if len(parsed) != 3 or any(values is None for values in parsed):
|
| 281 |
+
return None, {"parse": normalized, "aggregation": None, "fallback": True, "fallback_reason": "strict_time_parse_failure"}
|
| 282 |
+
lengths = {len(values) for values in parsed if values is not None}
|
| 283 |
+
if len(lengths) != 1:
|
| 284 |
+
return None, {"parse": normalized, "aggregation": None, "fallback": True, "fallback_reason": "time_point_count_mismatch"}
|
| 285 |
+
count = next(iter(lengths))
|
| 286 |
+
medians: list[str] = []
|
| 287 |
+
for index in range(count):
|
| 288 |
+
values = sorted(values[index] for values in parsed if values is not None)
|
| 289 |
+
medians.append(format_hms(values[1]))
|
| 290 |
+
return ", ".join(medians), {
|
| 291 |
+
"parse": normalized,
|
| 292 |
+
"aggregation": "per_position_median",
|
| 293 |
+
"aggregated_value": ", ".join(medians),
|
| 294 |
+
"fallback": False,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def aggregate_majority(
|
| 299 |
+
row: Mapping[str, Any], raw_predictions: Sequence[Any], baseline_raw: Any
|
| 300 |
+
) -> tuple[str, dict[str, Any]]:
|
| 301 |
+
normalized = [normalize_for_majority(row, value) for value in raw_predictions]
|
| 302 |
+
valid = [value for value in normalized if value is not None]
|
| 303 |
+
counts = Counter(valid)
|
| 304 |
+
winner: str | None = None
|
| 305 |
+
if counts:
|
| 306 |
+
candidate, count = counts.most_common(1)[0]
|
| 307 |
+
if count >= 2:
|
| 308 |
+
winner = candidate
|
| 309 |
+
baseline_normalized = normalize_for_majority(row, baseline_raw)
|
| 310 |
+
selected = winner if winner is not None else str(baseline_raw if baseline_raw is not None else "")
|
| 311 |
+
return selected, {
|
| 312 |
+
"normalized": normalized,
|
| 313 |
+
"baseline_normalized": baseline_normalized,
|
| 314 |
+
"votes": dict(counts),
|
| 315 |
+
"aggregation": "majority" if winner is not None else "baseline",
|
| 316 |
+
"selected": "sample_majority" if winner is not None else "baseline",
|
| 317 |
+
"fallback": winner is None,
|
| 318 |
+
"fallback_reason": None if winner is not None else "no_two_vote_majority",
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def aggregate_prediction(
|
| 323 |
+
row: Mapping[str, Any], raw_predictions: Sequence[Any], baseline_raw: Any
|
| 324 |
+
) -> tuple[str, dict[str, Any]]:
|
| 325 |
+
"""Reduce A/B/C samples according to the frozen format protocol."""
|
| 326 |
+
|
| 327 |
+
fmt = answer_format(row)
|
| 328 |
+
if fmt in {"number", "percentage"}:
|
| 329 |
+
final, audit = aggregate_numeric(row, raw_predictions)
|
| 330 |
+
if final is None:
|
| 331 |
+
final = str(baseline_raw if baseline_raw is not None else "")
|
| 332 |
+
audit.update(
|
| 333 |
+
{
|
| 334 |
+
"selected": "baseline",
|
| 335 |
+
"fallback": True,
|
| 336 |
+
"fallback_reason": audit.get("fallback_reason", "numeric_fallback"),
|
| 337 |
+
}
|
| 338 |
+
)
|
| 339 |
+
else:
|
| 340 |
+
audit["selected"] = "median"
|
| 341 |
+
audit["raw"] = [str(value) for value in raw_predictions]
|
| 342 |
+
return final, audit
|
| 343 |
+
if fmt == "time":
|
| 344 |
+
final, audit = aggregate_time(raw_predictions)
|
| 345 |
+
if final is None:
|
| 346 |
+
final = str(baseline_raw if baseline_raw is not None else "")
|
| 347 |
+
audit.update({"selected": "baseline", "fallback": True})
|
| 348 |
+
else:
|
| 349 |
+
audit["selected"] = "per_position_median"
|
| 350 |
+
audit["raw"] = [str(value) for value in raw_predictions]
|
| 351 |
+
return final, audit
|
| 352 |
+
final, audit = aggregate_majority(row, raw_predictions, baseline_raw)
|
| 353 |
+
audit["raw"] = [str(value) for value in raw_predictions]
|
| 354 |
+
return final, audit
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def merge_predictions(
|
| 358 |
+
rows: list[dict[str, Any]],
|
| 359 |
+
baseline_by_id: Mapping[str, Mapping[str, Any]],
|
| 360 |
+
generated_by_id: Mapping[str, Mapping[str, Any]],
|
| 361 |
+
selected_ids: set[str] | None = None,
|
| 362 |
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
| 363 |
+
selected = selected_ids if selected_ids is not None else {row_id(row) for row in rows}
|
| 364 |
+
merged: list[dict[str, Any]] = []
|
| 365 |
+
audit_rows: list[dict[str, Any]] = []
|
| 366 |
+
for row in rows:
|
| 367 |
+
rid = row_id(row)
|
| 368 |
+
if rid not in baseline_by_id:
|
| 369 |
+
raise ValueError(f"baseline missing id={rid}")
|
| 370 |
+
baseline = copy.deepcopy(dict(baseline_by_id[rid]))
|
| 371 |
+
generated = generated_by_id.get(rid) if rid in selected else None
|
| 372 |
+
output = copy.deepcopy(baseline)
|
| 373 |
+
audit: dict[str, Any] = {
|
| 374 |
+
"id": rid,
|
| 375 |
+
"target": rid in selected,
|
| 376 |
+
"source": "frozen_baseline",
|
| 377 |
+
"baseline_prediction": baseline.get("prediction", ""),
|
| 378 |
+
"changed_vs_baseline": False,
|
| 379 |
+
"error": None,
|
| 380 |
+
}
|
| 381 |
+
if rid in selected:
|
| 382 |
+
if generated is None or generated.get("error"):
|
| 383 |
+
audit["error"] = (generated or {}).get("error", "missing_generated_row")
|
| 384 |
+
if generated and isinstance(generated.get("audit"), Mapping):
|
| 385 |
+
audit.update(generated["audit"])
|
| 386 |
+
else:
|
| 387 |
+
output.update(
|
| 388 |
+
{
|
| 389 |
+
"prediction": generated.get("prediction", ""),
|
| 390 |
+
"tf_trick": "tf009",
|
| 391 |
+
"tf_raw_predictions": generated.get("raw_predictions", []),
|
| 392 |
+
"generation_seeds": list(SEEDS),
|
| 393 |
+
"generation_temperature": TEMPERATURE,
|
| 394 |
+
"generation_top_p": TOP_P,
|
| 395 |
+
"generation_max_new_tokens": MAX_NEW_TOKENS,
|
| 396 |
+
}
|
| 397 |
+
)
|
| 398 |
+
if "latency" in baseline:
|
| 399 |
+
output["baseline_latency"] = baseline["latency"]
|
| 400 |
+
if generated.get("latency") is not None:
|
| 401 |
+
output["generation_latency"] = generated["latency"]
|
| 402 |
+
if isinstance(generated.get("audit"), Mapping):
|
| 403 |
+
audit.update(generated["audit"])
|
| 404 |
+
audit["source"] = "self_consistency"
|
| 405 |
+
audit["changed_vs_baseline"] = output.get("prediction") != baseline.get("prediction")
|
| 406 |
+
audit["final_prediction"] = output.get("prediction", "")
|
| 407 |
+
merged.append(output)
|
| 408 |
+
audit_rows.append(audit)
|
| 409 |
+
return merged, audit_rows
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
class _RunLog:
|
| 413 |
+
def __init__(self, path: Path) -> None:
|
| 414 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 415 |
+
self.handle = path.open("a", encoding="utf-8")
|
| 416 |
+
|
| 417 |
+
def write(self, message: str) -> None:
|
| 418 |
+
line = message.rstrip("\n")
|
| 419 |
+
print(line, flush=True)
|
| 420 |
+
self.handle.write(line + "\n")
|
| 421 |
+
self.handle.flush()
|
| 422 |
+
|
| 423 |
+
def close(self) -> None:
|
| 424 |
+
self.handle.close()
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _git_state() -> tuple[str, bool, list[str]]:
|
| 428 |
+
commit = subprocess.run(
|
| 429 |
+
["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT, text=True, capture_output=True, check=False
|
| 430 |
+
).stdout.strip() or "unknown"
|
| 431 |
+
status = subprocess.run(
|
| 432 |
+
["git", "status", "--porcelain"], cwd=PROJECT_ROOT, text=True, capture_output=True, check=False
|
| 433 |
+
).stdout
|
| 434 |
+
files = [line[3:] if len(line) >= 3 else line for line in status.splitlines() if line]
|
| 435 |
+
return commit, bool(files), files
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _load_rows(path: Path, nframes: int) -> list[dict[str, Any]]:
|
| 439 |
+
from eval_segmentqa_vllm import load_fixed80_absmeta_requests
|
| 440 |
+
|
| 441 |
+
return load_fixed80_absmeta_requests(path, nframes)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _validate_model(model: Path) -> None:
|
| 445 |
+
if model.name != "merged_checkpoint-6013":
|
| 446 |
+
raise ValueError(f"TF009 requires merged Tr006 checkpoint-6013, got {model}")
|
| 447 |
+
if not model.is_dir():
|
| 448 |
+
raise FileNotFoundError(model)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _prepare_request(row: Mapping[str, Any], tokenizer: Any, nframes: int) -> dict[str, Any]:
|
| 452 |
+
from eval_segmentqa_vllm import fixed_absmeta_frames, make_messages
|
| 453 |
+
|
| 454 |
+
metadata = (row.get("chat_template_kwargs") or {}).get("video_metadata")
|
| 455 |
+
frames, metadata = fixed_absmeta_frames(Path(row["videos"][0]), metadata, nframes)
|
| 456 |
+
prompt = tokenizer.apply_chat_template(
|
| 457 |
+
make_messages(dict(row)), tokenize=False, add_generation_prompt=True, enable_thinking=False
|
| 458 |
+
)
|
| 459 |
+
return {"prompt": prompt, "multi_modal_data": {"video": (frames, metadata)}}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 463 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 464 |
+
parser.add_argument("--trick", choices=("tf009",), required=True)
|
| 465 |
+
parser.add_argument("--model", required=True, help="merged Tr006 checkpoint-6013")
|
| 466 |
+
parser.add_argument("--dataset-jsonl", required=True, help="fixed80 absmeta test_requests.jsonl")
|
| 467 |
+
parser.add_argument("--baseline-raw", required=True, help="frozen checkpoint-6013 raw_predictions.jsonl")
|
| 468 |
+
parser.add_argument("--output", required=True, help="RUN_DIR")
|
| 469 |
+
parser.add_argument("--results-subdir", action="store_true")
|
| 470 |
+
parser.add_argument("--run-id", default=None)
|
| 471 |
+
parser.add_argument("--nframes", type=int, default=DEFAULT_NFRAMES)
|
| 472 |
+
parser.add_argument("--tensor-parallel-size", type=int, default=2)
|
| 473 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 474 |
+
parser.add_argument("--max-model-len", type=int, default=16384)
|
| 475 |
+
parser.add_argument("--max-new-tokens", type=int, default=MAX_NEW_TOKENS)
|
| 476 |
+
parser.add_argument("--gpu-memory-utilization", type=float, default=0.9)
|
| 477 |
+
parser.add_argument("--max-num-seqs", type=int, default=32)
|
| 478 |
+
parser.add_argument("--decode-workers", type=int, default=4)
|
| 479 |
+
parser.add_argument("--smoke", type=int, help="generate N format-stratified rows, then merge all baseline rows")
|
| 480 |
+
return parser
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def run(args: argparse.Namespace) -> int:
|
| 484 |
+
if args.nframes != DEFAULT_NFRAMES:
|
| 485 |
+
raise ValueError(f"TF009 fixed-frame protocol requires nframes={DEFAULT_NFRAMES}")
|
| 486 |
+
if args.max_new_tokens != MAX_NEW_TOKENS:
|
| 487 |
+
raise ValueError(f"TF009 fixed decode protocol requires max_new_tokens={MAX_NEW_TOKENS}")
|
| 488 |
+
out = resolve_path(args.output)
|
| 489 |
+
if any(part.lower().startswith("tf001") or "tf001_" in part.lower() for part in out.parts):
|
| 490 |
+
raise ValueError(f"refusing to write inside a TF001 output path: {out}")
|
| 491 |
+
results = out / "results" if args.results_subdir else out
|
| 492 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 493 |
+
results.mkdir(parents=True, exist_ok=True)
|
| 494 |
+
logger = _RunLog(out / "run.log")
|
| 495 |
+
run_id = args.run_id or "TF009"
|
| 496 |
+
started = time.time()
|
| 497 |
+
commit, dirty, modified_files = _git_state()
|
| 498 |
+
runmeta_path = out / "run.json"
|
| 499 |
+
runmeta: dict[str, Any] = {}
|
| 500 |
+
try:
|
| 501 |
+
if runmeta_path.exists():
|
| 502 |
+
try:
|
| 503 |
+
runmeta = json.loads(runmeta_path.read_text(encoding="utf-8"))
|
| 504 |
+
except json.JSONDecodeError:
|
| 505 |
+
runmeta = {}
|
| 506 |
+
runmeta.update(
|
| 507 |
+
{
|
| 508 |
+
"run": {
|
| 509 |
+
**runmeta.get("run", {}),
|
| 510 |
+
"id": run_id,
|
| 511 |
+
"name": out.name,
|
| 512 |
+
"task_type": "debug" if args.smoke is not None else "test",
|
| 513 |
+
"trick": "tf009",
|
| 514 |
+
"status": "running",
|
| 515 |
+
},
|
| 516 |
+
"code": {
|
| 517 |
+
**runmeta.get("code", {}),
|
| 518 |
+
"commit": commit,
|
| 519 |
+
"dirty": dirty,
|
| 520 |
+
"modified_files": modified_files,
|
| 521 |
+
"entrypoint": "scripts/tf009_self_consistency.py",
|
| 522 |
+
},
|
| 523 |
+
"runtime": {
|
| 524 |
+
**runmeta.get("runtime", {}),
|
| 525 |
+
"python": sys.executable,
|
| 526 |
+
"host": platform.node(),
|
| 527 |
+
"launcher": "caller-managed tmux/tee; no launcher in runner",
|
| 528 |
+
"devices": ["GPU0", "GPU1"],
|
| 529 |
+
"cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES", "unknown"),
|
| 530 |
+
"tensor_parallel_size": args.tensor_parallel_size,
|
| 531 |
+
"precision": "bfloat16",
|
| 532 |
+
},
|
| 533 |
+
"execution": {
|
| 534 |
+
**runmeta.get("execution", {}),
|
| 535 |
+
"argv": sys.argv,
|
| 536 |
+
"command": shlex.join([sys.executable, *sys.argv]),
|
| 537 |
+
"seeds": list(SEEDS),
|
| 538 |
+
"temperature": TEMPERATURE,
|
| 539 |
+
"top_p": TOP_P,
|
| 540 |
+
"max_new_tokens": MAX_NEW_TOKENS,
|
| 541 |
+
"nframes": args.nframes,
|
| 542 |
+
"batch_size": args.batch_size,
|
| 543 |
+
"max_model_len": args.max_model_len,
|
| 544 |
+
"max_num_seqs": args.max_num_seqs,
|
| 545 |
+
"decode_workers": args.decode_workers,
|
| 546 |
+
"only_variable": "stochastic sampling policy + format-aware aggregation",
|
| 547 |
+
"reference_read_for_selection": False,
|
| 548 |
+
},
|
| 549 |
+
"tracking": {"backend": "none (analysis-only test-time run)", "location": None},
|
| 550 |
+
"time": {"started_at": started, "finished_at": None},
|
| 551 |
+
"issues": sorted(set(runmeta.get("issues", [])) | {"analysis-only", "test-reuse", "no-reference-selection"}),
|
| 552 |
+
}
|
| 553 |
+
)
|
| 554 |
+
write_json(runmeta_path, runmeta)
|
| 555 |
+
logger.write(
|
| 556 |
+
f"START trick=TF009 run_id={run_id} host={platform.node()} python={sys.executable} "
|
| 557 |
+
f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', 'unknown')}"
|
| 558 |
+
)
|
| 559 |
+
logger.write(f"model={args.model} dataset={args.dataset_jsonl} baseline={args.baseline_raw}")
|
| 560 |
+
logger.write(f"seeds={list(SEEDS)} temperature={TEMPERATURE} top_p={TOP_P} max_new_tokens={MAX_NEW_TOKENS}")
|
| 561 |
+
|
| 562 |
+
model = resolve_path(args.model)
|
| 563 |
+
_validate_model(model)
|
| 564 |
+
dataset_path = resolve_path(args.dataset_jsonl)
|
| 565 |
+
baseline_path = resolve_path(args.baseline_raw)
|
| 566 |
+
rows = _load_rows(dataset_path, args.nframes)
|
| 567 |
+
baseline_rows = read_jsonl(baseline_path)
|
| 568 |
+
baseline_by_id: dict[str, dict[str, Any]] = {}
|
| 569 |
+
for item in baseline_rows:
|
| 570 |
+
rid = row_id(item)
|
| 571 |
+
if rid in baseline_by_id:
|
| 572 |
+
raise ValueError(f"duplicate baseline id={rid}")
|
| 573 |
+
baseline_by_id[rid] = item
|
| 574 |
+
request_ids = [row_id(row) for row in rows]
|
| 575 |
+
if len(request_ids) != len(set(request_ids)):
|
| 576 |
+
raise ValueError("requests contain duplicate IDs")
|
| 577 |
+
missing = [rid for rid in request_ids if rid not in baseline_by_id]
|
| 578 |
+
if missing:
|
| 579 |
+
raise ValueError(f"baseline missing {len(missing)} request IDs")
|
| 580 |
+
target_rows = select_target_rows(rows, args.smoke)
|
| 581 |
+
target_ids = {row_id(row) for row in target_rows}
|
| 582 |
+
runmeta["inputs"] = {
|
| 583 |
+
"model": str(model),
|
| 584 |
+
"requests": str(dataset_path),
|
| 585 |
+
"baseline_raw_predictions": str(baseline_path),
|
| 586 |
+
"split": "test",
|
| 587 |
+
"count": len(rows),
|
| 588 |
+
"expected_official_count": EXPECTED_OFFICIAL_COUNT,
|
| 589 |
+
"target_count": len([row for row in rows if answer_format(row) in FORMAT_ORDER]),
|
| 590 |
+
"generated_target_count": len(target_rows),
|
| 591 |
+
"reference_read_for_selection": False,
|
| 592 |
+
}
|
| 593 |
+
write_json(runmeta_path, runmeta)
|
| 594 |
+
logger.write(f"loaded rows={len(rows)} baseline={len(baseline_rows)} selected_targets={len(target_rows)}")
|
| 595 |
+
|
| 596 |
+
from vllm import LLM, SamplingParams
|
| 597 |
+
|
| 598 |
+
llm = LLM(
|
| 599 |
+
model=str(model),
|
| 600 |
+
dtype="bfloat16",
|
| 601 |
+
tensor_parallel_size=args.tensor_parallel_size,
|
| 602 |
+
trust_remote_code=True,
|
| 603 |
+
limit_mm_per_prompt={"video": 1},
|
| 604 |
+
max_model_len=args.max_model_len,
|
| 605 |
+
max_num_seqs=args.max_num_seqs,
|
| 606 |
+
gpu_memory_utilization=args.gpu_memory_utilization,
|
| 607 |
+
)
|
| 608 |
+
tokenizer = llm.get_tokenizer()
|
| 609 |
+
rows_by_id = {row_id(row): row for row in target_rows}
|
| 610 |
+
prepared: dict[str, dict[str, Any]] = {}
|
| 611 |
+
decode_workers = max(1, args.decode_workers)
|
| 612 |
+
with ThreadPoolExecutor(max_workers=decode_workers) as pool:
|
| 613 |
+
futures = {rid: pool.submit(_prepare_request, row, tokenizer, args.nframes) for rid, row in rows_by_id.items()}
|
| 614 |
+
for rid, future in futures.items():
|
| 615 |
+
prepared[rid] = future.result()
|
| 616 |
+
|
| 617 |
+
raw_samples: dict[str, list[str]] = {rid: [] for rid in rows_by_id}
|
| 618 |
+
latencies: dict[str, list[float]] = {rid: [] for rid in rows_by_id}
|
| 619 |
+
generation_errors: dict[str, str] = {}
|
| 620 |
+
batch_size = max(1, args.batch_size)
|
| 621 |
+
for seed_index, seed in enumerate(SEEDS):
|
| 622 |
+
sampling = SamplingParams(
|
| 623 |
+
max_tokens=MAX_NEW_TOKENS,
|
| 624 |
+
temperature=TEMPERATURE,
|
| 625 |
+
top_p=TOP_P,
|
| 626 |
+
seed=seed,
|
| 627 |
+
)
|
| 628 |
+
for start in range(0, len(target_rows), batch_size):
|
| 629 |
+
batch = target_rows[start : start + batch_size]
|
| 630 |
+
requests = [prepared[row_id(row)] for row in batch]
|
| 631 |
+
try:
|
| 632 |
+
generation_started = time.perf_counter()
|
| 633 |
+
outputs = llm.generate(requests, sampling)
|
| 634 |
+
elapsed = time.perf_counter() - generation_started
|
| 635 |
+
if len(outputs) != len(batch):
|
| 636 |
+
raise RuntimeError(f"vLLM returned {len(outputs)} outputs for {len(batch)} requests")
|
| 637 |
+
for row, result in zip(batch, outputs):
|
| 638 |
+
values = getattr(result, "outputs", None) or []
|
| 639 |
+
if not values:
|
| 640 |
+
raise RuntimeError(f"empty vLLM output for id={row_id(row)} seed={seed}")
|
| 641 |
+
output = values[0]
|
| 642 |
+
rid = row_id(row)
|
| 643 |
+
raw_samples[rid].append(str(getattr(output, "text", "")))
|
| 644 |
+
latencies[rid].append(elapsed / len(batch))
|
| 645 |
+
except Exception as exc:
|
| 646 |
+
error = repr(exc)
|
| 647 |
+
logger.write(f"generation_error seed={seed} batch_start={start} error={error}")
|
| 648 |
+
for row in batch:
|
| 649 |
+
rid = row_id(row)
|
| 650 |
+
generation_errors[rid] = error
|
| 651 |
+
raw_samples[rid].append("")
|
| 652 |
+
latencies[rid].append(0.0)
|
| 653 |
+
logger.write(f"completed seed={seed} ({seed_index + 1}/{len(SEEDS)})")
|
| 654 |
+
|
| 655 |
+
generated_by_id: dict[str, dict[str, Any]] = {}
|
| 656 |
+
for rid, row in rows_by_id.items():
|
| 657 |
+
baseline_raw = baseline_by_id[rid].get("prediction", "")
|
| 658 |
+
if rid in generation_errors:
|
| 659 |
+
generated_by_id[rid] = {
|
| 660 |
+
"id": rid,
|
| 661 |
+
"error": generation_errors[rid],
|
| 662 |
+
"audit": {
|
| 663 |
+
"views": {label: {"seed": seed, "raw": raw, "normalized": None} for label, seed, raw in zip(("A", "B", "C"), SEEDS, raw_samples[rid])},
|
| 664 |
+
"selected": "baseline",
|
| 665 |
+
"fallback": True,
|
| 666 |
+
"fallback_reason": "generation_error",
|
| 667 |
+
},
|
| 668 |
+
}
|
| 669 |
+
continue
|
| 670 |
+
final, aggregation_audit = aggregate_prediction(row, raw_samples[rid], baseline_raw)
|
| 671 |
+
normalized = aggregation_audit.get("normalized")
|
| 672 |
+
if normalized is None:
|
| 673 |
+
normalized = [normalize_for_majority(row, raw) for raw in raw_samples[rid]]
|
| 674 |
+
views = {
|
| 675 |
+
label: {"seed": seed, "raw": raw, "normalized": normalized[index] if index < len(normalized) else None}
|
| 676 |
+
for index, (label, seed, raw) in enumerate(zip(("A", "B", "C"), SEEDS, raw_samples[rid]))
|
| 677 |
+
}
|
| 678 |
+
audit = {"views": views, **aggregation_audit}
|
| 679 |
+
generated_by_id[rid] = {
|
| 680 |
+
"id": rid,
|
| 681 |
+
"prediction": final,
|
| 682 |
+
"raw_predictions": raw_samples[rid],
|
| 683 |
+
"latency": sum(latencies[rid]) / len(latencies[rid]) if latencies[rid] else None,
|
| 684 |
+
"audit": audit,
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
merged, audit_rows = merge_predictions(rows, baseline_by_id, generated_by_id, target_ids)
|
| 688 |
+
raw_path = results / "raw_predictions.jsonl"
|
| 689 |
+
write_jsonl(raw_path, merged)
|
| 690 |
+
audit_path = results / "prepost_audit.jsonl"
|
| 691 |
+
write_jsonl(audit_path, audit_rows)
|
| 692 |
+
write_json(
|
| 693 |
+
results / "answer.json",
|
| 694 |
+
[{"qID": row_id(item), "content": _clean_text(item.get("prediction", ""), remove_prefix=False), "latency": item.get("latency")} for item in merged],
|
| 695 |
+
)
|
| 696 |
+
expected = set(request_ids)
|
| 697 |
+
actual_ids = [row_id(item) for item in merged]
|
| 698 |
+
actual = set(actual_ids)
|
| 699 |
+
errors = [item for item in audit_rows if item.get("error")]
|
| 700 |
+
changed = [item for item in audit_rows if item.get("changed_vs_baseline")]
|
| 701 |
+
target_audits = [item for item in audit_rows if item.get("target")]
|
| 702 |
+
valid_samples = 0
|
| 703 |
+
total_samples = 3 * len(target_audits)
|
| 704 |
+
for item in target_audits:
|
| 705 |
+
views = item.get("views") or {}
|
| 706 |
+
valid_samples += sum(views.get(label, {}).get("normalized") is not None for label in ("A", "B", "C"))
|
| 707 |
+
integrity = {
|
| 708 |
+
"predictions": len(merged),
|
| 709 |
+
"unique_ids": len(actual),
|
| 710 |
+
"missing": len(expected - actual),
|
| 711 |
+
"duplicate": len(actual_ids) - len(actual),
|
| 712 |
+
"unexpected": len(actual - expected),
|
| 713 |
+
"failed": len(errors),
|
| 714 |
+
"skipped": 0,
|
| 715 |
+
"empty_predictions": sum(not str(item.get("prediction", "")).strip() for item in merged),
|
| 716 |
+
"target_rows": len(target_audits),
|
| 717 |
+
"generated_target_rows": sum(item.get("source") == "self_consistency" for item in audit_rows),
|
| 718 |
+
"selected_smoke_target_rows": len(target_ids),
|
| 719 |
+
"baseline_reuse_rows": sum(not item.get("target") for item in audit_rows),
|
| 720 |
+
"changed_vs_baseline": len(changed),
|
| 721 |
+
"fallback_rows": sum(item.get("selected") == "baseline" for item in target_audits),
|
| 722 |
+
"switch_rate": len(changed) / len(target_audits) if target_audits else 0.0,
|
| 723 |
+
"valid_sample_count": valid_samples,
|
| 724 |
+
"sample_count": total_samples,
|
| 725 |
+
"parse_rate": valid_samples / total_samples if total_samples else 0.0,
|
| 726 |
+
"generation_errors": len(errors),
|
| 727 |
+
}
|
| 728 |
+
write_json(results / "integrity.json", integrity)
|
| 729 |
+
metrics = {
|
| 730 |
+
"protocol": "TF009 self-consistency; three seeded samples; no-reference selection",
|
| 731 |
+
"status": "passed"
|
| 732 |
+
if not errors
|
| 733 |
+
and integrity["missing"] == 0
|
| 734 |
+
and integrity["duplicate"] == 0
|
| 735 |
+
and integrity["unexpected"] == 0
|
| 736 |
+
and integrity["empty_predictions"] == 0
|
| 737 |
+
and integrity["generated_target_rows"] == integrity["selected_smoke_target_rows"]
|
| 738 |
+
else "partial",
|
| 739 |
+
"expected_official_count": EXPECTED_OFFICIAL_COUNT,
|
| 740 |
+
"seeds": list(SEEDS),
|
| 741 |
+
"temperature": TEMPERATURE,
|
| 742 |
+
"top_p": TOP_P,
|
| 743 |
+
"max_new_tokens": MAX_NEW_TOKENS,
|
| 744 |
+
"only_variable": "stochastic sampling policy + format-aware aggregation",
|
| 745 |
+
"reference_read_for_selection": False,
|
| 746 |
+
"format_counts": dict(Counter(answer_format(row) for row in rows)),
|
| 747 |
+
"target_rows": integrity["target_rows"],
|
| 748 |
+
"generated_target_rows": integrity["generated_target_rows"],
|
| 749 |
+
"baseline_reuse_rows": integrity["baseline_reuse_rows"],
|
| 750 |
+
"changed_vs_baseline": integrity["changed_vs_baseline"],
|
| 751 |
+
"switch_rate": integrity["switch_rate"],
|
| 752 |
+
"parse_rate": integrity["parse_rate"],
|
| 753 |
+
"fallback_rows": integrity["fallback_rows"],
|
| 754 |
+
"generation_errors": integrity["generation_errors"],
|
| 755 |
+
}
|
| 756 |
+
write_json(results / "metrics.json", metrics)
|
| 757 |
+
status = "completed" if metrics["status"] == "passed" else "failed"
|
| 758 |
+
runmeta["run"]["status"] = status
|
| 759 |
+
runmeta["time"]["finished_at"] = time.time()
|
| 760 |
+
runmeta["outputs"] = {
|
| 761 |
+
"run_dir": str(out),
|
| 762 |
+
"results_dir": str(results),
|
| 763 |
+
"raw_predictions": str(raw_path),
|
| 764 |
+
"answer": str(results / "answer.json"),
|
| 765 |
+
"prepost_audit": str(audit_path),
|
| 766 |
+
"integrity": str(results / "integrity.json"),
|
| 767 |
+
"metrics": str(results / "metrics.json"),
|
| 768 |
+
}
|
| 769 |
+
if errors:
|
| 770 |
+
runmeta.setdefault("issues", []).append("target-generation-error-fallback")
|
| 771 |
+
write_json(runmeta_path, runmeta)
|
| 772 |
+
logger.write(json.dumps({"status": status, "integrity": integrity}, ensure_ascii=False))
|
| 773 |
+
return 0 if status == "completed" else 2
|
| 774 |
+
except BaseException as exc:
|
| 775 |
+
runmeta.setdefault("run", {})["status"] = "failed"
|
| 776 |
+
runmeta.setdefault("time", {})["finished_at"] = time.time()
|
| 777 |
+
runmeta["error"] = repr(exc)
|
| 778 |
+
write_json(runmeta_path, runmeta)
|
| 779 |
+
logger.write(f"FAILED error={exc!r}")
|
| 780 |
+
raise
|
| 781 |
+
finally:
|
| 782 |
+
logger.close()
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def main() -> int:
|
| 786 |
+
return run(build_parser().parse_args())
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
if __name__ == "__main__":
|
| 790 |
+
raise SystemExit(main())
|
pipelines/Tr006_V2/tf009/training_free_postprocess.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Reference-free, deterministic prediction post-processing.
|
| 3 |
+
|
| 4 |
+
The functions in this module deliberately do not inspect references or model
|
| 5 |
+
scores. They only use the request contract (primarily ``answer_format`` and
|
| 6 |
+
the question text) and the raw model output. A transformation is applied
|
| 7 |
+
only when the answer can be parsed without guessing; otherwise the original
|
| 8 |
+
prediction is returned unchanged.
|
| 9 |
+
|
| 10 |
+
The module is intended to be called after generation, before writing the
|
| 11 |
+
official ``answer.json``. It is not a replacement for the official evaluator:
|
| 12 |
+
in particular, it does not apply time tolerances or judge open-ended answers.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from decimal import Decimal, InvalidOperation
|
| 19 |
+
from typing import Any, Mapping
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
SUPPORTED_FORMATS = frozenset(
|
| 23 |
+
{"binary", "number", "percentage", "time", "multiple_choice", "fo_class", "open_ended"}
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# This is the ontology used by the SegmentQA system prompt. We keep the
|
| 27 |
+
# spelling here so that a case-insensitive model response can be rendered in
|
| 28 |
+
# the contract's canonical spelling without consulting test references.
|
| 29 |
+
FOREIGN_OBJECT_CLASSES = (
|
| 30 |
+
"Sponge",
|
| 31 |
+
"Clip",
|
| 32 |
+
"Specimen Bag",
|
| 33 |
+
"Silicone Loop",
|
| 34 |
+
"External Drain",
|
| 35 |
+
"Needle",
|
| 36 |
+
"Gallstone",
|
| 37 |
+
"Specimen",
|
| 38 |
+
"Mesh",
|
| 39 |
+
"Absorbable Hemostatic Agent",
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.IGNORECASE | re.DOTALL)
|
| 43 |
+
_TIME_RE = re.compile(r"(?<![\d:])(?P<h>\d{1,6}):(?P<m>\d{2}):(?P<s>\d{2})(?![\d:])")
|
| 44 |
+
_NUMBER_RE = re.compile(
|
| 45 |
+
r"(?<![\w.])"
|
| 46 |
+
r"(?P<value>[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+))"
|
| 47 |
+
r"(?![\w.])"
|
| 48 |
+
)
|
| 49 |
+
_ANSWER_PREFIX_RE = re.compile(
|
| 50 |
+
r"^(?:(?:the\s+)?(?:final\s+)?(?:answer|response|result)\s*(?:(?:is|=)\s*|:\s*)|"
|
| 51 |
+
r"(?:final\s+)?answer\s*-\s*)",
|
| 52 |
+
re.IGNORECASE,
|
| 53 |
+
)
|
| 54 |
+
_MULTI_CHOICE_RE = re.compile(
|
| 55 |
+
r"\b(?:one\s+or\s+multiple|one\s+or\s+more|multiple\s+answers?|"
|
| 56 |
+
r"all\s+that\s+apply|none\s*,\s*one)\b",
|
| 57 |
+
re.IGNORECASE,
|
| 58 |
+
)
|
| 59 |
+
_OPTION_MARKER_RE = re.compile(
|
| 60 |
+
r"(?:following\s+)?(?:answer\s+)?(?:options?|choices?)\s*(?::|are|include)?\s*(.*)$",
|
| 61 |
+
re.IGNORECASE | re.DOTALL,
|
| 62 |
+
)
|
| 63 |
+
_EXPLANATION_RE = re.compile(
|
| 64 |
+
r"^(?:because|as|since|which|that|where|when|visible|present|shown|"
|
| 65 |
+
r"indicating|indicates|the\s+answer|the\s+correct\s+(?:answer|choice)|"
|
| 66 |
+
r"is|was|are|were)\b",
|
| 67 |
+
re.IGNORECASE,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _as_text(value: Any) -> str:
|
| 72 |
+
"""Return a stable textual representation for a model prediction."""
|
| 73 |
+
|
| 74 |
+
return value if isinstance(value, str) else str(value if value is not None else "")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _parse_text(value: Any) -> str:
|
| 78 |
+
"""Remove completed reasoning blocks and collapse whitespace for parsing."""
|
| 79 |
+
|
| 80 |
+
return re.sub(r"\s+", " ", _THINK_RE.sub("", _as_text(value))).strip()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _answer_format(request: Mapping[str, Any] | None) -> str | None:
|
| 84 |
+
"""Read ``answer_format`` from the row or its common metadata wrappers."""
|
| 85 |
+
|
| 86 |
+
if not isinstance(request, Mapping):
|
| 87 |
+
return None
|
| 88 |
+
candidates: list[Any] = [request.get("answer_format"), request.get("format")]
|
| 89 |
+
for key in ("segment_metadata", "metadata", "request_metadata"):
|
| 90 |
+
nested = request.get(key)
|
| 91 |
+
if isinstance(nested, Mapping):
|
| 92 |
+
candidates.extend((nested.get("answer_format"), nested.get("format")))
|
| 93 |
+
for value in candidates:
|
| 94 |
+
if value is not None:
|
| 95 |
+
text = str(value).strip().lower()
|
| 96 |
+
if text in SUPPORTED_FORMATS:
|
| 97 |
+
return text
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _question_text(request: Mapping[str, Any] | None) -> str:
|
| 102 |
+
if not isinstance(request, Mapping):
|
| 103 |
+
return ""
|
| 104 |
+
values: list[Any] = [request.get("question"), request.get("prompt")]
|
| 105 |
+
for key in ("segment_metadata", "metadata", "request_metadata"):
|
| 106 |
+
nested = request.get(key)
|
| 107 |
+
if isinstance(nested, Mapping):
|
| 108 |
+
values.extend((nested.get("question"), nested.get("prompt")))
|
| 109 |
+
for value in values:
|
| 110 |
+
if isinstance(value, str) and value.strip():
|
| 111 |
+
return value
|
| 112 |
+
# A fixed-frame request may only retain the question in a user message.
|
| 113 |
+
messages = request.get("messages")
|
| 114 |
+
if isinstance(messages, list):
|
| 115 |
+
for message in reversed(messages):
|
| 116 |
+
if isinstance(message, Mapping) and message.get("role") == "user":
|
| 117 |
+
content = message.get("content")
|
| 118 |
+
if isinstance(content, str):
|
| 119 |
+
return content
|
| 120 |
+
if isinstance(content, list):
|
| 121 |
+
pieces = [item.get("text", "") for item in content if isinstance(item, Mapping)]
|
| 122 |
+
text = " ".join(piece for piece in pieces if isinstance(piece, str))
|
| 123 |
+
if text.strip():
|
| 124 |
+
return text
|
| 125 |
+
return ""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _remove_prefix(text: str) -> str:
|
| 129 |
+
"""Strip an explicit answer label, but never infer an answer from prose."""
|
| 130 |
+
|
| 131 |
+
value = text.strip()
|
| 132 |
+
# A model can repeat a label (``Final answer: Answer: yes``); removing at
|
| 133 |
+
# most a few labels is deterministic and still leaves ambiguous prose
|
| 134 |
+
# untouched.
|
| 135 |
+
for _ in range(3):
|
| 136 |
+
match = _ANSWER_PREFIX_RE.match(value)
|
| 137 |
+
if not match:
|
| 138 |
+
break
|
| 139 |
+
value = value[match.end() :].lstrip()
|
| 140 |
+
return value
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _canonical_decimal(raw: str) -> str | None:
|
| 144 |
+
try:
|
| 145 |
+
value = Decimal(raw.replace(",", ""))
|
| 146 |
+
except (InvalidOperation, ValueError):
|
| 147 |
+
return None
|
| 148 |
+
if not value.is_finite():
|
| 149 |
+
return None
|
| 150 |
+
if value == 0:
|
| 151 |
+
return "0"
|
| 152 |
+
rendered = format(value, "f")
|
| 153 |
+
if "." in rendered:
|
| 154 |
+
rendered = rendered.rstrip("0").rstrip(".")
|
| 155 |
+
if rendered.startswith("+"):
|
| 156 |
+
rendered = rendered[1:]
|
| 157 |
+
return rendered
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def normalize_time_output(prediction: Any) -> str:
|
| 161 |
+
"""Normalize one or more ``h:mm:ss`` values without applying an offset.
|
| 162 |
+
|
| 163 |
+
All valid time tokens are extracted in their original order, duplicate
|
| 164 |
+
tokens are removed while retaining the first occurrence, and the result is
|
| 165 |
+
rendered as ``HH:MM:SS`` comma-separated values. If no unambiguous valid
|
| 166 |
+
token exists (including an invalid minute/second), the exact raw text is
|
| 167 |
+
returned. This function intentionally does *not* add the clip start time.
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
raw = _as_text(prediction)
|
| 171 |
+
cleaned = _parse_text(raw)
|
| 172 |
+
matches = list(_TIME_RE.finditer(cleaned))
|
| 173 |
+
if not matches:
|
| 174 |
+
return raw
|
| 175 |
+
|
| 176 |
+
values: list[str] = []
|
| 177 |
+
seen: set[str] = set()
|
| 178 |
+
for match in matches:
|
| 179 |
+
hours = int(match.group("h"))
|
| 180 |
+
minutes = int(match.group("m"))
|
| 181 |
+
seconds = int(match.group("s"))
|
| 182 |
+
if minutes >= 60 or seconds >= 60:
|
| 183 |
+
return raw
|
| 184 |
+
value = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
| 185 |
+
if value not in seen:
|
| 186 |
+
seen.add(value)
|
| 187 |
+
values.append(value)
|
| 188 |
+
return ", ".join(values) if values else raw
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _normalize_number(prediction: Any, percentage: bool = False) -> str:
|
| 192 |
+
raw = _as_text(prediction)
|
| 193 |
+
cleaned = _remove_prefix(_parse_text(raw))
|
| 194 |
+
matches = list(_NUMBER_RE.finditer(cleaned))
|
| 195 |
+
if len(matches) != 1:
|
| 196 |
+
return raw
|
| 197 |
+
match = matches[0]
|
| 198 |
+
# Number answers should not silently turn a percentage into a count, and
|
| 199 |
+
# percentage answers reject the negative values that the official parser
|
| 200 |
+
# considers invalid.
|
| 201 |
+
if not percentage and ("%" in cleaned or re.search(r"\bpercent(?:age)?\b", cleaned, re.IGNORECASE)):
|
| 202 |
+
return raw
|
| 203 |
+
value = _canonical_decimal(match.group("value"))
|
| 204 |
+
if value is None:
|
| 205 |
+
return raw
|
| 206 |
+
if percentage and value.startswith("-"):
|
| 207 |
+
return raw
|
| 208 |
+
return value
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _boundary(text: str, end: int) -> bool:
|
| 212 |
+
return end >= len(text) or text[end].isspace() or text[end] in ".,;:!?)]}\"'"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _match_option(text: str, options: tuple[str, ...], start: int = 0) -> tuple[str, int] | None:
|
| 216 |
+
remaining = text[start:]
|
| 217 |
+
lowered = remaining.casefold()
|
| 218 |
+
for option in sorted(options, key=lambda item: (-len(item), item.casefold())):
|
| 219 |
+
candidate = option.casefold()
|
| 220 |
+
if not lowered.startswith(candidate):
|
| 221 |
+
continue
|
| 222 |
+
end = start + len(option)
|
| 223 |
+
if _boundary(text, end):
|
| 224 |
+
return option, end
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _question_options(request: Mapping[str, Any] | None) -> tuple[str, ...]:
|
| 229 |
+
"""Extract explicit semicolon/comma choices from a question when present."""
|
| 230 |
+
|
| 231 |
+
if not isinstance(request, Mapping):
|
| 232 |
+
return ()
|
| 233 |
+
for key in ("options", "choices"):
|
| 234 |
+
values = request.get(key)
|
| 235 |
+
if isinstance(values, (list, tuple)):
|
| 236 |
+
result = tuple(str(value).strip() for value in values if str(value).strip())
|
| 237 |
+
if len(result) >= 2:
|
| 238 |
+
return result
|
| 239 |
+
|
| 240 |
+
question = _question_text(request)
|
| 241 |
+
if not question:
|
| 242 |
+
return ()
|
| 243 |
+
marker = _OPTION_MARKER_RE.search(question.replace("\n", " "))
|
| 244 |
+
if marker:
|
| 245 |
+
tail = marker.group(1).strip()
|
| 246 |
+
else:
|
| 247 |
+
# Segment prompts commonly use ``... options: a; b; c`` without the
|
| 248 |
+
# word "options" immediately before the colon. Only use a suffix
|
| 249 |
+
# containing a semicolon to avoid treating timestamps as choices.
|
| 250 |
+
tail = question.rsplit(":", 1)[-1].strip() if ";" in question.rsplit(":", 1)[-1] else ""
|
| 251 |
+
if not tail:
|
| 252 |
+
return ()
|
| 253 |
+
|
| 254 |
+
# Remove examples and sentence punctuation from the choice tail. Values
|
| 255 |
+
# such as ``top/left`` intentionally retain their slash.
|
| 256 |
+
parts = [part.strip(" \t\r\n .!?\"'“”()[]") for part in re.split(r"\s*;\s*", tail)]
|
| 257 |
+
parts = [part for part in parts if part]
|
| 258 |
+
if len(parts) < 2:
|
| 259 |
+
return ()
|
| 260 |
+
# A choice list should not contain sentence-like fragments. This check is
|
| 261 |
+
# intentionally conservative; callers fall back to raw output on failure.
|
| 262 |
+
return tuple(dict.fromkeys(parts))
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _safe_tail(tail: str) -> bool:
|
| 266 |
+
tail = tail.strip()
|
| 267 |
+
if not tail:
|
| 268 |
+
return True
|
| 269 |
+
if tail[0] in ".,;:!?)]}\"'" or tail.startswith("("):
|
| 270 |
+
return True
|
| 271 |
+
if re.match(r"^(?:or|/|and)\b", tail, re.IGNORECASE):
|
| 272 |
+
return False
|
| 273 |
+
return bool(_EXPLANATION_RE.match(tail))
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _normalize_choice(prediction: Any, request: Mapping[str, Any] | None) -> str:
|
| 277 |
+
raw = _as_text(prediction)
|
| 278 |
+
cleaned = _remove_prefix(_parse_text(raw))
|
| 279 |
+
options = _question_options(request)
|
| 280 |
+
if not options:
|
| 281 |
+
# When a prompt does not retain its explicit choices, a single leading
|
| 282 |
+
# letter is still safe to canonicalize; a word answer is not.
|
| 283 |
+
match = re.match(r"^\(?([A-Za-z])\)?(?=$|[.)\]:\-\s])", cleaned)
|
| 284 |
+
if match:
|
| 285 |
+
return match.group(1).upper()
|
| 286 |
+
return raw
|
| 287 |
+
|
| 288 |
+
allow_multiple = bool(_MULTI_CHOICE_RE.search(_question_text(request)))
|
| 289 |
+
# Include none only when the prompt explicitly allows it. Do not infer it
|
| 290 |
+
# from arbitrary prose.
|
| 291 |
+
if allow_multiple and not any(option.casefold() == "none" for option in options):
|
| 292 |
+
options = (*options, "none")
|
| 293 |
+
|
| 294 |
+
first = _match_option(cleaned, options)
|
| 295 |
+
if first is None:
|
| 296 |
+
# Letter-labelled options (A) foo; B) bar) are accepted only when the
|
| 297 |
+
# labels are explicit in the question and map to exactly one choice.
|
| 298 |
+
labelled = re.findall(r"(?:^|[;\n])\s*([A-Za-z])\s*[.)\]:-]\s*([^;\n]+)", _question_text(request))
|
| 299 |
+
labels = {label.casefold(): value.strip(" .!?\"'") for label, value in labelled}
|
| 300 |
+
letter = re.match(r"^\(?([A-Za-z])\)?(?=$|[.)\]:\-\s])", cleaned)
|
| 301 |
+
if letter and letter.group(1).casefold() in labels:
|
| 302 |
+
return labels[letter.group(1).casefold()]
|
| 303 |
+
return raw
|
| 304 |
+
|
| 305 |
+
selected = [first[0]]
|
| 306 |
+
position = first[1]
|
| 307 |
+
if not allow_multiple:
|
| 308 |
+
return selected[0] if _safe_tail(cleaned[position:]) else raw
|
| 309 |
+
|
| 310 |
+
while True:
|
| 311 |
+
tail = cleaned[position:].lstrip()
|
| 312 |
+
separator = re.match(r"^(?:[,;]|\band\b)\s*", tail, re.IGNORECASE)
|
| 313 |
+
if not separator:
|
| 314 |
+
break
|
| 315 |
+
position += len(cleaned[position:]) - len(tail) + separator.end()
|
| 316 |
+
match = _match_option(cleaned, options, position)
|
| 317 |
+
if match is None:
|
| 318 |
+
return raw
|
| 319 |
+
if any(match[0].casefold() == value.casefold() for value in selected):
|
| 320 |
+
return raw
|
| 321 |
+
selected.append(match[0])
|
| 322 |
+
position = match[1]
|
| 323 |
+
return ", ".join(selected) if _safe_tail(cleaned[position:]) else raw
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _normalize_fo_class(prediction: Any, request: Mapping[str, Any] | None) -> str:
|
| 327 |
+
raw = _as_text(prediction)
|
| 328 |
+
cleaned = _remove_prefix(_parse_text(raw))
|
| 329 |
+
options = tuple(FOREIGN_OBJECT_CLASSES)
|
| 330 |
+
explicit = _question_options(request)
|
| 331 |
+
if explicit:
|
| 332 |
+
# Prefer the known ontology but retain custom classes if the request
|
| 333 |
+
# explicitly supplies them.
|
| 334 |
+
options = tuple(dict.fromkeys((*explicit, *options)))
|
| 335 |
+
options = (*options, "none")
|
| 336 |
+
|
| 337 |
+
first = _match_option(cleaned, options)
|
| 338 |
+
if first is None:
|
| 339 |
+
return raw
|
| 340 |
+
selected = [first[0]]
|
| 341 |
+
position = first[1]
|
| 342 |
+
while True:
|
| 343 |
+
tail = cleaned[position:].lstrip()
|
| 344 |
+
separator = re.match(r"^(?:[,;]|\band\b)\s*", tail, re.IGNORECASE)
|
| 345 |
+
if not separator:
|
| 346 |
+
break
|
| 347 |
+
position += len(cleaned[position:]) - len(tail) + separator.end()
|
| 348 |
+
match = _match_option(cleaned, options, position)
|
| 349 |
+
if match is None or any(match[0].casefold() == value.casefold() for value in selected):
|
| 350 |
+
return raw
|
| 351 |
+
selected.append(match[0])
|
| 352 |
+
position = match[1]
|
| 353 |
+
if not _safe_tail(cleaned[position:]):
|
| 354 |
+
return raw
|
| 355 |
+
# ``none`` cannot be combined with a concrete class without guessing what
|
| 356 |
+
# the model intended.
|
| 357 |
+
if len(selected) > 1 and any(value.casefold() == "none" for value in selected):
|
| 358 |
+
return raw
|
| 359 |
+
return ", ".join(selected)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def normalize_prediction(request: Mapping[str, Any] | None, prediction: Any) -> str:
|
| 363 |
+
"""Apply the conservative TF011/TF012 transform for one prediction.
|
| 364 |
+
|
| 365 |
+
``request`` may be a parquet-like row, a fixed-frame request, or a small
|
| 366 |
+
metadata dictionary. ``open_ended`` and unknown formats are deliberately
|
| 367 |
+
unchanged. No function in this module reads an answer/reference field.
|
| 368 |
+
"""
|
| 369 |
+
|
| 370 |
+
answer_format = _answer_format(request)
|
| 371 |
+
if answer_format == "time":
|
| 372 |
+
return normalize_time_output(prediction)
|
| 373 |
+
if answer_format == "binary":
|
| 374 |
+
raw = _as_text(prediction)
|
| 375 |
+
cleaned = _remove_prefix(_parse_text(raw))
|
| 376 |
+
match = re.match(r"^(yes|no)(?=$|[\s.,;:!?)}])", cleaned, re.IGNORECASE)
|
| 377 |
+
if not match:
|
| 378 |
+
return raw
|
| 379 |
+
tail = cleaned[match.end() :]
|
| 380 |
+
if re.match(r"^\s*(?:or|/|and)\s+(?:yes|no)\b", tail, re.IGNORECASE):
|
| 381 |
+
return raw
|
| 382 |
+
return match.group(1).lower()
|
| 383 |
+
if answer_format == "number":
|
| 384 |
+
return _normalize_number(prediction)
|
| 385 |
+
if answer_format == "percentage":
|
| 386 |
+
return _normalize_number(prediction, percentage=True)
|
| 387 |
+
if answer_format == "multiple_choice":
|
| 388 |
+
return _normalize_choice(prediction, request)
|
| 389 |
+
if answer_format == "fo_class":
|
| 390 |
+
return _normalize_fo_class(prediction, request)
|
| 391 |
+
return _as_text(prediction)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# Friendly aliases for callers that use “post-process” terminology.
|
| 395 |
+
postprocess_prediction = normalize_prediction
|
| 396 |
+
normalize_answer = normalize_prediction
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
__all__ = [
|
| 400 |
+
"FOREIGN_OBJECT_CLASSES",
|
| 401 |
+
"SUPPORTED_FORMATS",
|
| 402 |
+
"normalize_answer",
|
| 403 |
+
"normalize_prediction",
|
| 404 |
+
"normalize_time_output",
|
| 405 |
+
"postprocess_prediction",
|
| 406 |
+
]
|