11# SPDX-License-Identifier: Apache-2.0
2- """Pure utilities for reward-enhanced H3 trajectory distillation.
2+ """Pure utilities for H3 reward-enhanced scored- trajectory distillation.
33
4- The functions in this module deliberately have no FastVideo model dependency so
5- that the REST/AMD mathematics and cache fingerprints can be audited on CPU.
4+ The functions in this module intentionally contain no FastVideo runtime or
5+ model dependencies. Cache builders and the training method share this exact
6+ math so the offline reward contract cannot drift from the optimizer contract.
67"""
78
89from __future__ import annotations
910
10- from collections .abc import Mapping , Sequence
1111import hashlib
1212import json
13+ import math
14+ from collections .abc import Mapping , Sequence
1315from typing import Any
1416
1517import torch
1618
1719
18- def normalize_reward_weights (weights : Mapping [ str , float ]) -> dict [ str , float ]:
19- """Return non-negative reward weights normalized to sum to one."""
20- if not weights :
21- raise ValueError ( " reward weights must be nonempty" )
20+ def normalize_reward_weights (
21+ reward_weights : Mapping [ str , float ],
22+ ) -> dict [ str , float ] :
23+ """Return finite nonnegative reward weights normalized to sum to one."""
2224 normalized : dict [str , float ] = {}
23- total = 0.0
24- for raw_name , raw_weight in weights .items ():
25+ for raw_name , raw_weight in reward_weights .items ():
2526 name = str (raw_name ).strip ().lower ()
2627 if not name :
27- raise ValueError ("reward names must be nonempty" )
28+ raise ValueError ("Reward names must be nonempty" )
29+ if name in normalized :
30+ raise ValueError (f"Duplicate canonical reward name: { name !r} " )
2831 weight = float (raw_weight )
29- if not torch .isfinite (torch . tensor ( weight )) :
30- raise ValueError (f"reward weight for { name !r } must be finite" )
31- if weight < 0.0 :
32- raise ValueError ( f"reward weight for { name !r } must be nonnegative" )
32+ if not math .isfinite (weight ) or weight < 0.0 :
33+ raise ValueError (
34+ f"Reward weight for { name !r } must be finite and nonnegative, got { raw_weight !r } "
35+ )
3336 normalized [name ] = weight
34- total += weight
37+
38+ total = sum (normalized .values ())
3539 if total <= 0.0 :
36- raise ValueError ("at least one reward weight must be positive" )
40+ raise ValueError ("At least one reward weight must be positive" )
3741 return {name : weight / total for name , weight in normalized .items ()}
3842
3943
@@ -43,146 +47,249 @@ def group_relative_advantages(
4347 * ,
4448 eps : float = 1e-6 ,
4549 clip : float = 1.0 ,
46- ) -> tuple [torch . Tensor , dict [str , torch .Tensor ]]:
47- """Compute REST/AMD group-normalized advantages for one prompt .
50+ ) -> tuple [dict [str , torch .Tensor ], torch . Tensor ]:
51+ """Compute REST per-reward group advantages and their weighted fusion .
4852
49- Every reward source is standardized independently across the ``K`` teacher
50- rollouts, clipped to ``[-clip, clip]``, and only then mixed using normalized
51- non-negative weights. Constant reward components contribute exactly zero.
53+ For each reward source ``j`` and candidate ``i`` in one prompt group,
54+
55+ ``A[j, i] = clip((r[j, i] - mean_j) / (std_j + eps), -clip, clip)``.
56+
57+ Constant reward components contribute exactly zero. The returned mixed
58+ advantage is a convex combination using normalized nonnegative weights.
59+ Population standard deviation is used because the candidates are the full
60+ group whose relative scores define the update.
5261 """
53- if eps <= 0.0 :
54- raise ValueError ("eps must be positive" )
55- if clip <= 0.0 :
56- raise ValueError ("clip must be positive" )
62+ if eps <= 0.0 or not math .isfinite (float (eps )):
63+ raise ValueError (f"eps must be finite and positive, got { eps !r} " )
64+ if clip <= 0.0 or not math .isfinite (float (clip )):
65+ raise ValueError (f"clip must be finite and positive, got { clip !r} " )
66+
5767 weights = normalize_reward_weights (reward_weights )
58- missing = sorted (set (weights ) - set (reward_scores ))
59- if missing :
60- raise ValueError (f"missing reward scores for { missing } " )
61-
62- expected_k : int | None = None
63- per_reward : dict [str , torch .Tensor ] = {}
64- for name in weights :
65- scores = torch .as_tensor (reward_scores [name ], dtype = torch .float32 )
66- if scores .ndim != 1 :
67- raise ValueError (f"reward { name !r} must have shape [K], got { tuple (scores .shape )} " )
68- if scores .numel () < 2 :
69- raise ValueError ("REST/AMD requires at least two rollouts per prompt" )
68+ canonical_scores : dict [str , torch .Tensor ] = {}
69+ for raw_name , scores in reward_scores .items ():
70+ name = str (raw_name ).strip ().lower ()
71+ if not name :
72+ raise ValueError ("Reward score names must be nonempty" )
73+ if name in canonical_scores :
74+ raise ValueError (f"Duplicate canonical reward score name: { name !r} " )
75+ canonical_scores [name ] = scores
76+
77+ missing = sorted (set (weights ) - set (canonical_scores ))
78+ extra = sorted (set (canonical_scores ) - set (weights ))
79+ if missing or extra :
80+ raise ValueError (
81+ "Reward score/weight keys must match exactly: "
82+ f"missing_scores={ missing } , unexpected_scores={ extra } "
83+ )
84+
85+ expected_shape : tuple [int , ...] | None = None
86+ expected_device : torch .device | None = None
87+ advantages : dict [str , torch .Tensor ] = {}
88+ mixed : torch .Tensor | None = None
89+ for name , weight in weights .items ():
90+ scores = torch .as_tensor (canonical_scores [name ]).detach ().float ()
91+ if scores .ndim != 1 or scores .numel () < 2 :
92+ raise ValueError (
93+ f"Reward { name !r} must have shape [K] with K >= 2, got { tuple (scores .shape )} "
94+ )
7095 if not bool (torch .isfinite (scores ).all ()):
71- raise ValueError (f"reward { name !r} contains non-finite values" )
72- if expected_k is None :
73- expected_k = int (scores .numel ())
74- elif scores .numel () != expected_k :
75- raise ValueError ("all reward components must have the same K" )
96+ raise ValueError (f"Reward { name !r} contains NaN or Inf" )
97+ if expected_shape is None :
98+ expected_shape = tuple (scores .shape )
99+ expected_device = scores .device
100+ elif tuple (scores .shape ) != expected_shape :
101+ raise ValueError (
102+ f"All reward vectors must share one shape: expected={ expected_shape } , "
103+ f"got { name } ={ tuple (scores .shape )} "
104+ )
105+ elif scores .device != expected_device :
106+ raise ValueError (
107+ f"All reward vectors must share one device: expected={ expected_device } , "
108+ f"got { name } ={ scores .device } "
109+ )
76110
77111 centered = scores - scores .mean ()
78- std = centered . square (). mean (). sqrt ( )
79- if float (std ) <= eps :
112+ std = scores . std ( unbiased = False )
113+ if float (std ) <= float ( eps ) :
80114 advantage = torch .zeros_like (scores )
81115 else :
82- advantage = (centered / std ).clamp (- clip , clip )
83- per_reward [name ] = advantage
116+ advantage = (centered / (std + float (eps ))).clamp (- float (clip ), float (clip ))
117+ advantages [name ] = advantage
118+ weighted = advantage * float (weight )
119+ mixed = weighted if mixed is None else mixed + weighted
84120
85- assert expected_k is not None
86- mixed = torch .zeros (expected_k , dtype = torch .float32 )
87- for name , weight in weights .items ():
88- mixed = mixed + float (weight ) * per_reward [name ]
89- return mixed , per_reward
121+ assert mixed is not None
122+ return advantages , mixed
90123
91124
92125def amd_coefficients (
93126 mixed_advantage : torch .Tensor ,
94127 * ,
95128 scale : float = 1.0 ,
96129 bias : float = 0.5 ,
130+ clip : float | None = None ,
97131) -> torch .Tensor :
98- """Apply the REST affine modulation ``lambda * (A_mix + b)``."""
99- if scale < 0.0 :
100- raise ValueError ("AMD scale must be nonnegative" )
101- if bias < 0.0 :
102- raise ValueError ("AMD bias must be nonnegative" )
103- advantage = torch .as_tensor (mixed_advantage , dtype = torch .float32 )
104- if not bool (torch .isfinite (advantage ).all ()):
105- raise ValueError ("mixed advantages must be finite" )
106- return float (scale ) * (advantage + float (bias ))
132+ """Return REST's signed advantage-modulated distillation coefficient.
133+
134+ The paper uses ``lambda * (A + b)`` with default ``lambda=1`` and
135+ ``b=0.5``. An optional symmetric coefficient clip is an explicit
136+ numerical-safety ablation; ``None`` preserves the published expression.
137+ """
138+ scale = float (scale )
139+ bias = float (bias )
140+ if not math .isfinite (scale ) or scale < 0.0 :
141+ raise ValueError (f"scale must be finite and nonnegative, got { scale !r} " )
142+ if not math .isfinite (bias ):
143+ raise ValueError (f"bias must be finite, got { bias !r} " )
144+ value = torch .as_tensor (mixed_advantage ).float ()
145+ if not bool (torch .isfinite (value ).all ()):
146+ raise ValueError ("mixed_advantage contains NaN or Inf" )
147+ coefficient = scale * (value + bias )
148+ if clip is not None :
149+ clip = float (clip )
150+ if not math .isfinite (clip ) or clip <= 0.0 :
151+ raise ValueError (f"clip must be finite and positive, got { clip !r} " )
152+ coefficient = coefficient .clamp (- clip , clip )
153+ return coefficient
107154
108155
109156def signed_loss_surrogate (
110157 per_sample_loss : torch .Tensor ,
111- coefficient : torch .Tensor | float ,
158+ coefficient : torch .Tensor ,
112159) -> torch .Tensor :
113- """Return a non-negative scalar whose gradient is signed AMD regression .
160+ """Keep a nonnegative forward value while preserving a signed gradient .
114161
115- A literal negative-weight MSE makes logged losses negative and can confuse
116- generic trainer checks. This detached-value surrogate has value
117- ``|c| * loss`` but derivative ``c * d(loss)/d(theta)``.
162+ The returned scalar has forward value ``mean(abs(c) * L)`` and derivative
163+ ``mean(c * dL)``. This avoids exposing optimizers/loggers to an unbounded
164+ negative scalar loss while producing the exact REST/AMD signed update.
165+ ``coefficient`` is always detached: rewards never receive gradients.
118166 """
119- losses = torch .as_tensor (per_sample_loss )
120- coefficients = torch .as_tensor (coefficient , device = losses .device , dtype = losses .dtype )
167+ if per_sample_loss .ndim == 0 :
168+ per_sample_loss = per_sample_loss .reshape (1 )
169+ coefficient = torch .as_tensor (
170+ coefficient ,
171+ device = per_sample_loss .device ,
172+ dtype = per_sample_loss .dtype ,
173+ ).detach ()
174+ if coefficient .ndim == 0 :
175+ coefficient = coefficient .reshape (1 )
121176 try :
122- coefficients = torch .broadcast_to (coefficients , losses .shape )
177+ coefficient = torch .broadcast_to (coefficient , per_sample_loss .shape )
123178 except RuntimeError as exc :
124179 raise ValueError (
125- f"coefficient shape { tuple (coefficients .shape )} cannot broadcast to loss shape { tuple (losses .shape )} "
180+ "coefficient must broadcast to per_sample_loss: "
181+ f"loss={ tuple (per_sample_loss .shape )} , coefficient={ tuple (coefficient .shape )} "
126182 ) from exc
127- return (coefficients .abs () * losses .detach () + coefficients * (losses - losses .detach ())).mean ()
183+ if not bool (torch .isfinite (per_sample_loss ).all ()):
184+ raise ValueError ("per_sample_loss contains NaN or Inf" )
185+ if not bool (torch .isfinite (coefficient ).all ()):
186+ raise ValueError ("coefficient contains NaN or Inf" )
187+
188+ signed = coefficient * per_sample_loss
189+ forward_value = coefficient .abs () * per_sample_loss .detach ()
190+ return (signed + (forward_value - signed .detach ())).mean ()
128191
129192
130193def segment_velocity_target (
131- current : torch .Tensor ,
194+ current_state : torch .Tensor ,
132195 next_state : torch .Tensor ,
133196 sigma_current : torch .Tensor | float ,
134197 sigma_next : torch .Tensor | float ,
198+ * ,
199+ eps : float = 1e-8 ,
135200) -> torch .Tensor :
136- """Compute a teacher segment slope in the modality's shifted H3 sigma."""
137- if current .shape != next_state .shape :
138- raise ValueError (f"segment states must match, got { tuple (current .shape )} and { tuple (next_state .shape )} " )
139- sigma0 = torch .as_tensor (sigma_current , device = current .device , dtype = torch .float32 )
140- sigma1 = torch .as_tensor (sigma_next , device = current .device , dtype = torch .float32 )
141- delta = sigma1 - sigma0
142- if bool ((delta .abs () <= torch .finfo (torch .float32 ).eps ).any ()):
143- raise ValueError ("segment sigma endpoints must be distinct" )
144- while delta .ndim < current .ndim :
201+ """Convert two trajectory anchors into a finite-difference flow target.
202+
203+ ``v = (x_next - x_current) / (sigma_next - sigma_current)``.
204+
205+ H3 video and audio use different shifted sigma schedules, so callers must
206+ invoke this function separately for each modality with that modality's
207+ sigma pair. Using the raw base-timestep delta is incorrect.
208+ """
209+ if current_state .shape != next_state .shape :
210+ raise ValueError (
211+ "Trajectory anchors must share a shape, got "
212+ f"{ tuple (current_state .shape )} and { tuple (next_state .shape )} "
213+ )
214+ if eps <= 0.0 :
215+ raise ValueError (f"eps must be positive, got { eps !r} " )
216+ sigma_current = torch .as_tensor (
217+ sigma_current ,
218+ device = current_state .device ,
219+ dtype = torch .float32 ,
220+ )
221+ sigma_next = torch .as_tensor (
222+ sigma_next ,
223+ device = current_state .device ,
224+ dtype = torch .float32 ,
225+ )
226+ delta = sigma_next - sigma_current
227+ if not bool (torch .isfinite (delta ).all ()):
228+ raise ValueError ("Sigma delta contains NaN or Inf" )
229+ if bool ((delta .abs () <= float (eps )).any ()):
230+ raise ValueError (
231+ "Trajectory segment has a zero/degenerate sigma interval: "
232+ f"sigma_current={ sigma_current } , sigma_next={ sigma_next } "
233+ )
234+ while delta .ndim < current_state .ndim :
145235 delta = delta .unsqueeze (- 1 )
146- return (( next_state .float () - current .float ()) / delta ). to ( current . dtype )
236+ return (next_state .float () - current_state .float ()) / delta
147237
148238
149239def build_piecewise_teacher_schedule (
150- student_timesteps : Sequence [float | int ],
151- * ,
240+ student_timesteps : Sequence [int | float ],
152241 substeps_per_segment : int ,
153242) -> tuple [float , ...]:
154- """Build a dense schedule that contains every deployed student boundary ."""
243+ """Build a dense schedule whose boundaries exactly match the student grid ."""
155244 anchors = tuple (float (value ) for value in student_timesteps )
156245 if len (anchors ) < 2 :
157246 raise ValueError ("student_timesteps must contain at least two boundaries" )
158- if any (left <= right for left , right in zip (anchors , anchors [1 :], strict = True )):
159- raise ValueError ("student_timesteps must be strictly decreasing" )
160- if anchors [- 1 ] != 0.0 :
161- raise ValueError ("student_timesteps must end at terminal zero" )
162- if substeps_per_segment < 1 :
163- raise ValueError ("substeps_per_segment must be at least one" )
164-
165- schedule : list [float ] = []
166- for start , end in zip (anchors , anchors [1 :], strict = True ):
167- width = end - start
168- for index in range (substeps_per_segment ):
169- schedule .append (start + width * (index / substeps_per_segment ))
170- schedule .append (anchors [- 1 ])
247+ if substeps_per_segment <= 0 :
248+ raise ValueError ("substeps_per_segment must be positive" )
249+ if any (not math .isfinite (value ) for value in anchors ):
250+ raise ValueError ("student_timesteps must be finite" )
251+ if any (
252+ left <= right
253+ for left , right in zip (anchors [:- 1 ], anchors [1 :], strict = True )
254+ ):
255+ raise ValueError (
256+ "student_timesteps must be strictly descending, got "
257+ f"{ list (student_timesteps )} "
258+ )
259+
260+ schedule : list [float ] = [anchors [0 ]]
261+ for segment_index in range (len (anchors ) - 1 ):
262+ start = anchors [segment_index ]
263+ end = anchors [segment_index + 1 ]
264+ for substep in range (1 , substeps_per_segment + 1 ):
265+ fraction = substep / substeps_per_segment
266+ schedule .append (start + fraction * (end - start ))
171267 return tuple (schedule )
172268
173269
174- def teacher_anchor_indices (num_student_segments : int , substeps_per_segment : int ) -> tuple [int , ...]:
175- """Return dense-trajectory indices corresponding to student boundaries."""
176- if num_student_segments < 1 :
270+ def teacher_anchor_indices (
271+ num_student_segments : int ,
272+ substeps_per_segment : int ,
273+ ) -> tuple [int , ...]:
274+ """Indices of student boundaries in a piecewise dense teacher schedule."""
275+ if num_student_segments <= 0 :
177276 raise ValueError ("num_student_segments must be positive" )
178- if substeps_per_segment < 1 :
277+ if substeps_per_segment <= 0 :
179278 raise ValueError ("substeps_per_segment must be positive" )
180- return tuple (index * substeps_per_segment for index in range (num_student_segments + 1 ))
279+ return tuple (
280+ index * substeps_per_segment for index in range (num_student_segments + 1 )
281+ )
181282
182283
183- def canonical_json_hash (payload : Mapping [str , Any ]) -> str :
184- """SHA-256 of a JSON-compatible mapping with deterministic formatting."""
185- encoded = json .dumps (payload , sort_keys = True , separators = ("," , ":" ), ensure_ascii = True ).encode ("utf-8" )
284+ def canonical_json_hash (payload : Any ) -> str :
285+ """Return SHA-256 over deterministic UTF-8 JSON for provenance locks."""
286+ encoded = json .dumps (
287+ payload ,
288+ sort_keys = True ,
289+ separators = ("," , ":" ),
290+ ensure_ascii = False ,
291+ allow_nan = False ,
292+ ).encode ("utf-8" )
186293 return hashlib .sha256 (encoded ).hexdigest ()
187294
188295
0 commit comments