roostField / Lab
Curriculum
Phase 06Lesson 3 of 4
80 min
Produce original evidenceWeeks 19–24

The capstone method: a latency-aware adaptive scheduler

Everything you have built converges into the capstone method: a scheduler on the robot host that decides, tick by tick, when to replan and how to switch chunks — driven by predicted inference latency and chunk disagreement.

After this lesson you can
  • Assemble the scheduler design space — two signals crossed with three decisions — and name the three adaptive variants worth building.
  • Derive the replan launch deadline and frozen-prefix length from a calibrated latency quantile, and verify the predictor's coverage.
  • Define a normalized disagreement metric over the chunk overlap window and diagnose its aliasing modes.
  • Lay out the ablation matrix — six methods under clean, delayed, and jittered injection — with guardrails against oscillation and starvation.

You have shipped this system before, under different names. Continuous batching decides when to admit work from predicted service time; speculative decoding decides whether to keep draft tokens from a verifier's disagreement. The capstone method fuses those decisions and points them at a 7-dimensional action stream: a scheduler on the robot host that watches its own latency and its policy's self-consistency, and decides — every 20 ms — when to replan and how to switch chunks. Lesson one pinned the hypothesis, lesson two the protocol, and Phase 05 built the trace layer, injection harness, and scheduling baselines. This lesson assembles the method itself, down to the log your analysis will consume.

Conceptual Foundations: State Space, Continuity, and Normalization

Before deriving the scheduler's logic, we must rigorously define the physical quantities it manipulates. The policy outputs an action chunk, a sequence of HH future actions. In this context, each action atR7a_t \in \mathbb{R}^7 represents a joint position delta (in radians) for the six rotational joints and a gripper command (in meters or normalized units). With an execution rate of 50 Hz, the time step is Δ=20\Delta = 20 ms. Therefore, a chunk of H=50H=50 actions represents exactly 1 second of future motion. The scheduler does not operate on raw pixels or high-dimensional latent states; it operates on this concrete, low-dimensional kinematic stream. This distinction is critical: the scheduler's decisions are constrained by the physical limits of the arm, not just the statistical properties of the policy.

The primary physical constraint governing chunk switching is kinematic continuity. When the scheduler replaces the active chunk with a new one, the transition must not introduce discontinuities in the velocity or acceleration profiles. A discontinuity in position commands at 50 Hz manifests as a step change in the target trajectory. The arm's high-bandwidth current controllers will attempt to track this step, resulting in a torque spike. Mathematically, if aolda^{old} and anewa^{new} are the old and new chunks, the frozen prefix constraint requires that for the first kk actions (where kk is the frozen prefix length), the difference remains below a velocity tolerance ϵvel\epsilon_{vel}: at+jnewat+jold<ϵvel\|a^{new}_{t+j} - a^{old}_{t+j}\| < \epsilon_{vel} for j<kj < k. This is not merely a software preference; it is a hardware safety requirement to prevent mechanical shock and controller saturation.

The second foundational concept is the normalized disagreement metric. The policy is a stochastic process, specifically a flow-matching or diffusion model, which samples from a conditional distribution p(ao)p(a|o). Multimodality in this context means that p(ao)p(a|o) has multiple distinct peaks (modes) for a single observation oo. For example, a grasp task might have a valid 'left approach' mode and a 'right approach' mode. If the policy samples from different modes on consecutive calls, the resulting chunks will differ significantly in action space, even if the scene is static. This is why raw Euclidean distance is an inadequate measure of disagreement: it conflates 'the world changed' with 'the policy sampled a different valid mode.' To decouple these, we normalize the difference by the standard deviation of the actions in the training set.

Dt=jWγjΣ1/2(at+jnewat+jold)2jWγj,Σ=diag(σ12,,σ72)D_t = \frac{\sum_{j \in \mathcal{W}} \gamma^j \| \Sigma^{-1/2} (a^{new}_{t+j} - a^{old}_{t+j}) \|_2}{\sum_{j \in \mathcal{W}} \gamma^j}, \quad \Sigma = \text{diag}(\sigma_1^2, \dots, \sigma_7^2)
The normalized disagreement metric. Σ\Sigma is the diagonal covariance matrix of action dimensions from the training set. γ\gamma is a discount factor (e.g., 0.9) that weights near-term disagreement more heavily.

Consider the dimensional implications. Suppose the standard deviation of a joint delta is σjoint=0.02\sigma_{joint} = 0.02 rad, while the gripper command has σgrip=0.1\sigma_{grip} = 0.1 m. A raw error of 0.05 rad in a joint is 2.5σ2.5\sigma, indicating a significant deviation. However, a raw error of 0.05 m in the gripper is only 0.5σ0.5\sigma, indicating a minor fluctuation. Without Σ1/2\Sigma^{-1/2}, the gripper's larger absolute range would dominate the norm, masking critical joint errors. Normalization ensures that DtD_t is a unitless measure of statistical deviation, allowing a single threshold to apply across all dimensions.

Finally, we address the EWMA variance derivation for the latency predictor. The Exponentially Weighted Moving Average (EWMA) is defined as ^t=αt+(1α)^t1\hat{\ell}_t = \alpha \ell_t + (1-\alpha)\hat{\ell}_{t-1}. Unrolling this recurrence yields ^t=αk=0(1α)ktk\hat{\ell}_t = \alpha \sum_{k=0}^{\infty} (1-\alpha)^k \ell_{t-k}. Assuming the latencies t\ell_t are independent and identically distributed (i.i.d.) with variance σ2\sigma^2, the variance of the estimator is Var[^]=α2k=0(1α)2kσ2\text{Var}[\hat{\ell}] = \alpha^2 \sum_{k=0}^{\infty} (1-\alpha)^{2k} \sigma^2. The sum is a geometric series with ratio r=(1α)2r = (1-\alpha)^2, which converges to 11(1α)2=12αα2=1α(2α)\frac{1}{1-(1-\alpha)^2} = \frac{1}{2\alpha - \alpha^2} = \frac{1}{\alpha(2-\alpha)}. Thus, Var[^]=α2ασ2\text{Var}[\hat{\ell}] = \frac{\alpha}{2-\alpha}\sigma^2. This derivation shows that the EWMA is not a finite-window average but an infinite-memory filter with an effective memory length Neff=2ααN_{eff} = \frac{2-\alpha}{\alpha}.

Worked Example: Scheduler Decision Logic

We now apply these foundations to a concrete scenario. Assume the following state at tick tt: the predicted 90th-percentile latency is L^0.9=132\hat{L}_{0.9} = 132 ms; the time step is Δ=20\Delta = 20 ms; the margin is m=2m = 2; the remaining actions in the active chunk are R=8R = 8; the current disagreement is D=1.5D = 1.5 (with thresholds θlo=1.0,θhi=2.0\theta_{lo}=1.0, \theta_{hi}=2.0); and the refractory period is 10 ticks (200 ms), with the last launch occurring 5 ticks ago.

  1. Calculate Launch Threshold: Rtrigger=132/20+2=7+2=9R_{trigger} = \lceil 132/20 \rceil + 2 = 7 + 2 = 9. Since R=89R = 8 \le 9, the latency condition for launching a replan is met.
  2. Check Refractory Period: Time since last launch is 5×20=1005 \times 20 = 100 ms. The refractory period is 200 ms. Since 100<200100 < 200, the refractory condition is NOT met.
  3. Decision: The scheduler does NOT launch a new replan. It continues executing the current chunk. This prevents a 'replan storm' where rapid successive launches overload the server.
  4. Frozen Prefix Calculation (Hypothetical): If the refractory period had passed, the frozen prefix length would be k=132/20=7k = \lceil 132/20 \rceil = 7. The new chunk would be conditioned to match the old chunk for the next 7 actions (140 ms).
  5. Disagreement Gate: Since no new chunk is generated, DD is not evaluated for adoption. The scheduler waits for the next tick.

This example illustrates the interaction between latency prediction and rate limiting. Even though the latency threshold was crossed, the refractory period prevented a launch. This is a critical safety feature: without it, a jittery latency signal could cause the scheduler to launch a replan every tick, saturating the GPU and increasing latency further, creating a positive feedback loop of failure.

Checkpoint 01

In the worked example, why did the scheduler NOT launch a replan even though RRtriggerR \le R_{trigger}?

The design space: two signals, three decisions

Fix the vocabulary first — a clean design space separates a method from a pile of heuristics. Your executor ticks at 50 Hz (Δ=20\Delta = 20 ms); the policy server turns one observation into a chunk of H=50H = 50 actions in roughly 100 ms of inference — about 150 ms capture-to-first-action, the numbers you measured on your own WidowX-plus-RealSense stack. At every tick the scheduler faces three questions:

  1. When to launch a replan. Fresh chunks cost one inference pass each; what justifies spending it now rather than 200 ms from now?
  2. Whether and how to adopt the result. Hard-swap, splice behind a frozen prefix, blend — or discard the new chunk and keep the old one.
  3. How long to commit. Many actions per chunk amortizes inference; few preserves reactivity. The commit horizon is a dial, not a constant.

Every Phase 05 baseline is a fixed answer: synchronous execution replans on exhaustion and hard-swaps; temporal ensembling replans on a timer and blends; fixed-rate RTC splices behind a worst-case prefix. The adaptive scheduler makes each answer a function of two signals: L^q\hat L_q, a calibrated quantile of the next inference latency — the cost of fresh information — and DD, the disagreement between the active chunk and the newest one — the value the last purchase delivered. Cost versus value is the whole method; the rest of the lesson makes each signal measurable and each decision safe.

Signal one: predicting your own inference latency

Start with why prediction is unavoidable. Two of the scheduler's core quantities are functions of the next inference latency \ell — a number that has not happened yet. Suppose the active chunk has RR actions remaining and you launch a replan now: the new chunk becomes usable \ell milliseconds from now, while the executor keeps consuming one action every Δ=20\Delta = 20 ms. Two consequences follow:

RΔ    launch deadlineandk()  =  Δ    actions are beyond recall\underbrace{R\,\Delta \;\ge\; \ell}_{\text{launch deadline}} \qquad\text{and}\qquad k(\ell) \;=\; \Bigl\lceil \frac{\ell}{\Delta} \Bigr\rceil \;\;\text{actions are beyond recall}
Launch while R·Δ covers the inference time, or the executor starves; every action that executes during inference is committed — the frozen prefix.

Read the two halves. The first is the launch deadline: if RΔ<R\,\Delta < \ell, the old chunk runs dry — the starvation synchronous execution produced in Phase 05. The second is the frozen prefix: the kk actions that execute during inference cannot be changed by its result, so the new chunk must be generated to agree with them — the inference-time inpainting move from real-time chunking (opens in a new tab). Fixed-rate RTC hard-codes kk to the worst case — p99/Δ=13\lceil p99/\Delta \rceil = 13 actions at a 260 ms p99, even when the server answers in 95 ms: reactivity thrown away. The adaptive move sizes both from a predicted quantile:

Rtrigger  =  L^qΔ+mPr[starvation at launch]    Pr[>L^q]  =  1qR_{\text{trigger}} \;=\; \Bigl\lceil \frac{\hat L_q}{\Delta} \Bigr\rceil + m \qquad\Longrightarrow\qquad \Pr[\text{starvation at launch}] \;\le\; \Pr\bigl[\ell > \hat L_q\bigr] \;=\; 1 - q
With q = 0.9, m = 2, and a 130 ms predicted quantile, launch while R ≤ 9 — a bounded, measured starvation risk instead of an unbounded hope.

What predicts \ell? Three features carry nearly all the signal. Recent latency — service time is strongly autocorrelated: slow states (thermal throttling, a fragmented allocator, a background job) persist for seconds to minutes, so an EWMA is the right memory. Queue state at launch — each in-flight request on a single-GPU server adds one service time to yours; on a dedicated RTX workstation the depth is usually zero, until a visualizer or stray finetune shares the GPU. Preprocessing cost — two RealSense streams versus one, decode and resize: 5–15 ms, measurable before the request leaves the host.

Two honest lines of math for the EWMA: unrolling the recurrence shows a geometric-weighted average; equating its variance to a flat NN-sample mean gives its effective memory:

^t=αt+(1α)^t1=αk=0(1α)ktk,Var[^]=α2ασ2=σ2Neff    Neff=2αα=9   at   α=0.2\hat\ell_t = \alpha\,\ell_t + (1-\alpha)\,\hat\ell_{t-1} = \alpha \sum_{k=0}^{\infty} (1-\alpha)^k\,\ell_{t-k}, \qquad \operatorname{Var}[\hat\ell] = \frac{\alpha}{2-\alpha}\,\sigma^2 = \frac{\sigma^2}{N_{\text{eff}}} \;\Rightarrow\; N_{\text{eff}} = \frac{2-\alpha}{\alpha} = 9 \;\text{ at }\; \alpha = 0.2
α = 0.2 remembers roughly the last nine inference calls — long enough to smooth noise, short enough to track a thermal shift.

Now the honesty clause. On a dedicated workstation the distribution is a tight core with rare spikes — 105 ms p50, 118 ms p90, occasional 250–400 ms excursions from allocator stalls or a compositor grabbing the GPU. The core is predictable to within about 10 ms; the spikes are not. So the predictor's contract is a quantile, not a point estimate: a number the true latency stays below qq of the time. Expect it to beat a static p99 constant by 100–130 ms of recovered freeze budget, and to earn its keep under jitter injection, where any constant is always wrong in one direction.

latency_predictor.py — calibrated quantile prediction of inference timepython
import numpy as np

class LatencyQuantilePredictor:
    """Predicts the q-quantile of the next inference latency in ms.

    Features per request: [1, ewma_ms, queue_depth, preproc_ms].
    Fit offline on a Phase 05 trace with pinball loss; falls back
    online to EWMA plus an empirical residual quantile.
    """

    def __init__(self, q=0.9, alpha=0.2, window=64):
        self.q = q
        self.alpha = alpha
        self.window = window
        self.ewma = None
        self.residuals = []
        self.w = None  # set by fit()

    def fit(self, X, y, iters=4000, lr=0.05):
        """X: (n, 4) feature rows; y: (n,) observed latency ms."""
        w = np.zeros(X.shape[1])
        for _ in range(iters):
            pred = X @ w
            # subgradient of the pinball (quantile) loss
            g = np.where(y > pred, -self.q, 1.0 - self.q)
            w -= lr * (X.T @ g) / len(y)
        self.w = w
        return w

    def update(self, observed_ms):
        prev = observed_ms if self.ewma is None else self.ewma
        self.residuals.append(observed_ms - prev)
        self.residuals = self.residuals[-self.window:]
        self.ewma = self.alpha * observed_ms + (1 - self.alpha) * prev

    def predict(self, queue_depth, preproc_ms):
        if self.w is not None:
            x = np.array([1.0, self.ewma, queue_depth, preproc_ms])
            return float(x @ self.w)
        margin = np.quantile(self.residuals, self.q) if self.residuals else 0.0
        return float(self.ewma + margin)

def coverage(y_true, y_pred):
    """Fraction of latencies at or below prediction. Should be near q."""
    return float(np.mean(np.asarray(y_true) <= np.asarray(y_pred)))

An uncalibrated quantile predictor is a liability with a confidence-interval-shaped hole. The check is one line: on a held-out trace, coverage should return approximately qq. With n=500n = 500 requests the binomial standard error is 0.9×0.1/5001.3%\sqrt{0.9 \times 0.1 / 500} \approx 1.3\% — coverage of 0.88–0.92 is healthy; 0.82 means the predictor is lying about risk. Recalibrate every session (driver updates and ambient temperature move the distribution overnight), and log every prediction beside its realized latency: the prediction is part of the trace now.

Checkpoint 02

Your calibrated predictor puts the 90th-percentile latency of the next inference call at 132 ms; the executor consumes one action every 20 ms. How many upcoming actions must the frozen prefix cover when you launch a replan?

Signal two: chunk disagreement over the overlap window

The second signal asks not what fresh information costs, but whether the last purchase changed anything. When a new chunk arrives, the old one still holds RR remaining actions, and both prescribe actions for the same absolute ticks. Aligning them by tick index gives the overlap window W\mathcal{W}, the stretch of future both chunks claim to know; disagreement is a distance over it:

Dt  =  jWγjΣ1/2(at+jnewat+jold)2jWγj,Σ=diag(σ12,,σ72)D_t \;=\; \frac{\displaystyle\sum_{j \in \mathcal{W}} \gamma^{\,j}\, \bigl\lVert \Sigma^{-1/2} \bigl( a^{\text{new}}_{t+j} - a^{\text{old}}_{t+j} \bigr) \bigr\rVert_2}{\displaystyle\sum_{j \in \mathcal{W}} \gamma^{\,j}}, \qquad \Sigma = \operatorname{diag}\bigl(\sigma_1^2, \dots, \sigma_7^2\bigr)
Per-dimension normalization by the training-set action standard deviations your OpenPI adapter already stores; a discount γ ≈ 0.9 weights near-term disagreement above disagreement 800 ms out.

Each choice earns its place. Without Σ1/2\Sigma^{-1/2} the metric is a unit salad: WidowX AI joint deltas with standard deviations of 0.02–0.2 rad share a vector with a gripper command spanning its full range — unnormalized, the gripper either dominates the norm or vanishes from it. The discount matters because a disagreement 40 ms ahead will actually execute; one 800 ms ahead will likely be replanned away first. The mean-over-window form is spike-tolerant: a max variant fires on one noisy dimension at one tick, and chatters.

Now the interpretive question: what does Dt=2.4D_t = 2.4 mean? One of three things, and the metric cannot say which. The world changed — the old chunk came from an observation 300 ms staler; a nudged mug or an entering hand shows up first as disagreement — the case the scheduler exists for. The policy is uncertain — near a decision boundary, observation noise flips the output; disagreement acts as a cheap two-member ensemble probe. The policy is multimodal — a flow-matching head sampling fresh noise picks a different valid grasp approach on each call: both correct, DD at 2–3 in a static scene, and switching modes mid-reach is exactly the lurching indecision to prevent. One scalar, three mechanisms — those are the aliasing risks.

Checkpoint 03

In a bench test with a completely static scene, your disagreement signal still spikes to D ≈ 2.5 every few chunks. Most likely cause?

The policy space: triggers, switches, and the variants worth building

One asymmetry shapes the policy space: measuring DD requires generating a chunk — a full 100 ms inference pass. Latency prediction is nearly free; disagreement is the most expensive sensor you own. So DD cannot decide whether to spend inference, only gate what you do with a chunk you already bought and modulate how soon you buy the next one. That constraint sorts the triggers:

Trigger policyRuleAdapts toCharacteristic failure
Fixed rateReplan every EE executed actions (E=25E = 25: 2 Hz)Nothing — open loopWastes compute when quiet; too slow when not
Staleness deadline (latency-aware)Launch when RL^q/Δ+mR \le \lceil \hat L_q/\Delta \rceil + mMeasured latency and queue stateMiscalibration becomes starvation or chronic early replans
Disagreement-modulated cadenceBaseline cadence; shorten while recent D>θhiD > \theta_{\text{hi}}, stretch below θlo\theta_{\text{lo}}Scene change and policy uncertaintyMultimodal aliasing drives replan storms unless hysteresis bounds it
Trigger policies: when to launch a replan

Adoption — the second decision — has three mechanisms worth knowing and two worth building:

  • Hard swap. Replace the remaining actions at arrival. A step discontinuity sits at the seam; at 50 Hz even a 0.05 rad per-joint jump is a step the joints' high-bandwidth FOC controllers will faithfully chase — a torque spike at the seam — and your jerk metric lights up. Keep it only as the ablation showing why continuity machinery exists.
  • Frozen-prefix splice (RTC-style). The new chunk is generated to agree with the kk frozen actions, so the handover is continuous by construction. Your adaptive variants use this, with k=L^q/Δk = \lceil \hat L_q / \Delta \rceil instead of a static worst case.
  • Blend / cross-fade. Interpolate old to new over 5–10 actions. Cheap and smooth — but blending two modes averages a left approach and a right approach into a trajectory through the middle of the mug: the multimodal-averaging failure from Phase 03, resurfacing at the scheduler level. Build it only to demonstrate the failure.

The third decision folds in almost for free. The commit horizon CC — how many actions a chunk may execute before a fresh one is required — is temporal ensembling's query period and RTC's cadence wearing different clothes. Make it a function of DD:

Ct  =  clip(Cmin,  Cmax,  C0κ(Dˉtθlo)),Cmin=10,    C0=30,    Cmax=40,    κ16C_t \;=\; \operatorname{clip}\Bigl( C_{\min},\; C_{\max},\; C_0 - \kappa\,\bigl(\bar D_t - \theta_{\text{lo}}\bigr) \Bigr), \qquad C_{\min} = 10,\;\; C_0 = 30,\;\; C_{\max} = 40,\;\; \kappa \approx 16
Disagreement at 2.0 contracts the horizon from 30 actions (600 ms) to 14 (280 ms); a quiet scene stretches it to 40, saving inference passes.

The design space is now enumerable, and scope discipline says build three points in it, not thirty:

  1. A1 — latency-adaptive RTC. Fixed cadence; frozen prefix and launch deadline sized by L^q\hat L_q instead of a static p99. One mechanism, one claim: prediction recovers the reactivity worst-case provisioning throws away.
  2. A2 — A1 plus disagreement gating. Adopt when DθhiD \ge \theta_{\text{hi}}, keep the old chunk (mode-stable) when DθloD \le \theta_{\text{lo}}, with a refractory period between switches. Hysteresis, not a single threshold — the 1.0-to-2.0 gap prevents chatter.
  3. A3 — A2 plus adaptive commit horizon. The full method: CtC_t shrinks under high disagreement, stretches in quiet scenes. Exploratory — if A2 shows nothing, A3 is two more free parameters, not a rescue.

From method to evidence: architecture, guardrails, and the ablation matrix

Where does this run? In the executor thread on the robot host — the process that owns the 50 Hz tick and the UDP command stream to the arm's iNerve controller. Not next to the policy: the scheduler must keep making safe decisions precisely when the workstation stops answering. Its inputs are host-local: the action deque with each chunk's capture timestamp, predictor state fed by the trace layer, pending-request status, the current frame's preprocessing cost. Tick budget: under 1 ms — no allocation, no locks across the command send, no blocking calls; the policy client lives on its own thread behind a mailbox.

scheduler.py — the executor-side decision tick with its own logpython
import math
from collections import deque

HZ = 50
DT_MS = 1000.0 / HZ  # 20 ms per action

class AdaptiveScheduler:
    """Runs inside the executor tick on the robot host.

    Collaborators (request_chunk, disagreement, splice, HOLD_ACTION)
    come from your Phase 05 runtime package; request_chunk is async
    and never blocks this thread.
    """

    def __init__(self, predictor, theta_hi=2.0, theta_lo=1.0,
                 refractory_ticks=10, margin_actions=2):
        self.pred = predictor
        self.theta_hi = theta_hi
        self.theta_lo = theta_lo
        self.refractory = refractory_ticks
        self.margin = margin_actions
        self.active = deque()        # remaining actions with chunk metadata
        self.pending = False         # one request in flight at most
        self.last_launch_tick = -10_000
        self.log = []                # one record per tick: the decision log

    def tick(self, t, obs_meta, result):
        rec = {"tick": t, "R": len(self.active), "decision": "execute"}
        lhat_ms = self.pred.predict(obs_meta["queue_depth"],
                                    obs_meta["preproc_ms"])
        rec["lhat_q_ms"] = round(lhat_ms, 1)

        if result is not None:                    # a new chunk just landed
            d = disagreement(self.active, result.chunk, t)
            rec["D"] = round(d, 2)
            if d >= self.theta_hi or len(self.active) <= self.margin:
                self.active = splice(result.chunk, t)
                rec["switch"] = "adopt"
            else:
                rec["switch"] = "keep_old"        # mode-stable inside the band
            self.pending = False

        r_trigger = math.ceil(lhat_ms / DT_MS) + self.margin
        cooled = (t - self.last_launch_tick) >= self.refractory
        if not self.pending and cooled and len(self.active) <= r_trigger:
            freeze_k = math.ceil(lhat_ms / DT_MS)
            request_chunk(obs_meta, freeze_k)     # async launch
            self.pending = True
            self.last_launch_tick = t
            rec["decision"] = "replan"
            rec["freeze_k"] = freeze_k

        if not self.active:                       # starvation watchdog
            rec["decision"] = "hold"
            self.log.append(rec)
            return HOLD_ACTION
        self.log.append(rec)
        return self.active.popleft()

The decision log is half the research contribution. Each tick appends one record: remaining actions, predicted quantile, measured disagreement, the decision, and why. Joined against the trace spans and encoder stream, it answers questions no success rate can: why did it replan at t=14.3t = 14.3 s; what did DD do in the 500 ms before each failure; how often did the hysteresis band absorb a would-be switch. And counterfactual replay — a recorded session rerun through a different configuration offline — is how you tune guardrails without burning trials.

Two failure modes deserve named guardrails. Replan storms: disagreement triggers a switch, the switch changes the mode, the next comparison disagrees again — oscillation at the refractory frequency, a saturated server, climbing latency, the correlated-signals trap closing. Detection: the inter-replan-interval histogram piles at the refractory bound. Guardrails: hysteresis at 2.0/1.0, a 10-tick (200 ms) refractory period, cadence bounds of 1–5 Hz. Starvation: the predictor underestimates, the launch fires late, the deque empties. Guardrails: the margin mm, the watchdog, an honest deadline-miss counter in the report.

Finally, the matrix that turns the method into evidence: three Phase 05 baselines and three adaptive variants, crossed with the injection conditions your harness already produces. Trial counts, blocking, and randomization come from the previous lesson; the matrix makes every comparison explicit before the first trial runs:

MethodUses latency signalUses disagreementClean (p50 ≈ 105 ms)+100 ms constant delayJitter 40–300 ms
B0 — synchronousreference floorreference floorreference floor
B1 — temporal ensembling, fixed ratebaselinebaselinebaseline
B2 — RTC, fixed freeze at p99baselinebaselineprimary comparator
A1 — adaptive freeze from L^q\hat L_qyesregression checksecondaryisolates latency prediction
A2 — A1 + disagreement gateyesyesregression checksecondaryprimary claim
A3 — A2 + adaptive commit horizonyesyesexploratoryexploratoryexploratory
Six methods under three injection conditions; every cell runs the same task set under the previous lesson's protocol — about 10 screening trials to prune, then predeclared confirmatory counts.

Read the matrix by its isolations. B2 versus A1 under jitter isolates latency prediction — same trigger, same splice, only freeze sizing differs. A1 versus A2 isolates disagreement gating; A2 versus A3, commit adaptation. The clean column is the regression guard: an adaptive scheduler that taxes the easy case has not earned its complexity, so a null result there is desired. And the constant-delay column separates delay from jitter as the causal variable — the distinction your hypothesis (adaptive scheduling recovers at least 10 success points under jitter versus fixed-rate RTC) was written to expose.

Studio exercise 01

Dry-run the scheduler on recorded traces before it touches the arm

Implement the scheduler tick and run it in replay mode: feed it a recorded Phase 05 session — logged latency spans, chunk arrivals, and the chunks themselves — with request_chunk returning the recorded chunk after its recorded latency. Produce (1) the decision log for one 5-minute session, (2) the inter-replan-interval histogram, (3) a table comparing A1 against B2 on replayed staleness — mean and max age of each executing action's source observation — and (4) a paragraph on whether you observed oscillation and which guardrail bounded it.

Need a hint?

Replay makes the scheduler a pure function of the trace: seed everything and assert the decision log is byte-identical across two runs. Age is tick time minus the chunk's capture time. A histogram spike exactly at the refractory bound means the refractory period is doing work hysteresis should be doing: widen the theta band and rerun.

Where this goes next: the previous lesson, Designing robot experiments that survive review, supplied the protocol this matrix presupposes — trial counts, blocking, randomization, the predeclared metric. What remains is communication: The research artifact: report, repo, and talk turns the decision log, the matrix, and the preserved negative results into a six-to-eight-page report, a reproducible repository, and a ten-minute talk another engineer can check.