File size: 11,623 Bytes
9d5790d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""Real-Time Chunking (RTC) for MolmoAct2 — inference-time, no retraining.

RTC (arXiv 2506.07339) removes the discontinuity you get when a freshly generated
action chunk replaces the one currently executing. It treats the overlap as an
INPAINTING problem during flow sampling: the actions that will inevitably execute
while we're computing are pinned to the previous chunk, a middle band is softly
guided toward it, and the tail beyond the old chunk is generated freely.

    idx <  d          "frozen"  weight 1      (executes during the inference delay)
    d <= idx < s      "guided"  weight 1->0   (EXP schedule)
    idx >= s          "free"    weight 0

Guidance is NOT an overwrite. Following the paper (and LeRobot's reference
implementation) it is a pseudoinverse-guidance (PiGDM) correction injected into the
velocity field at EVERY denoising step, via a vector-Jacobian product:

    x1     = x_t + (1 - tau) * v            # predicted clean sample
    err    = (prev_chunk - x1) * W          # weighted target error
    corr   = VJP(x1, x_t, err)              # d x1/d x_t ^T @ err
    v_rtc  = v + w(tau) * corr
    w(tau) = min(beta, ((1-tau)^2 + tau^2) / (tau * (1-tau)))

SIGN NOTE (the highest-risk part of this port): LeRobot integrates time 1->0 with a
velocity pointing toward NOISE and writes `v - w*corr`. MolmoAct2 integrates 0->1
with a velocity pointing toward DATA (`trajectory = trajectory + dt * velocity`), so
the correction ADDS here. Both end up moving the trajectory by +corr; only the
velocity convention differs. `selftest_guidance_direction()` asserts this
empirically rather than trusting the derivation.

COSTS (measured/reported, worth knowing before enabling):
  * needs autograd -> the model's @torch.no_grad() must be lifted, and the
    CUDA-graph fast path must be disabled (you cannot backprop a captured graph).
  * ~20% extra latency on top of that. We are already NETWORK-bound, so RTC is
    only worth enabling once the round-trip is short.
  * requires d <= s <= H - d. With H=30, s=10: d <= 10. At 10fps that's ~1.0s of
    tolerable delay; at 15fps only ~0.66s.
"""

import math
from typing import Optional

import torch


def prefix_weights(
    delay: int, execution_horizon: int, total: int, schedule: str = "exp"
) -> torch.Tensor:
    """Per-timestep guidance weights (port of LeRobot's get_prefix_weights).

    `delay` (d) actions are pinned at 1.0; weights decay to 0 by `execution_horizon`
    (s); everything at/after s is free (0.0)."""
    start = min(delay, execution_horizon)
    end = execution_horizon
    if schedule == "zeros":
        w = torch.zeros(total)
        w[:start] = 1.0
        return w
    if schedule == "ones":
        w = torch.ones(total)
        w[end:] = 0.0
        return w

    # linear ramp over the guided band, exclusive of the 1.0 and 0.0 endpoints
    skip = max(total - end, 0)
    steps = total - skip - start
    lin = torch.linspace(1, 0, steps + 2)[1:-1] if (end > start and steps > 0) else torch.tensor([])
    if schedule == "exp":
        # decay harder than linear: w * expm1(w) / (e - 1)
        lin = lin * torch.expm1(lin).div(math.e - 1)
    if total - end > 0:
        lin = torch.cat([lin, torch.zeros(total - end)])
    if min(start, total) > 0:
        lin = torch.cat([torch.ones(min(start, total)), lin])
    return lin


def guidance_weight(tau: float, max_weight: float) -> float:
    """w(tau) = min(beta, ((1-tau)^2 + tau^2) / (tau*(1-tau))), clamped at both ends."""
    one_minus = 1.0 - tau
    if tau <= 0.0 or one_minus <= 0.0:
        return float(max_weight)
    w = ((one_minus ** 2) + (tau ** 2)) / (tau * one_minus)
    return float(min(w, max_weight))


def feasible(delay: int, horizon: int, execution_horizon: int) -> bool:
    """RTC needs d <= s <= H - d. Past that the frozen prefix and the free tail
    overlap and the guidance is not well defined — better to skip RTC for that
    request than to emit a silently wrong chunk."""
    return 0 <= delay <= execution_horizon <= horizon - delay


def pick_execution_horizon(delay: int, horizon: int) -> Optional[int]:
    """Smallest feasible s that leaves the guided band some room, or None if the
    delay is too large for this horizon (d > H/2)."""
    s = min(max(delay + 4, delay), horizon - delay)
    return s if feasible(delay, horizon, s) else None


class RTCState:
    """Per-session guidance target, kept in the model's NORMALIZED action space.

    We cache the previous chunk as the raw flow output rather than asking the client
    to send actions back: the flow operates on normalized actions, so a robot-scale
    chunk from the client would have to be re-normalized (and our client also applies
    a joint calibration). Caching the model's own output sidesteps both."""

    def __init__(self) -> None:
        self.prev: Optional[torch.Tensor] = None  # (B, H, A) normalized
        self.enabled = False
        self.consumed = 0        # actions executed since `prev` was produced (= alignment shift)
        self.delay = 0           # d: actions that will execute during THIS inference
        self.execution_horizon = 10
        self.max_guidance_weight = 10.0
        self.schedule = "exp"
        self.applied = 0         # count of guided steps, for observability

    def target(self, like: torch.Tensor) -> Optional[torch.Tensor]:
        """Previous chunk aligned to the new chunk's timeline: drop the `consumed`
        actions that already executed, then zero-pad to the new chunk's shape."""
        if not self.enabled or self.prev is None:
            return None
        left = self.prev[:, self.consumed:, :]
        if left.shape[1] == 0:
            return None
        out = torch.zeros_like(like)
        n = min(left.shape[1], out.shape[1])
        a = min(left.shape[2], out.shape[2])
        out[:, :n, :a] = left[:, :n, :a].to(out.device, out.dtype)
        return out


def _flow_owner(model):
    """Find the object that actually owns the flow loop.

    `_run_action_flow_loop`, `_require_action_expert` and `_mask_action_dim_tensor`
    are all methods of MolmoAct2Model, while what you load from the Hub is a
    MolmoAct2ForConditionalGeneration that HOLDS one as `.model`. Patching the
    outer wrapper raises AttributeError at load time and takes the whole server
    down, so resolve the owner instead of assuming it."""
    for cand in (model, getattr(model, "model", None), getattr(model, "base_model", None)):
        if cand is not None and hasattr(cand, "_run_action_flow_loop"):
            return cand
    return None


def install_rtc(model, state: RTCState):
    """Monkeypatch the flow loop to apply RTC guidance.

    Returns the original bound method, or None if the loop could not be located —
    NEVER raises. A diagnostic feature must not be able to stop the model from
    loading; without the patch the server simply serves un-guided chunks.

    The patched loop is a no-op (bit-identical to upstream) whenever `state` has no
    target, so leaving it installed costs nothing when RTC is off."""
    owner = _flow_owner(model)
    if owner is None:
        return None
    original = owner._run_action_flow_loop

    def guided_loop(inputs, steps: int) -> torch.Tensor:
        trajectory = inputs.trajectory
        target = state.target(trajectory)
        if target is None:
            # No usable prefix (first call of a session, or the previous chunk is
            # fully consumed). Run upstream verbatim — but still CAPTURE the output,
            # or the next call has nothing to guide toward.
            out = original(inputs, steps)
            state.prev = out.detach()
            return out

        action_expert = owner._require_action_expert()
        dt = 1.0 / steps
        pad = inputs.action_dim_is_pad
        mask_enabled = owner.config.mask_action_dim_padding
        W = prefix_weights(state.delay, state.execution_horizon,
                           trajectory.shape[1], state.schedule)
        W = W.to(trajectory.device, trajectory.dtype).view(1, -1, 1)

        for idx in range(steps):
            tau = idx / steps  # 0 = noise, 1 = data (MolmoAct2 integrates forward)
            x_t = trajectory.detach().requires_grad_(True)
            with torch.enable_grad():
                velocity = action_expert.forward_with_context(
                    x_t,
                    inputs.modulations[idx].conditioning,
                    context=inputs.context,
                    modulation=inputs.modulations[idx],
                )
                velocity = owner._mask_action_dim_tensor(
                    velocity, action_dim_is_pad=pad, enabled=mask_enabled
                )
                x1 = x_t + (1.0 - tau) * velocity          # predicted clean sample
                err = ((target - x1) * W).detach()          # weighted pull toward prev
                corr = torch.autograd.grad(x1, x_t, err, retain_graph=False)[0]

            w = guidance_weight(tau, state.max_guidance_weight)
            # + (not -): our velocity points toward DATA -- see SIGN NOTE above.
            velocity = (velocity + w * corr).detach()
            trajectory = owner._mask_action_dim_tensor(
                trajectory.detach() + dt * velocity, action_dim_is_pad=pad, enabled=mask_enabled
            )
            state.applied += 1
        state.prev = trajectory.detach()
        return trajectory

    owner._run_action_flow_loop = guided_loop
    return original


# --------------------------------------------------------------------------- tests
def selftest_guidance_direction() -> dict:
    """Assert the SIGN empirically on a toy linear flow, with no model involved.

    Toy: velocity = (goal - x). Euler-integrating it drives x -> goal. With RTC
    guidance toward `prev`, the FROZEN prefix must end up closer to `prev` than the
    unguided run does, and the free tail must be left alone."""
    H, A, steps = 30, 4, 10
    goal = torch.zeros(1, H, A)
    prev = torch.ones(1, H, A) * 5.0
    W = prefix_weights(4, 10, H, "exp").view(1, -1, 1)

    def run(guided: bool) -> torch.Tensor:
        x = torch.full((1, H, A), -5.0)
        for idx in range(steps):
            tau = idx / steps
            xt = x.detach().requires_grad_(True)
            with torch.enable_grad():
                v = goal - xt
                x1 = xt + (1.0 - tau) * v
                if guided:
                    err = ((prev - x1) * W).detach()
                    corr = torch.autograd.grad(x1, xt, err, retain_graph=False)[0]
                    v = v + guidance_weight(tau, 10.0) * corr
            x = (x.detach() + (1.0 / steps) * v.detach())
        return x

    plain, rtc = run(False), run(True)
    d_plain = (plain[0, 0] - prev[0, 0]).abs().mean().item()
    d_rtc = (rtc[0, 0] - prev[0, 0]).abs().mean().item()
    tail_shift = (rtc[0, -1] - plain[0, -1]).abs().mean().item()
    return {
        "prefix_dist_unguided": round(d_plain, 4),
        "prefix_dist_rtc": round(d_rtc, 4),
        "prefix_pulled_toward_prev": d_rtc < d_plain,
        "free_tail_unchanged": tail_shift < 1e-5,
    }


if __name__ == "__main__":
    w = prefix_weights(4, 10, 30, "exp")
    print("weights[:12]:", [round(float(x), 3) for x in w[:12]])
    print("frozen prefix all 1.0 :", bool((w[:4] == 1.0).all()))
    print("free tail all 0.0     :", bool((w[10:] == 0.0).all()))
    print("monotonic in guided band:", bool((w[4:10].diff() <= 0).all()))
    print("w(tau) mid/edges      :", [round(guidance_weight(t, 10.0), 3) for t in (0.0, 0.1, 0.5, 0.9, 1.0)])
    print("direction selftest    :", selftest_guidance_direction())