Gbone3176 commited on
Commit
abc77ff
·
verified ·
1 Parent(s): a1408a1

Add TF009 Docker integration references and fixed runtime

Browse files
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, max_new_tokens=MAX_NEW_TOKENS, do_sample=False)
345
- new_tokens = generated[:, inputs["input_ids"].shape[1] :]
346
- answer = processor.batch_decode(
347
- new_tokens, skip_special_tokens=True, clean_up_tokenization_spaces=False
348
- )[0].strip()
349
- # The official evaluator consumes the content verbatim; only remove the
350
- # optional reasoning wrapper if a checkpoint emits it despite disabled
351
- # thinking, preserving all answer text otherwise.
352
- if "</think>" in answer:
353
- answer = answer.split("</think>", 1)[1].strip()
354
- return answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- log.info("[%d/%d] qID=%s answered in %.4f s: %r", i, len(requests), req.qID, latency, answer)
 
 
 
 
 
 
 
 
 
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
+ ]