Add Flora inference examples

#1
batch_utils/README.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FLORA Rewarder: example inference
2
+
3
+ Example scripts for the three reward prompts of the FLORA Rewarder. One set of weights answers
4
+ all three; the prompt selects the reward.
5
+
6
+ | Script | Reward | Model input | Output |
7
+ | --- | --- | --- | --- |
8
+ | `terminal_reward.py` | Episode success, 1–5 | Whole episode, up to 64 frames at up to 10 FPS, camera collage | Score, `P(1..5)`, expected score |
9
+ | `dense_reward.py` | Action quality, 1–3 per 0.5 s | Overlapping 16 s clips, 64 frames at 4 FPS, camera collage | Score per frame, segments, rationales for bad spans |
10
+ | `rubric_reward.py` | Rubric step scores | One video per camera, 2 FPS (up to 166 frames each), rubric | Thinking trace, one score per step |
11
+
12
+ Scores: terminal `1` = complete failure, `5` = complete success; dense `1` = failure,
13
+ `2` = suboptimal, `3` = normal.
14
+
15
+ ## Setup
16
+
17
+ Validated with Python 3.12 and NVIDIA H100 80 GB GPUs: one GPU for terminal and dense rewards,
18
+ two for rubric rewards with the default settings (`--tensor-parallel-size 2`).
19
+
20
+ ```bash
21
+ pip install -r batch_utils/requirements.txt
22
+ ```
23
+
24
+ Run the scripts from a downloaded copy of this repository, the directory that contains
25
+ `batch_utils/`; they load the weights stored next to it. To load the weights from elsewhere,
26
+ pass `--model` with a Hub repository id or a local directory, or set `FLORA_REWARDER_REPO`.
27
+ The first run compiles GPU kernels and can take several minutes before scoring starts.
28
+ `--dry-run` reads the videos and prints the first prompt without loading the model.
29
+
30
+ ## Inputs
31
+
32
+ Each episode is one line of a JSONL file; video paths are relative to that file. Camera videos
33
+ of one episode must be synchronized: same frame rate and frame count, starting together.
34
+
35
+ ```json
36
+ {"id": "ep001", "task": "Put the cup on the tray.", "videos": ["left_wrist.mp4", "top.mp4", "right_wrist.mp4"]}
37
+ ```
38
+
39
+ `task` is one plain-language instruction for the whole episode.
40
+
41
+ **Camera order (terminal and dense).** The views of each time step are tiled into one frame:
42
+ one view as is, two or three side by side, four as a 2 × 2 grid, in the order given. Use the
43
+ order the model was trained with:
44
+
45
+ | Views | Order |
46
+ | --- | --- |
47
+ | 2 | left camera, right camera (for example a left exterior camera and the wrist camera) |
48
+ | 3 | left, center, right (for example left wrist, top, right wrist) |
49
+ | 4 | top, front or bottom, left, right |
50
+
51
+ A single episode can also be passed on the command line:
52
+
53
+ ```bash
54
+ python -m batch_utils.terminal_reward --video left_wrist.mp4 top.mp4 right_wrist.mp4 \
55
+ --task "Put the cup on the tray." --output terminal.jsonl
56
+ ```
57
+
58
+ ## Terminal reward
59
+
60
+ ```bash
61
+ python -m batch_utils.terminal_reward --jobs batch_utils/examples/jobs.jsonl --output terminal.jsonl
62
+ ```
63
+
64
+ Frames are sampled uniformly over the whole episode: `min(64, floor(10 × duration in s))`,
65
+ rounded down to an even count. Add `--with-rationale` for a one-line explanation before the
66
+ score. Each output line has:
67
+
68
+ - `score`: the generated score, 1–5;
69
+ - `probabilities`: `P(1..5)` at the score token, and `expected_score`, their mean;
70
+ - `progress`: `(expected_score − 1) / 4`, a value in [0, 1] for ranking, not a calibrated
71
+ success probability.
72
+
73
+ ## Dense reward
74
+
75
+ ```bash
76
+ python -m batch_utils.dense_reward --jobs batch_utils/examples/jobs.jsonl --output dense.jsonl
77
+ ```
78
+
79
+ The episode is split into 16 s clips (64 frames at 4 FPS) that start every 10 s; the last clip
80
+ ends at the episode end. The model scores the 32 frame pairs (0.5 s each) of every clip and
81
+ writes a rationale for every span scored 1 or 2. Clips are stitched by dropping the last 4 s
82
+ of every clip except the final one and giving each frame the score of the nearest kept pair.
83
+
84
+ Each output line has `segments` (start and end time, score, rationale), `per_second` (the score
85
+ at the middle of each second), `fraction` of time per score, and the raw answer of every clip.
86
+ `--frame-scores` adds one score per source frame. Clips whose answer cannot be parsed are
87
+ scored normal and counted in `invalid_clips`.
88
+
89
+ Dense scores are retrospective: a clip also shows the seconds after each scored moment. Use
90
+ them for offline labeling and data filtering, not as an online signal.
91
+
92
+ ## Rubric reward
93
+
94
+ ```bash
95
+ python -m batch_utils.rubric_reward --jobs batch_utils/examples/rubric_jobs.jsonl --output rubric.jsonl
96
+ ```
97
+
98
+ A rubric job also names the robot and describes every camera, in video order:
99
+
100
+ ```json
101
+ {"id": "ep001", "task": "Put the cup on the tray.", "robot": "bimanual robot",
102
+ "cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"],
103
+ "videos": ["top.mp4", "left_wrist.mp4", "right_wrist.mp4"],
104
+ "rubric": [
105
+ {"action": "Grasp the cup", "grading": [
106
+ {"label": "Not attempted", "points": 0.0},
107
+ {"label": "Grasped after re-tries", "points": 0.1},
108
+ {"label": "Clean grasp", "points": 0.2}]},
109
+ {"action": "Set the cup on the tray", "grading": [
110
+ {"label": "Not attempted", "points": 0.0},
111
+ {"label": "Placed but tipped over", "points": 0.4},
112
+ {"label": "Placed upright", "points": 0.8}]}]}
113
+ ```
114
+
115
+ Each step lists the scores that may be awarded; the step maxima should sum to 1. Instead of
116
+ `robot` and `cameras`, a job may give `header`, the full opening paragraph of the prompt.
117
+
118
+ The model thinks before it answers `ANSWER: [...]`. The script samples `--num-samples` answers
119
+ (default 9) and reports the medoid: the answer with the smallest summed L1 distance to the
120
+ others. Use `--num-samples 1` for a single answer. Traces are often several thousand tokens;
121
+ `--keep-thinking` writes them to the output.
122
+
123
+ ## Notes
124
+
125
+ - Terminal and dense answers are decoded greedily with the answer format enforced; rubric
126
+ answers are sampled at temperature 1.
127
+ - Videos are read at their native frame rate, and the model receives absolute episode times.
128
+ Keep the native videos; do not resample them.
129
+ - Validate reward mappings on held-out episodes from your own robot before using them for
130
+ training or data selection.
batch_utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Example inference scripts for the FLORA rewarder: terminal, dense and rubric rewards."""
batch_utils/core.py ADDED
@@ -0,0 +1,803 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inputs, prompts and output parsing for the FLORA rewarder.
2
+
3
+ One model answers three prompts:
4
+
5
+ * terminal: one 1-5 score for a whole episode (1 = complete failure, 5 = complete success);
6
+ * dense: one 1-3 action-quality score per 0.5 s (1 = failure, 2 = suboptimal, 3 = normal),
7
+ with a short rationale for every non-normal span;
8
+ * rubric: a score for every step of a user-defined rubric, after a thinking trace.
9
+
10
+ This module has no inference backend. It builds the chat messages and the exact video
11
+ frames the model expects, and turns the generated text back into rewards.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from functools import cache
20
+ import json
21
+ import math
22
+ from pathlib import Path
23
+ import re
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+ from PIL import Image
28
+
29
+ VIDEO_PLACEHOLDER = "__video__"
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Video reading
33
+ # ---------------------------------------------------------------------------
34
+
35
+ FPS_RELATIVE_TOLERANCE = 0.01
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class VideoInfo:
40
+ path: str
41
+ fps: float
42
+ num_frames: int
43
+ width: int
44
+ height: int
45
+
46
+ @property
47
+ def duration_s(self) -> float:
48
+ return self.num_frames / self.fps
49
+
50
+
51
+ def probe_video(path: str | Path) -> VideoInfo:
52
+ """Frame rate, frame count and size of the first video stream."""
53
+ import av
54
+
55
+ with av.open(str(path)) as container:
56
+ stream = container.streams.video[0]
57
+ rate = stream.average_rate or stream.guessed_rate
58
+ if not rate or float(rate) <= 0:
59
+ raise ValueError(f"{path}: the video has no usable frame rate")
60
+ width, height = int(stream.codec_context.width), int(stream.codec_context.height)
61
+ frames = sum(1 for packet in container.demux(stream) if packet.size)
62
+ if frames < 1:
63
+ raise ValueError(f"{path}: the video has no frames")
64
+ return VideoInfo(str(path), float(rate), frames, width, height)
65
+
66
+
67
+ def read_frames(
68
+ path: str | Path, indices: Sequence[int], size: tuple[int, int] | None = None
69
+ ) -> dict[int, np.ndarray]:
70
+ """Decode the requested frame indices as RGB uint8 arrays, optionally resized.
71
+
72
+ Resizing uses bilinear interpolation to ``size`` (width, height), with no crop or padding.
73
+ """
74
+ import av
75
+
76
+ wanted = sorted({int(i) for i in indices})
77
+ out: dict[int, np.ndarray] = {}
78
+ if not wanted:
79
+ return out
80
+ with av.open(str(path)) as container:
81
+ stream = container.streams.video[0]
82
+ stream.thread_type = "AUTO"
83
+ pending = iter(wanted)
84
+ target = next(pending)
85
+ for position, frame in enumerate(container.decode(stream)):
86
+ if position != target:
87
+ continue
88
+ image = frame.to_image()
89
+ if size is not None and image.size != tuple(size):
90
+ image = image.resize(tuple(size), resample=Image.Resampling.BILINEAR)
91
+ out[position] = np.asarray(image.convert("RGB"), dtype=np.uint8)
92
+ target = next(pending, None)
93
+ if target is None:
94
+ break
95
+ if len(out) != len(wanted):
96
+ raise ValueError(f"{path}: decoded fewer frames than its container reports")
97
+ return out
98
+
99
+
100
+ def check_synchronized(videos: Sequence[VideoInfo]) -> tuple[float, int]:
101
+ """Camera views must share one frame clock. Returns (fps, frame count)."""
102
+ if not videos:
103
+ raise ValueError("at least one camera video is required")
104
+ fps = videos[0].fps
105
+ for video in videos[1:]:
106
+ if abs(video.fps - fps) > FPS_RELATIVE_TOLERANCE * fps:
107
+ raise ValueError(f"camera videos have different frame rates: {fps} vs {video.fps}")
108
+ counts = [video.num_frames for video in videos]
109
+ if max(counts) - min(counts) > max(1, round(0.01 * max(counts))):
110
+ raise ValueError(f"camera videos have different lengths: {sorted(set(counts))} frames")
111
+ return fps, min(counts)
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Multi-view collage (terminal and dense prompts)
116
+ # ---------------------------------------------------------------------------
117
+ # All camera views of one time step are tiled into one frame: one view as is, two or three
118
+ # views side by side, four views as a 2 x 2 grid, in the order given. Every view is resized
119
+ # to a 32-pixel-aligned cell of about 100k pixels, with no crop, padding or gutter.
120
+
121
+ ALIGN = 32
122
+ TARGET_PIXELS_PER_VIEW = 100_000
123
+ MAX_VISUAL_TOKENS = 14_336
124
+ MAX_VIEWS = 4
125
+
126
+
127
+ def _aspect_distortion(source_width: int, source_height: int, width: int, height: int) -> float:
128
+ return 100.0 * abs((width / height) / (source_width / source_height) - 1.0)
129
+
130
+
131
+ def _cell_loss(source: tuple[int, int], width: int, height: int) -> float:
132
+ distortion = _aspect_distortion(*source, width, height)
133
+ area_error = abs(math.log((width * height) / TARGET_PIXELS_PER_VIEW))
134
+ over_limit = max(0.0, distortion - 4.0)
135
+ return (distortion / 4.0) ** 2 + (area_error / 0.32) ** 2 + 10_000.0 * over_limit**2
136
+
137
+
138
+ def _candidate_widths(source: tuple[int, int], height: int) -> list[int]:
139
+ center = round(source[0] / source[1] * height / ALIGN) * ALIGN
140
+ return sorted(
141
+ {
142
+ center + offset * ALIGN
143
+ for offset in range(-4, 5)
144
+ if 2 * ALIGN <= center + offset * ALIGN <= 32 * ALIGN
145
+ }
146
+ )
147
+
148
+
149
+ def _heights() -> range:
150
+ return range(4 * ALIGN, 17 * ALIGN, ALIGN)
151
+
152
+
153
+ def _tokens(width: int, height: int) -> int:
154
+ return 32 * (width // ALIGN) * (height // ALIGN)
155
+
156
+
157
+ def _single_cell(source: tuple[int, int]) -> tuple[int, int]:
158
+ candidates = [
159
+ (_cell_loss(source, width, height), width, height)
160
+ for height in _heights()
161
+ for width in _candidate_widths(source, height)
162
+ ]
163
+ _, width, height = min(candidates)
164
+ return width, height
165
+
166
+
167
+ def _row_cells(sources: Sequence[tuple[int, int]]):
168
+ candidates = []
169
+ for height in _heights():
170
+ widths = [
171
+ min(_candidate_widths(s, height), key=lambda w, s=s: _cell_loss(s, w, height))
172
+ for s in sources
173
+ ]
174
+ loss = sum(_cell_loss(s, w, height) for s, w in zip(sources, widths))
175
+ if _tokens(sum(widths), height) <= MAX_VISUAL_TOKENS:
176
+ candidates.append((loss, [(w, height) for w in widths], sum(widths), height))
177
+ if not candidates:
178
+ raise ValueError("no side-by-side layout fits the visual token budget")
179
+ _, cells, total_width, total_height = min(candidates)
180
+ return cells, total_width, total_height
181
+
182
+
183
+ def _row_options(first: tuple[int, int], second: tuple[int, int]):
184
+ options: dict[int, tuple[float, int, int, int]] = {}
185
+ for height in _heights():
186
+ for w1 in _candidate_widths(first, height):
187
+ for w2 in _candidate_widths(second, height):
188
+ loss = _cell_loss(first, w1, height) + _cell_loss(second, w2, height)
189
+ current = options.get(w1 + w2)
190
+ if current is None or loss < current[0]:
191
+ options[w1 + w2] = (loss, height, w1, w2)
192
+ return options
193
+
194
+
195
+ def _grid_cells(sources: Sequence[tuple[int, int]]):
196
+ top_options = _row_options(sources[0], sources[1])
197
+ bottom_options = _row_options(sources[2], sources[3])
198
+ candidates = []
199
+ for total_width in top_options.keys() & bottom_options.keys():
200
+ top, bottom = top_options[total_width], bottom_options[total_width]
201
+ if _tokens(total_width, top[1] + bottom[1]) <= MAX_VISUAL_TOKENS:
202
+ candidates.append((top[0] + bottom[0], total_width, top, bottom))
203
+ if not candidates:
204
+ raise ValueError("no 2 x 2 layout fits the visual token budget")
205
+ _, total_width, top, bottom = min(candidates)
206
+ (_, top_h, tl_w, tr_w), (_, bottom_h, bl_w, br_w) = top, bottom
207
+ rectangles = [
208
+ (0, 0, tl_w, top_h),
209
+ (tl_w, 0, tr_w, top_h),
210
+ (0, top_h, bl_w, bottom_h),
211
+ (bl_w, top_h, br_w, bottom_h),
212
+ ]
213
+ return rectangles, total_width, top_h + bottom_h
214
+
215
+
216
+ @cache
217
+ def plan_collage(sources: tuple[tuple[int, int], ...]):
218
+ """Return ``(rectangles, width, height)``: one ``(x, y, w, h)`` cell per view, in order."""
219
+ if not 1 <= len(sources) <= MAX_VIEWS:
220
+ raise ValueError(f"1 to {MAX_VIEWS} camera views are supported, got {len(sources)}")
221
+ if len(sources) == 1:
222
+ width, height = _single_cell(sources[0])
223
+ return ((0, 0, width, height),), width, height
224
+ if len(sources) in (2, 3):
225
+ cells, width, height = _row_cells(sources)
226
+ rectangles, x = [], 0
227
+ for w, h in cells:
228
+ rectangles.append((x, 0, w, h))
229
+ x += w
230
+ return tuple(rectangles), width, height
231
+ rectangles, width, height = _grid_cells(sources)
232
+ return tuple(rectangles), width, height
233
+
234
+
235
+ class Collage:
236
+ """Synchronized camera videos composed into one collage video."""
237
+
238
+ def __init__(self, paths: Sequence[str | Path]):
239
+ if not paths:
240
+ raise ValueError("at least one camera video is required")
241
+ self.videos = [probe_video(path) for path in paths]
242
+ self.fps, self.num_frames = check_synchronized(self.videos)
243
+ sources = tuple((v.width, v.height) for v in self.videos)
244
+ self.rectangles, self.width, self.height = plan_collage(sources)
245
+ self._cache: dict[int, np.ndarray] = {}
246
+
247
+ @property
248
+ def duration_s(self) -> float:
249
+ return self.num_frames / self.fps
250
+
251
+ def release(self) -> None:
252
+ """Drop decoded frames."""
253
+ self._cache.clear()
254
+
255
+ def frames(self, indices: Sequence[int]) -> np.ndarray:
256
+ """``[T, H, W, 3]`` uint8 collage frames for source frame ``indices``."""
257
+ missing = sorted({int(i) for i in indices} - set(self._cache))
258
+ if missing:
259
+ per_view = [
260
+ read_frames(video.path, missing, size=(w, h))
261
+ for video, (_, _, w, h) in zip(self.videos, self.rectangles)
262
+ ]
263
+ for index in missing:
264
+ canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
265
+ for frames, (x, y, w, h) in zip(per_view, self.rectangles):
266
+ canvas[y : y + h, x : x + w] = frames[index]
267
+ self._cache[index] = canvas
268
+ return np.stack([self._cache[int(i)] for i in indices])
269
+
270
+
271
+ def video_metadata(indices: Sequence[int], fps: float, num_frames: int) -> dict[str, Any]:
272
+ """Frame-clock metadata so the model's timestamps are absolute episode seconds."""
273
+ return {
274
+ "total_num_frames": int(num_frames),
275
+ "fps": float(fps),
276
+ "frames_indices": [int(i) for i in indices],
277
+ "duration": float(num_frames) / float(fps),
278
+ "do_sample_frames": False,
279
+ }
280
+
281
+
282
+ def _messages(system: str, user: str) -> list[dict[str, Any]]:
283
+ before, marker, after = user.partition("<video>")
284
+ if not marker:
285
+ raise AssertionError("user prompt lost its video marker")
286
+ content: list[dict[str, Any]] = []
287
+ if before.strip():
288
+ content.append({"type": "text", "text": before.strip()})
289
+ content.append({"type": "video", "video": VIDEO_PLACEHOLDER})
290
+ if after.strip():
291
+ content.append({"type": "text", "text": after.strip()})
292
+ return [{"role": "system", "content": system}, {"role": "user", "content": content}]
293
+
294
+
295
+ def _compact(text: str) -> str:
296
+ compact = " ".join(str(text or "").split())
297
+ if not compact:
298
+ raise ValueError("a non-empty task instruction is required")
299
+ return compact
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Terminal reward: one 1-5 score per episode
304
+ # ---------------------------------------------------------------------------
305
+
306
+ TERMINAL_MAX_FRAMES = 64
307
+ TERMINAL_MAX_SAMPLE_FPS = 10.0
308
+ TERMINAL_TOP_LOGPROBS = 20
309
+
310
+ _TERMINAL_SYSTEM = """You are a terminal-success reward model for robot trajectories.
311
+ Given the task instruction and the complete trajectory video, predict a score from 1 to 5.
312
+ A complete failure is 1. A complete success is 5.
313
+ Judge only what is visible in the trajectory and follow the requested output format exactly."""
314
+
315
+
316
+ def terminal_frame_indices(num_frames: int, fps: float) -> list[int]:
317
+ """Uniform whole-episode sampling: ``min(64, floor(duration * 10))`` frames, even, >= 2.
318
+
319
+ The frame count follows the episode duration; keep it, the model relies on it.
320
+ """
321
+ if num_frames <= 0 or not math.isfinite(fps) or fps <= 0:
322
+ raise ValueError(f"invalid timing: frames={num_frames}, fps={fps}")
323
+ count = min(TERMINAL_MAX_FRAMES, max(2, math.floor(num_frames / fps * TERMINAL_MAX_SAMPLE_FPS)))
324
+ count = max(2, count - count % 2)
325
+ if num_frames == 1:
326
+ return [0, 0]
327
+ return [round(i * (num_frames - 1) / (count - 1)) for i in range(count)]
328
+
329
+
330
+ def terminal_messages(task: str, with_rationale: bool = False) -> list[dict[str, Any]]:
331
+ if with_rationale:
332
+ suffix = (
333
+ "Provide one short, compact rationale followed by the score. Return exactly:\n"
334
+ "Rationale: <rationale>\n\nScore: <1-5>"
335
+ )
336
+ else:
337
+ suffix = "Do not provide a rationale. Return exactly:\nScore: <1-5>"
338
+ user = (
339
+ f"Task: {_compact(task)}\n\n"
340
+ "Complete trajectory video:\n<video>\n\n"
341
+ "Evaluate the terminal success of the task."
342
+ )
343
+ return _messages(f"{_TERMINAL_SYSTEM}\n{suffix}", user)
344
+
345
+
346
+ def terminal_regex(with_rationale: bool = False) -> str:
347
+ return r"Rationale: [^\n]{1,512}\n\nScore: [1-5]" if with_rationale else r"Score: [1-5]"
348
+
349
+
350
+ def terminal_max_tokens(with_rationale: bool = False) -> int:
351
+ return 160 if with_rationale else 12
352
+
353
+
354
+ def parse_terminal(text: str, with_rationale: bool = False) -> tuple[int, str | None]:
355
+ if with_rationale:
356
+ match = re.fullmatch(r"Rationale:\s*(.*?)\n\s*\nScore:\s*([1-5])\s*$", text.strip(), re.DOTALL)
357
+ if match is None or not " ".join(match.group(1).split()):
358
+ raise ValueError(f"unexpected terminal answer: {text!r}")
359
+ return int(match.group(2)), " ".join(match.group(1).split())
360
+ match = re.fullmatch(r"Score:\s*([1-5])\s*$", text.strip())
361
+ if match is None:
362
+ raise ValueError(f"unexpected terminal answer: {text!r}")
363
+ return int(match.group(1)), None
364
+
365
+
366
+ def score_probabilities(
367
+ positions: Sequence[tuple[str, float, Sequence[tuple[str, float]]]], score: int
368
+ ) -> tuple[dict[str, float], float]:
369
+ """``P(1..5)`` at the generated score token, and the expected score.
370
+
371
+ ``positions`` has one ``(generated token text, its logprob, top candidates)`` entry per
372
+ generated token, where top candidates are ``(token text, logprob)`` pairs.
373
+ """
374
+ for token, logprob, candidates in reversed(list(positions)):
375
+ if token.strip() != str(score):
376
+ continue
377
+ mass = dict.fromkeys(range(1, 6), 0.0)
378
+ seen = set()
379
+ for text, value in [(token, logprob), *candidates]:
380
+ if text.strip() in {"1", "2", "3", "4", "5"} and (text, value) not in seen:
381
+ seen.add((text, value))
382
+ mass[int(text.strip())] += math.exp(value)
383
+ total = sum(mass.values())
384
+ probabilities = {str(k): mass[k] / total for k in range(1, 6)}
385
+ return probabilities, sum(k * probabilities[str(k)] for k in range(1, 6))
386
+ raise ValueError("the generated score token is missing from the logprobs")
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Dense reward: one 1-3 score per 0.5 s
391
+ # ---------------------------------------------------------------------------
392
+ # An episode is covered by 16 s clips of 64 collage frames at 4 FPS. Frame position 9 is the
393
+ # clip anchor; consecutive anchors are 10 s apart, and the last clip ends at the episode end.
394
+ # The vision encoder merges each pair of frames, so a clip has 32 groups of 0.5 s, and the
395
+ # model scores every group. Clips overlap; stitching drops the last 4 s of every clip except
396
+ # the final one and gives every source frame the score of the nearest kept group.
397
+
398
+ DENSE_FRAMES = 64
399
+ DENSE_FPS = 4.0
400
+ DENSE_GROUPS = 32
401
+ DENSE_HISTORY = 8
402
+ DENSE_SCORED_SPAN = 40
403
+ DENSE_STRIDE_S = 10.0
404
+ DENSE_TAIL_TRIM_S = 4.0
405
+ DENSE_MAX_TOKENS = 640
406
+ MAX_RATIONALE_CHARS = 240
407
+ LABELS = {1: "failure", 2: "suboptimal", 3: "normal"}
408
+
409
+ _DENSE_SYSTEM = f"""You are a dense action-quality reward model for robot manipulation.
410
+ You watch a synchronized multiview clip and predict action quality at the finest temporal resolution visible to the model.
411
+
412
+ The clip contains exactly {DENSE_FRAMES} sampled frames at {DENSE_FPS:g} FPS. The vision encoder combines each consecutive pair of frames into one temporal group, so you must predict exactly {DENSE_GROUPS} ordered groups. Group 1 contains frames 1-2, group 2 contains frames 3-4, and group {DENSE_GROUPS} contains frames {DENSE_FRAMES - 1}-{DENSE_FRAMES}.
413
+ The clip is retrospective: a later recovery must not improve an earlier group and a later unrelated mistake must not degrade it.
414
+
415
+ Use exactly this score scale:
416
+ 1 = failure: the action causes or clearly constitutes a major task failure, unsafe event, destructive mistake, or loss of task viability.
417
+ 2 = suboptimal: the action is noticeably hesitant, inefficient, imprecise, or locally incorrect, but the task remains recoverable.
418
+ 3 = normal: the action is appropriate, safe, and reasonably efficient for the visible situation.
419
+
420
+ Return the worst score before the temporal sequence. Worst must equal the minimum of all {DENSE_GROUPS} quality digits. Quality must contain exactly {DENSE_GROUPS} characters from 1, 2, or 3 with no spaces; character i is the score for group i.
421
+ For every contiguous group range scored 1 or 2, add one short rationale grounded only in visible evidence. Groups scored 3 get no rationale. Rationale ranges must exactly match the contiguous non-normal runs in Quality.
422
+
423
+ Return exactly:
424
+ Worst: <1, 2, or 3>
425
+ Quality:
426
+ <{DENSE_GROUPS} digits with no spaces>
427
+ Rationales:
428
+ <first group>-<last group> <short visible-evidence rationale>
429
+ <one line per contiguous group range scored 1 or 2, in time order>"""
430
+
431
+
432
+ @dataclass(frozen=True)
433
+ class DenseWindow:
434
+ index: int
435
+ anchor: int
436
+ frame_indices: tuple[int, ...]
437
+ real: tuple[bool, ...]
438
+ is_last: bool
439
+ fps: float
440
+
441
+ @property
442
+ def start_s(self) -> float:
443
+ return self.frame_indices[0] / self.fps
444
+
445
+ @property
446
+ def end_s(self) -> float:
447
+ return self.frame_indices[-1] / self.fps
448
+
449
+ def group_frames(self, group: int) -> tuple[int, int]:
450
+ """Source frames of 1-based ``group``."""
451
+ return self.frame_indices[2 * group - 2], self.frame_indices[2 * group - 1]
452
+
453
+ def group_valid(self, group: int) -> bool:
454
+ """A group is valid when at least one of its frames is inside the episode."""
455
+ return self.real[2 * group - 2] or self.real[2 * group - 1]
456
+
457
+ def group_time_s(self, group: int) -> float:
458
+ left, right = self.group_frames(group)
459
+ return (left + right) / (2.0 * self.fps)
460
+
461
+
462
+ def dense_windows(num_frames: int, fps: float, stride_s: float = DENSE_STRIDE_S) -> list[DenseWindow]:
463
+ if num_frames < 1:
464
+ raise ValueError("episode length must be positive")
465
+ stride = round(stride_s * fps)
466
+ if stride < 1:
467
+ raise ValueError(f"window stride collapses at {fps} FPS")
468
+ last_scored = round((DENSE_SCORED_SPAN - 1) * fps / DENSE_FPS)
469
+ maximum = max(0, num_frames - 1 - last_scored)
470
+ anchors = list(range(0, maximum, stride))
471
+ if not anchors or anchors[-1] != maximum:
472
+ anchors.append(maximum)
473
+ steps = range(-DENSE_HISTORY, DENSE_FRAMES - DENSE_HISTORY)
474
+ windows = []
475
+ for index, anchor in enumerate(anchors):
476
+ positions = [round(anchor + step * fps / DENSE_FPS) for step in steps]
477
+ raw = [anchor + round(step * fps / DENSE_FPS) for step in steps]
478
+ windows.append(
479
+ DenseWindow(
480
+ index=index,
481
+ anchor=anchor,
482
+ frame_indices=tuple(min(num_frames - 1, max(0, value)) for value in positions),
483
+ real=tuple(0 <= value < num_frames for value in raw),
484
+ is_last=index == len(anchors) - 1,
485
+ fps=float(fps),
486
+ )
487
+ )
488
+ return windows
489
+
490
+
491
+ def dense_messages(task: str, window: DenseWindow) -> list[dict[str, Any]]:
492
+ user = "\n".join(
493
+ [
494
+ f"Task: {_compact(task)}",
495
+ "",
496
+ "Synchronized multiview clip:",
497
+ "<video>",
498
+ "",
499
+ (
500
+ f"The clip contains {DENSE_FRAMES} sampled frame positions at {DENSE_FPS:g} FPS "
501
+ f"({DENSE_FRAMES / DENSE_FPS:g} seconds nominal span) and {DENSE_GROUPS} visible "
502
+ f"temporal groups of 2 frames each. The sampled positions run from "
503
+ f"t={window.start_s:.3f} to t={window.end_s:.3f} seconds of the episode. "
504
+ "Episode boundaries repeat the nearest available frame."
505
+ ),
506
+ f"Predict Worst, all {DENSE_GROUPS} group scores, and rationales for non-normal runs.",
507
+ ]
508
+ )
509
+ return _messages(_DENSE_SYSTEM, user)
510
+
511
+
512
+ def dense_regex() -> str:
513
+ numbers = sorted(range(1, DENSE_GROUPS + 1), key=lambda v: -len(str(v)))
514
+ group = "(?:" + "|".join(str(v) for v in numbers) + ")"
515
+ rationale = f"{group}-{group} [^\\n]{{1,{MAX_RATIONALE_CHARS}}}"
516
+ return f"Worst: [123]\\nQuality:\\n[123]{{{DENSE_GROUPS}}}\\nRationales:(?:\\n{rationale})*"
517
+
518
+
519
+ @dataclass(frozen=True)
520
+ class DenseAnswer:
521
+ scores: tuple[int, ...] # one 1-3 score per group
522
+ regions: tuple[tuple[int, int, int, str | None], ...] # (first group, last group, score, rationale)
523
+ valid: bool
524
+ error: str | None = None
525
+ unmatched_rationales: int = 0 # rationale lines whose span is not a non-normal run
526
+ missing_rationales: int = 0 # non-normal runs without a rationale line
527
+
528
+
529
+ def _runs(values: Sequence[int]) -> list[tuple[int, int, int]]:
530
+ """(start, end_exclusive, value) runs of equal values."""
531
+ runs, start = [], 0
532
+ for end in range(1, len(values) + 1):
533
+ if end == len(values) or values[end] != values[start]:
534
+ runs.append((start, end, int(values[start])))
535
+ start = end
536
+ return runs
537
+
538
+
539
+ def parse_dense(text: str) -> DenseAnswer:
540
+ """Strict parse. An unparseable answer becomes all-normal with ``valid=False``."""
541
+ try:
542
+ lines = text.strip("\n").split("\n")
543
+ if len(lines) < 4 or lines[1] != "Quality:" or lines[3] != "Rationales:":
544
+ raise ValueError("missing Worst / Quality / Rationales headers")
545
+ worst = re.fullmatch(r"Worst: ([123])", lines[0])
546
+ if worst is None or re.fullmatch(rf"[123]{{{DENSE_GROUPS}}}", lines[2]) is None:
547
+ raise ValueError("malformed Worst or Quality field")
548
+ scores = tuple(int(c) for c in lines[2])
549
+ if int(worst.group(1)) != min(scores):
550
+ raise ValueError("Worst disagrees with Quality")
551
+ given = {}
552
+ for line in lines[4:]:
553
+ match = re.fullmatch(r"(\d{1,2})-(\d{1,2}) (.+)", line)
554
+ if match is None:
555
+ raise ValueError(f"malformed rationale line: {line!r}")
556
+ given[(int(match.group(1)), int(match.group(2)))] = " ".join(match.group(3).split())
557
+ except ValueError as error:
558
+ return DenseAnswer((3,) * DENSE_GROUPS, ((1, DENSE_GROUPS, 3, None),), False, str(error))
559
+ runs = [(start + 1, end, score) for start, end, score in _runs(scores)]
560
+ expected = {(first, last) for first, last, score in runs if score < 3}
561
+ regions = tuple((first, last, score, given.get((first, last))) for first, last, score in runs)
562
+ return DenseAnswer(scores, regions, True, None, len(set(given) - expected), len(expected - set(given)))
563
+
564
+
565
+ def window_regions(window: DenseWindow, answer: DenseAnswer) -> list[dict[str, Any]]:
566
+ """Same-score group runs of one clip, with absolute times and rationales."""
567
+ return [
568
+ {
569
+ "first_group": first,
570
+ "last_group": last,
571
+ "score": score,
572
+ "label": LABELS[score],
573
+ "start_s": round(window.group_time_s(first), 3),
574
+ "end_s": round(window.group_time_s(last), 3),
575
+ "rationale": rationale,
576
+ }
577
+ for first, last, score, rationale in answer.regions
578
+ ]
579
+
580
+
581
+ def stitch_dense(
582
+ windows: Sequence[DenseWindow],
583
+ answers: Sequence[DenseAnswer],
584
+ num_frames: int,
585
+ tail_trim_s: float = DENSE_TAIL_TRIM_S,
586
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
587
+ """Per-source-frame scores from overlapping clips.
588
+
589
+ Every clip except the last drops its final ``tail_trim_s`` seconds. Each source frame then
590
+ takes the score of the nearest kept group; ties prefer the group farther from its clip's
591
+ edges, then the earlier clip. Returns ``(scores, clip index, group)`` per source frame.
592
+ """
593
+ if tail_trim_s < 0 or (tail_trim_s * DENSE_FPS) % 2:
594
+ raise ValueError("tail trim must be a non-negative multiple of 0.5 s")
595
+ trim_groups = round(tail_trim_s * DENSE_FPS / 2)
596
+ if trim_groups >= DENSE_GROUPS:
597
+ raise ValueError("tail trim removes every group")
598
+ rows = [] # (frame, -interiority, clip, group, score)
599
+ for window, answer in zip(windows, answers, strict=True):
600
+ kept = DENSE_GROUPS if window.is_last else DENSE_GROUPS - trim_groups
601
+ for group in range(1, kept + 1):
602
+ if not window.group_valid(group):
603
+ continue
604
+ interiority = min(group - 1, DENSE_GROUPS - group)
605
+ for frame in sorted(set(window.group_frames(group))):
606
+ rows.append((frame, -interiority, window.index, group, answer.scores[group - 1]))
607
+ if not rows:
608
+ raise ValueError("no kept groups cover the episode")
609
+ rows.sort(key=lambda row: (row[1], row[2], row[3]))
610
+ frames = np.asarray([row[0] for row in rows], dtype=np.int64)
611
+ best = np.empty(num_frames, dtype=np.int64)
612
+ for start in range(0, num_frames, 4096):
613
+ query = np.arange(start, min(num_frames, start + 4096), dtype=np.int64)
614
+ best[start : start + len(query)] = np.argmin(np.abs(frames[None, :] - query[:, None]), axis=1)
615
+ pick = [rows[i] for i in best]
616
+ return (
617
+ np.asarray([row[4] for row in pick], dtype=np.int8),
618
+ np.asarray([row[2] for row in pick], dtype=np.int32),
619
+ np.asarray([row[3] for row in pick], dtype=np.int32),
620
+ )
621
+
622
+
623
+ def dense_segments(
624
+ scores: np.ndarray,
625
+ clip_of_frame: np.ndarray,
626
+ group_of_frame: np.ndarray,
627
+ answers: Sequence[DenseAnswer],
628
+ fps: float,
629
+ ) -> list[dict[str, Any]]:
630
+ """Episode-level runs (end-exclusive frames) with the rationale covering most frames."""
631
+ segments = []
632
+ for start, end, score in _runs(scores.tolist()):
633
+ rationale = None
634
+ if score < 3:
635
+ votes: Counter[str] = Counter()
636
+ for frame in range(start, end):
637
+ answer, group = answers[int(clip_of_frame[frame])], int(group_of_frame[frame])
638
+ for first, last, _, text in answer.regions:
639
+ if first <= group <= last and text:
640
+ votes[text] += 1
641
+ break
642
+ if votes:
643
+ rationale = max(sorted(votes), key=lambda text: (votes[text], text))
644
+ segments.append(
645
+ {
646
+ "start_frame": start,
647
+ "end_frame": end,
648
+ "start_s": round(start / fps, 3),
649
+ "end_s": round(end / fps, 3),
650
+ "score": score,
651
+ "label": LABELS[score],
652
+ "rationale": rationale,
653
+ }
654
+ )
655
+ return segments
656
+
657
+
658
+ # ---------------------------------------------------------------------------
659
+ # Rubric reward: a score for every step of a user-defined rubric
660
+ # ---------------------------------------------------------------------------
661
+ # Each camera is a separate video ("Video 1", "Video 2", ...), not a collage. The model
662
+ # thinks first, then answers ``ANSWER: [s_1, ..., s_n]`` with one allowed value per step.
663
+
664
+ RUBRIC_SYSTEM = "You are a helpful assistant."
665
+ RUBRIC_FPS = 2.0
666
+ RUBRIC_MAX_FRAMES = 166
667
+ RUBRIC_MIN_PIXELS = 64 * 32 * 32
668
+ RUBRIC_MAX_PIXELS = 128 * 32 * 32
669
+ RUBRIC_TOTAL_PIXELS = 224 * 1024 * 32 * 32
670
+ _COUNT = {1: "one video", 2: "two time-synchronized videos", 3: "three time-synchronized videos",
671
+ 4: "four time-synchronized videos"}
672
+
673
+ _RUBRIC_INSTRUCTIONS = (
674
+ "Score how well the robot performed, using the rubric below. The rubric lists the task's "
675
+ "steps; each step has its own maximum number of points and an explicit list of the point "
676
+ "values you may award for it. For every step, award exactly one of the values listed for "
677
+ "that step -- not a value in between.\n"
678
+ )
679
+ _RUBRIC_FOOTER = (
680
+ "\nRespond with your scores as a JSON array of numbers -- one score per step, in the "
681
+ "exact order listed above (a length-{n} array). Each number must be one of the values "
682
+ "allowed for that step. Output the array on a single line prefixed with 'ANSWER:' and "
683
+ "nothing after it, e.g.\nANSWER: {example}\n"
684
+ )
685
+
686
+
687
+ def rubric_header(robot: str, cameras: Sequence[str]) -> str:
688
+ """Describe the robot and every camera, in video order.
689
+
690
+ Example: ``rubric_header("bimanual robot", ["a top (scene) camera", "a left-wrist camera",
691
+ "a right-wrist camera"])``.
692
+ """
693
+ if not 1 <= len(cameras) <= 4:
694
+ raise ValueError("1 to 4 camera videos are supported")
695
+ views = [f"Video {i} is {_compact(text)}" for i, text in enumerate(cameras, 1)]
696
+ if len(views) > 1:
697
+ views[-1] = "and " + views[-1]
698
+ return (
699
+ "You are an expert evaluator of robot manipulation. You are shown "
700
+ f"{_COUNT[len(cameras)]} of a {_compact(robot)} performing one task: {', '.join(views)}.\n\n"
701
+ )
702
+
703
+
704
+ def _steps(rubric: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
705
+ steps = []
706
+ for item in rubric:
707
+ grading = [{"label": str(g["label"]), "points": float(g["points"])} for g in item["grading"]]
708
+ if not grading:
709
+ raise ValueError("every rubric step needs at least one allowed value")
710
+ maximum = float(item.get("max_score", max(g["points"] for g in grading)))
711
+ steps.append({"action": _compact(item["action"]), "max_score": maximum, "grading": grading})
712
+ if not steps:
713
+ raise ValueError("the rubric has no steps")
714
+ return steps
715
+
716
+
717
+ def rubric_prompt(
718
+ task: str,
719
+ rubric: Sequence[Mapping[str, Any]],
720
+ header: str,
721
+ task_name: str | None = None,
722
+ ) -> str:
723
+ """The text that follows the camera videos.
724
+
725
+ ``rubric`` is a list of steps: ``{"action": str, "grading": [{"label": str, "points": float},
726
+ ...]}`` with optional ``max_score`` (default: the largest allowed value). The step maxima
727
+ should sum to 1.
728
+ """
729
+ steps = _steps(rubric)
730
+ instruction = _compact(task)
731
+ lines = [header + _RUBRIC_INSTRUCTIONS, f"Task: {_compact(task_name or instruction)}"]
732
+ lines.append(f'Instruction to the robot: "{instruction}"')
733
+ lines.append("\nScoring rubric -- award one of the listed values for each step:")
734
+ for i, step in enumerate(steps, 1):
735
+ lines.append(f" {i}. {step['action']} (max {step['max_score']:g})")
736
+ tiers = " | ".join(f"{g['label']} = {g['points']:.2f}" for g in step["grading"])
737
+ lines.append(f" award one of: {tiers}")
738
+ example = json.dumps([round(step["max_score"], 3) for step in steps])
739
+ lines.append(_RUBRIC_FOOTER.format(n=len(steps), example=example))
740
+ return "\n".join(lines)
741
+
742
+
743
+ def rubric_messages(prompt: str, video_paths: Sequence[str | Path], fps: float = RUBRIC_FPS,
744
+ max_frames: int = RUBRIC_MAX_FRAMES) -> list[dict[str, Any]]:
745
+ """Chat messages in the format read by the video loader of ``qwen_vl_utils``."""
746
+ video = {"min_pixels": RUBRIC_MIN_PIXELS, "max_pixels": RUBRIC_MAX_PIXELS,
747
+ "total_pixels": RUBRIC_TOTAL_PIXELS, "max_frames": max_frames, "fps": fps}
748
+ content = [{"type": "video", "video": str(path), **video} for path in video_paths]
749
+ content.append({"type": "text", "text": prompt})
750
+ return [{"role": "system", "content": RUBRIC_SYSTEM}, {"role": "user", "content": content}]
751
+
752
+
753
+ def split_thinking(text: str) -> tuple[str, str | None]:
754
+ """``(answer, thinking)``; the answer is empty when generation stopped inside the trace."""
755
+ before, closed, after = text.partition("</think>")
756
+ if not closed:
757
+ return "", before.replace("<think>", "", 1).strip() or None
758
+ return after.strip(), before.replace("<think>", "", 1).strip() or None
759
+
760
+
761
+ def parse_rubric(answer: str, rubric: Sequence[Mapping[str, Any]]) -> tuple[list[float], bool]:
762
+ """Scores after the last ``ANSWER:``, aligned to the steps and clamped to ``[0, max]``.
763
+
764
+ Returns ``(scores, ok)``. A missing or wrong-length answer gives ``ok=False``; missing
765
+ steps are scored 0.
766
+ """
767
+ steps = _steps(rubric)
768
+ parsed = None
769
+ if answer:
770
+ segments = re.split(r"ANSWER\s*:", answer, flags=re.IGNORECASE)
771
+ spaces = [segments[-1]] + ([answer] if len(segments) > 1 else [])
772
+ for space in spaces:
773
+ for match in re.finditer(r"\[[^\[\]]*\]", space, re.DOTALL):
774
+ try:
775
+ values = json.loads(match.group(0))
776
+ except json.JSONDecodeError:
777
+ numbers = re.findall(r"-?\d+(?:\.\d+)?", match.group(0))
778
+ values = [float(x) for x in numbers] if numbers else None
779
+ if isinstance(values, list) and values and all(isinstance(v, (int, float)) for v in values):
780
+ parsed = [float(v) for v in values]
781
+ break
782
+ if parsed is not None:
783
+ break
784
+ if parsed is None:
785
+ numbers = re.findall(r"-?\d+(?:\.\d+)?", spaces[0])
786
+ parsed = [float(x) for x in numbers[: len(steps)]] if numbers else None
787
+ ok = parsed is not None and len(parsed) == len(steps)
788
+ values = list(parsed or []) + [0.0] * len(steps)
789
+ return [min(max(0.0, values[i]), steps[i]["max_score"]) for i in range(len(steps))], ok
790
+
791
+
792
+ def medoid(score_vectors: Sequence[Sequence[float]]) -> int:
793
+ """Index of the vector with the smallest summed L1 distance to all others."""
794
+ if not score_vectors:
795
+ raise ValueError("no parsed rubric answers")
796
+
797
+ def l1(a, b):
798
+ return sum(abs(x - y) for x, y in zip(a, b))
799
+
800
+ return min(
801
+ range(len(score_vectors)),
802
+ key=lambda i: (sum(l1(score_vectors[i], other) for other in score_vectors), i),
803
+ )
batch_utils/dense_reward.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dense reward: one 1-3 action-quality score per 0.5 s, with rationales for bad spans.
2
+
3
+ python -m batch_utils.dense_reward --jobs jobs.jsonl --output dense.jsonl
4
+ python -m batch_utils.dense_reward --video top.mp4 wrist.mp4 --task "Put the cup on the tray." --output dense.jsonl
5
+
6
+ Each job is ``{"id": ..., "task": ..., "videos": [camera videos, in collage order]}``.
7
+ Scores: 1 = failure, 2 = suboptimal, 3 = normal. The episode is scored in overlapping 16 s
8
+ clips that are stitched into one score per source frame. Scoring is retrospective (a clip
9
+ sees 4 s past each kept moment), so use it for offline labeling, not as an online signal.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import Counter
15
+
16
+ import numpy as np
17
+
18
+ from . import core
19
+ from .engine import (
20
+ COLLAGE_VIDEO_SIZE,
21
+ base_parser,
22
+ load_llm,
23
+ load_processor,
24
+ log,
25
+ open_output,
26
+ read_jobs,
27
+ render,
28
+ resolve_model,
29
+ write_record,
30
+ )
31
+
32
+
33
+ def summarize(job, collage, windows, answers, texts, tail_trim_s, include_frames):
34
+ scores, clip_of_frame, group_of_frame = core.stitch_dense(windows, answers, collage.num_frames, tail_trim_s)
35
+ counts = Counter(scores.tolist())
36
+ record = {
37
+ "id": job["id"],
38
+ "task": job["task"],
39
+ "fps": collage.fps,
40
+ "num_frames": collage.num_frames,
41
+ "duration_s": round(collage.duration_s, 3),
42
+ "worst_score": int(scores.min()),
43
+ "mean_score": round(float(scores.mean()), 4),
44
+ "fraction": {core.LABELS[s]: round(counts.get(s, 0) / collage.num_frames, 4) for s in (1, 2, 3)},
45
+ "segments": core.dense_segments(scores, clip_of_frame, group_of_frame, answers, collage.fps),
46
+ # One score per second of video, read at the middle of each second.
47
+ "per_second": [int(scores[min(collage.num_frames - 1, int((t + 0.5) * collage.fps))])
48
+ for t in range(int(np.ceil(collage.duration_s)))],
49
+ "clips": [
50
+ {
51
+ "start_s": round(window.start_s, 3),
52
+ "end_s": round(window.end_s, 3),
53
+ "quality": "".join(map(str, answer.scores)),
54
+ "regions": core.window_regions(window, answer),
55
+ "valid": answer.valid,
56
+ "output": text,
57
+ }
58
+ for window, answer, text in zip(windows, answers, texts)
59
+ ],
60
+ "invalid_clips": sum(not a.valid for a in answers),
61
+ }
62
+ if include_frames:
63
+ record["frame_scores"] = scores.tolist()
64
+ return record
65
+
66
+
67
+ def main(argv=None) -> int:
68
+ p = base_parser("FLORA rewarder: dense action-quality scores, 1-3 per 0.5 s.")
69
+ p.add_argument("--tail-trim", type=float, default=core.DENSE_TAIL_TRIM_S,
70
+ help="Seconds dropped from the end of every clip but the last when stitching.")
71
+ p.add_argument("--frame-scores", action="store_true", help="Also write one score per source frame.")
72
+ p.add_argument("--max-model-len", type=int, default=16384)
73
+ args = p.parse_args(argv)
74
+ jobs = read_jobs(args)
75
+ model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
76
+ processor = load_processor(model_path)
77
+
78
+ if args.dry_run:
79
+ job = jobs[0]
80
+ collage = core.Collage(job["videos"])
81
+ windows = core.dense_windows(collage.num_frames, collage.fps)
82
+ prompt = render(processor, core.dense_messages(job["task"], windows[0]), thinking=False)
83
+ log(f"{job['id']}: {collage.num_frames} frames at {collage.fps:g} FPS, collage "
84
+ f"{collage.width}x{collage.height}, {len(windows)} clips\n{prompt}")
85
+ return 0
86
+
87
+ from vllm import SamplingParams
88
+ from vllm.sampling_params import StructuredOutputsParams
89
+
90
+ llm = load_llm(model_path, args, max_model_len=args.max_model_len,
91
+ mm_processor_kwargs={"do_sample_frames": False, "size": COLLAGE_VIDEO_SIZE})
92
+ params = SamplingParams(temperature=0.0, max_tokens=core.DENSE_MAX_TOKENS,
93
+ structured_outputs=StructuredOutputsParams(regex=core.dense_regex()))
94
+ with open_output(args) as stream:
95
+ for number, job in enumerate(jobs, 1):
96
+ collage = core.Collage(job["videos"])
97
+ windows = core.dense_windows(collage.num_frames, collage.fps)
98
+ collage.frames(sorted({i for window in windows for i in window.frame_indices})) # one decoding pass
99
+ texts = []
100
+ for start in range(0, len(windows), args.max_num_seqs):
101
+ batch = windows[start : start + args.max_num_seqs]
102
+ requests = [
103
+ {
104
+ "prompt": render(processor, core.dense_messages(job["task"], window), thinking=False),
105
+ "multi_modal_data": {
106
+ "video": (
107
+ collage.frames(window.frame_indices),
108
+ core.video_metadata(window.frame_indices, collage.fps, collage.num_frames),
109
+ )
110
+ },
111
+ "mm_processor_kwargs": {"do_sample_frames": False},
112
+ }
113
+ for window in batch
114
+ ]
115
+ texts += [out.outputs[0].text for out in llm.generate(requests, params, use_tqdm=False)]
116
+ answers = [core.parse_dense(text) for text in texts]
117
+ write_record(stream, summarize(job, collage, windows, answers, texts, args.tail_trim, args.frame_scores))
118
+ collage.release()
119
+ log(f"dense: {number}/{len(jobs)} episodes ({len(windows)} clips)")
120
+ return 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ raise SystemExit(main())
batch_utils/engine.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared command-line plumbing and the offline vLLM engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import sys
11
+ from typing import Any, Iterable
12
+
13
+ # Without --model: $FLORA_REWARDER_REPO, else the downloaded copy of the repository that holds
14
+ # batch_utils/ (absolute, not resolved: Hub cache files are symlinks).
15
+ MODEL_ENV = "FLORA_REWARDER_REPO"
16
+ REPOSITORY_COPY = Path(__file__).absolute().parent.parent
17
+ # Pixel budget for collage clips (terminal and dense): at least ``shortest_edge`` and at most
18
+ # ``longest_edge`` pixels over all frames of one clip. Collages are planned inside this budget,
19
+ # so frames reach the model at their planned size.
20
+ # Configuration and tokenizer files: enough to render prompts for --dry-run.
21
+ PROCESSOR_FILES = ["*.json", "*.jinja", "*.txt"]
22
+ COLLAGE_VIDEO_SIZE = {"shortest_edge": 4096, "longest_edge": 29_360_128}
23
+
24
+
25
+ def default_model() -> str | None:
26
+ if os.environ.get(MODEL_ENV):
27
+ return os.environ[MODEL_ENV]
28
+ if (REPOSITORY_COPY / "model.safetensors.index.json").is_file():
29
+ return str(REPOSITORY_COPY)
30
+ return None
31
+
32
+
33
+ def base_parser(description: str, *, tensor_parallel_size: int = 1) -> argparse.ArgumentParser:
34
+ p = argparse.ArgumentParser(description=description)
35
+ source = p.add_mutually_exclusive_group(required=True)
36
+ source.add_argument("--jobs", type=Path, help="JSONL file, one episode per line (paths relative to the file).")
37
+ source.add_argument("--video", type=Path, nargs="+", help="Camera video(s) of one episode, in order.")
38
+ p.add_argument("--task", help="Task instruction for --video.")
39
+ p.add_argument("--output", type=Path, required=True, help="Output JSONL file.")
40
+ p.add_argument("--overwrite", action="store_true", help="Replace an existing output file.")
41
+ p.add_argument("--model", default=default_model(),
42
+ help=f"Hub repository id or local directory (default: ${MODEL_ENV}, else this repository's copy).")
43
+ p.add_argument("--revision", default=None, help="Hub revision (branch, tag or commit).")
44
+ p.add_argument("--tensor-parallel-size", type=int, default=tensor_parallel_size)
45
+ p.add_argument("--gpu-memory-utilization", type=float, default=0.88)
46
+ p.add_argument("--max-num-seqs", type=int, default=8, help="Concurrent requests in the engine.")
47
+ p.add_argument("--seed", type=int, default=0)
48
+ p.add_argument("--dry-run", action="store_true", help="Read the videos and print the first prompt; no model.")
49
+ return p
50
+
51
+
52
+ def read_jobs(args: argparse.Namespace, extra_fields: Iterable[str] = ()) -> list[dict[str, Any]]:
53
+ """Jobs from ``--jobs`` or from ``--video``/``--task``; video paths become absolute."""
54
+ if args.video:
55
+ if not args.task:
56
+ raise SystemExit("--task is required with --video")
57
+ return [{"id": "episode", "task": args.task, "videos": [str(p.expanduser().resolve()) for p in args.video]}]
58
+ root = args.jobs.expanduser().resolve().parent
59
+ allowed = {"id", "task", "videos", *extra_fields}
60
+ jobs, ids = [], set()
61
+ for number, line in enumerate(args.jobs.read_text().splitlines(), 1):
62
+ if not line.strip():
63
+ continue
64
+ job = json.loads(line)
65
+ if not isinstance(job, dict) or set(job) - allowed or not {"task", "videos"} <= set(job):
66
+ raise SystemExit(f"{args.jobs}:{number}: expected keys {sorted(allowed)}")
67
+ job.setdefault("id", f"episode_{number:06d}")
68
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", str(job["id"])) or job["id"] in ids:
69
+ raise SystemExit(f"{args.jobs}:{number}: invalid or duplicate id {job['id']!r}")
70
+ ids.add(job["id"])
71
+ if not isinstance(job["videos"], list) or not job["videos"]:
72
+ raise SystemExit(f"{args.jobs}:{number}: 'videos' must be a non-empty list")
73
+ job["videos"] = [str((root / Path(v).expanduser()).resolve()) for v in job["videos"]]
74
+ jobs.append(job)
75
+ if not jobs:
76
+ raise SystemExit(f"{args.jobs} has no jobs")
77
+ return jobs
78
+
79
+
80
+ def open_output(args: argparse.Namespace):
81
+ if args.output.exists() and not args.overwrite:
82
+ raise SystemExit(f"{args.output} exists; pass --overwrite to replace it")
83
+ args.output.parent.mkdir(parents=True, exist_ok=True)
84
+ return args.output.open("w")
85
+
86
+
87
+ def write_record(stream, record: dict[str, Any]) -> None:
88
+ stream.write(json.dumps(record, ensure_ascii=False) + "\n")
89
+ stream.flush()
90
+
91
+
92
+ def resolve_model(model: str | None, revision: str | None = None, *, weights: bool = True) -> str:
93
+ """A local directory with the model; ``weights=False`` fetches only the processor files."""
94
+ if not model:
95
+ raise SystemExit(f"Pass --model (a Hub repository id or a local directory) or set {MODEL_ENV}.")
96
+ path = Path(model).expanduser()
97
+ if path.is_dir():
98
+ return str(path.resolve())
99
+ from huggingface_hub import snapshot_download
100
+
101
+ return snapshot_download(model, revision=revision, allow_patterns=None if weights else PROCESSOR_FILES)
102
+
103
+
104
+ def load_processor(path: str):
105
+ from transformers import AutoProcessor
106
+
107
+ return AutoProcessor.from_pretrained(path)
108
+
109
+
110
+ def load_llm(
111
+ path: str,
112
+ args: argparse.Namespace,
113
+ *,
114
+ max_model_len: int,
115
+ videos_per_prompt: int = 1,
116
+ mm_processor_kwargs: dict[str, Any] | None = None,
117
+ **engine_kwargs: Any,
118
+ ):
119
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
120
+ from vllm import LLM
121
+
122
+ return LLM(
123
+ model=path,
124
+ tensor_parallel_size=args.tensor_parallel_size,
125
+ dtype="bfloat16",
126
+ gpu_memory_utilization=args.gpu_memory_utilization,
127
+ max_model_len=max_model_len,
128
+ max_num_seqs=args.max_num_seqs,
129
+ limit_mm_per_prompt={"image": 0, "video": videos_per_prompt},
130
+ mm_processor_kwargs=mm_processor_kwargs,
131
+ # Triton prefill for the linear-attention layers: the configuration the reported
132
+ # results were measured with.
133
+ gdn_prefill_backend="triton",
134
+ enable_prefix_caching=True,
135
+ seed=args.seed,
136
+ **engine_kwargs,
137
+ )
138
+
139
+
140
+ def render(processor, messages, *, thinking: bool, vision_ids: bool = False) -> str:
141
+ return processor.apply_chat_template(
142
+ messages,
143
+ tokenize=False,
144
+ add_generation_prompt=True,
145
+ enable_thinking=thinking,
146
+ add_vision_id=vision_ids,
147
+ )
148
+
149
+
150
+ def log(message: str) -> None:
151
+ print(message, file=sys.stderr, flush=True)
batch_utils/examples/jobs.jsonl ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ {"id": "episode_0001", "task": "Put the cup on the tray.", "videos": ["videos/episode_0001/left_wrist.mp4", "videos/episode_0001/top.mp4", "videos/episode_0001/right_wrist.mp4"]}
2
+ {"id": "episode_0002", "task": "Open the drawer and put the sponge inside.", "videos": ["videos/episode_0002/exterior_left.mp4", "videos/episode_0002/wrist.mp4", "videos/episode_0002/exterior_right.mp4"]}
batch_utils/examples/rubric_jobs.jsonl ADDED
@@ -0,0 +1 @@
 
 
1
+ {"id": "episode_0001", "task": "Put the cup on the tray.", "robot": "bimanual robot", "cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"], "videos": ["videos/episode_0001/top.mp4", "videos/episode_0001/left_wrist.mp4", "videos/episode_0001/right_wrist.mp4"], "rubric": [{"action": "Grasp the cup", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Grasped after re-tries", "points": 0.1}, {"label": "Clean grasp", "points": 0.2}]}, {"action": "Carry the cup to the tray", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Slipped or dropped on the way", "points": 0.15}, {"label": "Carried to the tray", "points": 0.3}]}, {"action": "Set the cup upright on the tray", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Placed but tipped over", "points": 0.25}, {"label": "Placed upright", "points": 0.5}]}]}
batch_utils/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ vllm==0.24.0
2
+ transformers==5.8.1
3
+ qwen-vl-utils==0.0.14
4
+ decord==0.6.0
5
+ av>=15
6
+ numpy<3
7
+ Pillow>=11,<13
8
+ huggingface_hub>=1.0
batch_utils/rubric_reward.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rubric reward: a score for every step of a user-defined rubric.
2
+
3
+ python -m batch_utils.rubric_reward --jobs rubric_jobs.jsonl --output rubric.jsonl
4
+
5
+ Each job gives the camera videos (each camera is a separate video, in the order the camera
6
+ descriptions list them), the robot, the task instruction and the rubric:
7
+
8
+ {"id": "ep01", "task": "Put the cup on the tray.",
9
+ "robot": "bimanual robot",
10
+ "cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"],
11
+ "videos": ["top.mp4", "left_wrist.mp4", "right_wrist.mp4"],
12
+ "rubric": [{"action": "Grasp the cup",
13
+ "grading": [{"label": "Not attempted", "points": 0.0},
14
+ {"label": "Grasped after re-tries", "points": 0.1},
15
+ {"label": "Clean grasp", "points": 0.2}]}, ...]}
16
+
17
+ Instead of ``robot`` and ``cameras``, a job may give ``header``: the full opening paragraph
18
+ that describes the robot and Video 1..N (ending with a blank line). The step maxima should sum
19
+ to 1. The model thinks before answering. Several answers are sampled per episode and the final
20
+ scores are the medoid answer (the answer closest, in summed L1 distance, to all others).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+
27
+ from . import core
28
+ from .engine import base_parser, load_llm, load_processor, log, open_output, read_jobs, render, resolve_model, write_record
29
+
30
+
31
+ def video_inputs(messages):
32
+ """Decode every camera video at the rubric frame rate and pixel budget."""
33
+ if "FORCE_QWENVL_VIDEO_READER" not in os.environ:
34
+ try:
35
+ import decord # noqa: F401
36
+
37
+ os.environ["FORCE_QWENVL_VIDEO_READER"] = "decord"
38
+ except ImportError:
39
+ pass
40
+ from qwen_vl_utils import process_vision_info
41
+
42
+ _, videos, _ = process_vision_info(
43
+ [messages], return_video_kwargs=True, image_patch_size=16, return_video_metadata=True
44
+ )
45
+ inputs = []
46
+ for frames, metadata in videos:
47
+ metadata = dict(metadata)
48
+ metadata.setdefault("duration", metadata["total_num_frames"] / metadata["fps"])
49
+ metadata["do_sample_frames"] = False
50
+ inputs.append((frames.numpy(), metadata))
51
+ return inputs
52
+
53
+
54
+ def main(argv=None) -> int:
55
+ p = base_parser("FLORA rewarder: rubric scores with thinking.", tensor_parallel_size=2)
56
+ p.add_argument("--num-samples", type=int, default=9, help="Answers sampled per episode; the medoid is reported.")
57
+ p.add_argument("--fps", type=float, default=core.RUBRIC_FPS, help="Frames per second read from each camera.")
58
+ p.add_argument("--max-frames", type=int, default=core.RUBRIC_MAX_FRAMES, help="Frame cap per camera.")
59
+ p.add_argument("--temperature", type=float, default=1.0)
60
+ p.add_argument("--max-new-tokens", type=int, default=20480)
61
+ p.add_argument("--max-model-len", type=int, default=98304)
62
+ p.add_argument("--max-num-batched-tokens", type=int, default=24576)
63
+ p.add_argument("--keep-thinking", action="store_true", help="Write every sample's thinking trace.")
64
+ p.set_defaults(gpu_memory_utilization=0.85)
65
+ args = p.parse_args(argv)
66
+ jobs = read_jobs(args, extra_fields=("robot", "cameras", "rubric", "task_name", "header"))
67
+ for job in jobs:
68
+ if "rubric" not in job or not ({"robot", "cameras"} <= set(job) or "header" in job):
69
+ raise SystemExit(f"{job['id']}: rubric jobs need 'rubric' and 'robot' + 'cameras' (or 'header')")
70
+ if "cameras" in job and len(job["cameras"]) != len(job["videos"]):
71
+ raise SystemExit(f"{job['id']}: give one camera description per video")
72
+ model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
73
+ processor = load_processor(model_path)
74
+
75
+ def prepare(job):
76
+ header = job.get("header") or core.rubric_header(job["robot"], job["cameras"])
77
+ prompt = core.rubric_prompt(job["task"], job["rubric"], header, job.get("task_name"))
78
+ messages = core.rubric_messages(prompt, job["videos"], fps=args.fps, max_frames=args.max_frames)
79
+ return render(processor, messages, thinking=True, vision_ids=True), messages
80
+
81
+ if args.dry_run:
82
+ text, messages = prepare(jobs[0])
83
+ frames = [(tuple(f.shape), m["fps"]) for f, m in video_inputs(messages)]
84
+ log(f"{jobs[0]['id']}: camera frames {frames}\n{text}")
85
+ return 0
86
+
87
+ from vllm import SamplingParams
88
+
89
+ llm = load_llm(model_path, args, max_model_len=args.max_model_len,
90
+ videos_per_prompt=max(len(job["videos"]) for job in jobs),
91
+ mm_processor_kwargs={"do_resize": False},
92
+ max_num_batched_tokens=args.max_num_batched_tokens)
93
+ params = SamplingParams(n=args.num_samples, temperature=args.temperature, top_p=1.0, top_k=0,
94
+ max_tokens=args.max_new_tokens)
95
+ with open_output(args) as stream:
96
+ for number, job in enumerate(jobs, 1):
97
+ text, messages = prepare(job)
98
+ request = {"prompt": text, "multi_modal_data": {"video": video_inputs(messages)}}
99
+ output = llm.generate([request], params, use_tqdm=False)[0]
100
+ samples = []
101
+ for completion in output.outputs:
102
+ answer, thinking = core.split_thinking(completion.text)
103
+ scores, ok = core.parse_rubric(answer, job["rubric"])
104
+ sample = {"scores": scores, "parsed": ok, "answer": answer,
105
+ "generated_tokens": len(completion.token_ids)}
106
+ if args.keep_thinking:
107
+ sample["thinking"] = thinking
108
+ samples.append(sample)
109
+ parsed = [i for i, s in enumerate(samples) if s["parsed"]]
110
+ best = parsed[core.medoid([samples[i]["scores"] for i in parsed])] if parsed else None
111
+ scores = samples[best]["scores"] if best is not None else None
112
+ write_record(stream, {
113
+ "id": job["id"],
114
+ "task": job["task"],
115
+ "steps": [step["action"] for step in job["rubric"]],
116
+ "scores": scores,
117
+ "total": round(sum(scores), 4) if scores is not None else None,
118
+ "medoid_sample": best,
119
+ "parsed_samples": len(parsed),
120
+ "samples": samples,
121
+ "prompt_tokens": len(output.prompt_token_ids),
122
+ })
123
+ log(f"rubric: {number}/{len(jobs)} episodes, {len(parsed)}/{len(samples)} answers parsed")
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
batch_utils/terminal_reward.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Terminal reward: one 1-5 success score for each whole episode.
2
+
3
+ python -m batch_utils.terminal_reward --jobs jobs.jsonl --output terminal.jsonl
4
+ python -m batch_utils.terminal_reward --video top.mp4 wrist.mp4 --task "Put the cup on the tray." --output terminal.jsonl
5
+
6
+ Each job is ``{"id": ..., "task": ..., "videos": [camera videos, in collage order]}``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from . import core
12
+ from .engine import (
13
+ COLLAGE_VIDEO_SIZE,
14
+ base_parser,
15
+ load_llm,
16
+ load_processor,
17
+ log,
18
+ open_output,
19
+ read_jobs,
20
+ render,
21
+ resolve_model,
22
+ write_record,
23
+ )
24
+
25
+
26
+ def main(argv=None) -> int:
27
+ p = base_parser("FLORA rewarder: terminal (episode success) score, 1-5.")
28
+ p.add_argument("--with-rationale", action="store_true", help="Ask for a one-line rationale before the score.")
29
+ p.add_argument("--max-model-len", type=int, default=16384)
30
+ args = p.parse_args(argv)
31
+ jobs = read_jobs(args)
32
+ model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
33
+ processor = load_processor(model_path)
34
+
35
+ prepared = []
36
+ for job in jobs:
37
+ collage = core.Collage(job["videos"])
38
+ indices = core.terminal_frame_indices(collage.num_frames, collage.fps)
39
+ messages = core.terminal_messages(job["task"], args.with_rationale)
40
+ prepared.append((job, collage, indices, render(processor, messages, thinking=False)))
41
+ if args.dry_run:
42
+ job, collage, indices, prompt = prepared[0]
43
+ log(f"{job['id']}: {collage.num_frames} frames at {collage.fps:g} FPS, "
44
+ f"collage {collage.width}x{collage.height}, {len(indices)} sampled frames\n{prompt}")
45
+ return 0
46
+
47
+ from vllm import SamplingParams
48
+ from vllm.sampling_params import StructuredOutputsParams
49
+
50
+ llm = load_llm(model_path, args, max_model_len=args.max_model_len,
51
+ mm_processor_kwargs={"do_sample_frames": False, "size": COLLAGE_VIDEO_SIZE},
52
+ max_logprobs=core.TERMINAL_TOP_LOGPROBS)
53
+ tokenizer = llm.get_tokenizer()
54
+ params = SamplingParams(
55
+ temperature=0.0,
56
+ max_tokens=core.terminal_max_tokens(args.with_rationale),
57
+ logprobs=core.TERMINAL_TOP_LOGPROBS,
58
+ structured_outputs=StructuredOutputsParams(regex=core.terminal_regex(args.with_rationale)),
59
+ )
60
+ with open_output(args) as stream:
61
+ for start in range(0, len(prepared), args.max_num_seqs):
62
+ batch = prepared[start : start + args.max_num_seqs]
63
+ requests = [
64
+ {
65
+ "prompt": prompt,
66
+ "multi_modal_data": {
67
+ "video": (collage.frames(indices), core.video_metadata(indices, collage.fps, collage.num_frames))
68
+ },
69
+ "mm_processor_kwargs": {"do_sample_frames": False},
70
+ }
71
+ for _, collage, indices, prompt in batch
72
+ ]
73
+ outputs = llm.generate(requests, params, use_tqdm=False)
74
+ for _, collage, _, _ in batch:
75
+ collage.release()
76
+ for (job, collage, indices, _), output in zip(batch, outputs):
77
+ completion = output.outputs[0]
78
+ score, rationale = core.parse_terminal(completion.text, args.with_rationale)
79
+ positions = [
80
+ (
81
+ tokenizer.decode([token]),
82
+ float(candidates[token].logprob),
83
+ [(tokenizer.decode([i]), float(c.logprob)) for i, c in candidates.items()],
84
+ )
85
+ for token, candidates in zip(completion.token_ids, completion.logprobs)
86
+ ]
87
+ probabilities, expected = core.score_probabilities(positions, score)
88
+ write_record(stream, {
89
+ "id": job["id"],
90
+ "task": job["task"],
91
+ "score": score,
92
+ "expected_score": round(expected, 4),
93
+ # Expected score rescaled to [0, 1]: a progress value, not a success probability.
94
+ "progress": round((expected - 1.0) / 4.0, 4),
95
+ "probabilities": {k: round(v, 4) for k, v in probabilities.items()},
96
+ "rationale": rationale,
97
+ "duration_s": round(collage.duration_s, 3),
98
+ "sampled_frames": len(indices),
99
+ "output": completion.text,
100
+ })
101
+ log(f"terminal: {min(start + len(batch), len(prepared))}/{len(prepared)} episodes")
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ raise SystemExit(main())