File size: 20,397 Bytes
e1acc15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# SPDX-License-Identifier: Apache-2.0
"""Environment configuration of the remote DSpark proxy (Spark side).

The overlay is inactive unless VLLM_DSPARK_REMOTE is set (then the image's own code
path runs byte-identically). Every value is validated at startup; a bad value raises.

  VLLM_DSPARK_REMOTE              endpoint for rank 0: o1rdma://10.202.28.1:18620 (lane O1's ofl, RDMA),
                                  o1tcp://... (ofl's TCP backend) or tcp://... (the contract's TCP stub)
  VLLM_DSPARK_REMOTE_BIND         this side's direct-link IPv4 (10.202.28.2); same as ?bind= in the
                                  endpoint, without a '?' that a launcher shell could glob
  VLLM_DSPARK_REMOTE_OFL_DIR      directory holding libofl.so + ofl.py (o1tcp/o1rdma endpoints)
  VLLM_DSPARK_REMOTE_POLICY       shadow (default) | resilience | capacity
  VLLM_DSPARK_REMOTE_IDENTITY     64-hex drafter identity (CONTRACT 6.7); required
  VLLM_DSPARK_REMOTE_MARKOV_IDENTITY  64-hex; default: sha256 of the VLLM_DSPARK_MARKOV_CACHE
                                  file, zeros if that variable is unset
  VLLM_DSPARK_REMOTE_TIMEOUT_MS   per-round deadline (default 50)
  VLLM_DSPARK_REMOTE_CONNECT_TIMEOUT_MS  connect + HELLO deadline (default 1000)
  VLLM_DSPARK_REMOTE_BACKOFF_MS   first reconnect delay after a failure (default 1000,
                                  doubles up to VLLM_DSPARK_REMOTE_BACKOFF_MAX_MS, default 30000)
  VLLM_DSPARK_REMOTE_ON_FAIL      target_only (default) | raise   (capacity policy)
  VLLM_DSPARK_REMOTE_TRIP         circuit breaker (CONTRACT 10, breaker.py): consecutive failed attempts
                                  before the remote is skipped (default 3; 0 = off)
  VLLM_DSPARK_REMOTE_COOLDOWN     rounds without network after a trip, then one probe (default 256)
  VLLM_DSPARK_REMOTE_STRICT_START 1: raise at startup if connect + HELLO fails (default 0)
  VLLM_DSPARK_REMOTE_SHADOW_USE   local (default) | remote        (shadow policy)
  VLLM_DSPARK_REMOTE_WANT_MARGIN  1: ask the worker for top1-top2 margins (default 1 in shadow)
  VLLM_DSPARK_REMOTE_DRAFT_MS     remote draft round cost for the depth policy (ms, finite >= 0);
                                  replaces VLLM_DSPARK_DRAFT_MS in capacity/resilience
  VLLM_DSPARK_REMOTE_ROUND_MS     verify-only forward table for remote mode: ms for depth 0..k
                                  (k + 1 finite values > 0, non-decreasing); replaces
                                  VLLM_DSPARK_ROUND_MS (whose live value includes the local
                                  drafter) in capacity/resilience. With VLLM_DSPARK_DEPTH_POLICY
                                  on, capacity/resilience need BOTH remote costs (no mixed cost
                                  model) and refuse "confr:<table>" (its own table includes the
                                  local drafter). Shadow keeps the local measurements.
  VLLM_DSPARK_REMOTE_ASYNC        1: capacity policy only - rank 0 does not block its CPU on the round;
                                  the engine stream and the round's proxy thread hand each round over
                                  through two words in registered host memory (streamsync.HostFlagSync);
                                  the proxy thread makes no CUDA call. Self-tested at startup; falls back
                                  to the synchronous round with a warning. Default 0.
  VLLM_DSPARK_REMOTE_ASYNC_PROXY  async mode: where the round's proxy runs. thread (default): a Python thread of the
                                  engine process (needs the engine's GIL to publish). process: a torch-free child
                                  process with its own interpreter (proxy_proc.py); the gather staging, the descriptor
                                  ring and the hand-off words share one registered memfd region (shmregion.py), so the
                                  success path never waits for the engine's GIL; the switch interval is not changed
  VLLM_DSPARK_REMOTE_HOSTFLAG_LIB async mode: path of libdsv41_hostflag.so (built from
                                  remote_dspark/hostflag.c). Default: VLLM_DSPARK_REMOTE_OFL_DIR's copy,
                                  else hostflag.c compiled with cc at startup.
  VLLM_DSPARK_REMOTE_ASYNC_NAP_US async mode: the proxy thread spins on the gathered word; > 0 = sleep this
                                  many us between checks after VLLM_DSPARK_REMOTE_ASYNC_SPIN_US of
                                  spinning (default 0: spin; one rank-0 core while a round is pending)
  VLLM_DSPARK_REMOTE_ASYNC_SPIN_US see ASYNC_NAP_US (default 1000)
  VLLM_DSPARK_REMOTE_ASYNC_WATCHDOG_MS async mode: a native watchdog fails and releases a round the engine stream
                                  gathered but nobody released within this many ms (zero drafts for that round), so a
                                  proxy thread that cannot run never parks the GPU for good. Default 0 = auto:
                                  max(4 x VLLM_DSPARK_REMOTE_TIMEOUT_MS, 200)
  VLLM_DSPARK_REMOTE_ASYNC_RESPAWNS  async process mode: new proxy processes the engine may start after its proxy died
                                  or stalled (default 3; 0 = none: target-only rounds for the rest of the run). Between
                                  the loss and the new proxy's start every round is target-only (zero drafts); the new
                                  proxy starts with a new session, so the worker drops its windows and every slot is
                                  re-announced (NEW_GEN). Spent: target-only rounds, one error log line
  VLLM_DSPARK_REMOTE_ASYNC_RESPAWN_RESET_S  a proxy process that served this long restores the full respawn budget
                                  (default 3600; 0 = never)
  VLLM_DSPARK_REMOTE_SWITCH_INTERVAL_US  async thread mode: Python GIL switch interval while the proxy thread
                                  runs (default 500; the interpreter default is 5000; 0 = unchanged); ignored with
                                  ASYNC_PROXY=process
  VLLM_DSPARK_REMOTE_FUSED_GATHER auto (default: on CUDA, after a startup self-check against the torch
                                  path) | 0 | 1: the one-launch Triton row gather (fused_gather.py)
  VLLM_DSPARK_REMOTE_N_OUT        auto (default) | 0: ReqEntry.n_out (CONTRACT 6.3). auto = cap[n] of the dynamic
                                  speculation schedule when the depth policy masks positions >= cap
                                  (conf/confb/confr, no VLLM_DSPARK_DEPTH_FILE) in capacity/resilience;
                                  otherwise, and in shadow, always all N positions
  VLLM_DSPARK_REMOTE_LOG_EVERY    rounds between stats log lines (default 1024, 0 = off)
  VLLM_DSPARK_REMOTE_STATS_FILE   JSON stats written by rank 0 (default
                                  /tmp/dsv41-remote-dspark.json, inside the container)
"""

from __future__ import annotations

import hashlib
import math
import os
from dataclasses import dataclass

from . import wire
from .transport import Endpoint, parse_endpoint

POLICIES = ("shadow", "resilience", "capacity")
ENV_ENDPOINT = "VLLM_DSPARK_REMOTE"


def remote_requested(environ=None) -> bool:
    env = os.environ if environ is None else environ
    return bool((env.get(ENV_ENDPOINT) or "").strip())


def _int(env, key: str, default: int, lo: int = 0) -> int:
    raw = env.get(key)
    if raw is None or raw.strip() == "":
        return default
    try:
        v = int(raw)
    except ValueError as e:
        raise ValueError(f"{key}={raw!r}: not an integer") from e
    if v < lo:
        raise ValueError(f"{key}={v}: must be >= {lo}")
    return v


def _choice(env, key: str, default: str, choices: tuple[str, ...]) -> str:
    v = (env.get(key) or default).strip().lower()
    if v not in choices:
        raise ValueError(f"{key}={v!r}: expected one of {choices}")
    return v


def _bool(env, key: str, default: bool) -> bool:
    raw = env.get(key)
    if raw is None or raw.strip() == "":
        return default
    v = raw.strip().lower()
    if v in ("1", "true", "on", "yes"):
        return True
    if v in ("0", "false", "off", "no"):
        return False
    raise ValueError(f"{key}={raw!r}: expected 0 or 1")


def _float_list(env, key: str) -> list[float] | None:
    raw = env.get(key)
    if raw is None or raw.strip() == "":
        return None
    try:
        vals = [float(x) for x in raw.split(",") if x.strip()]
    except ValueError as e:
        raise ValueError(f"{key}={raw!r}: expected comma-separated numbers") from e
    if not vals or not all(math.isfinite(v) for v in vals):
        raise ValueError(f"{key}={raw!r}: expected finite numbers")
    if any(v < 0 for v in vals):
        raise ValueError(f"{key}={raw!r}: expected non-negative numbers")
    return vals


@dataclass
class RemoteConfig:
    endpoint: Endpoint
    policy: str
    drafter_identity: bytes
    markov_identity: bytes
    timeout_ms: int
    connect_timeout_ms: int
    backoff_ms: int
    backoff_max_ms: int
    on_fail: str
    strict_start: bool
    shadow_use: str
    want_margin: bool
    remote_draft_ms: float | None
    remote_round_ms: list[float] | None
    log_every: int
    stats_file: str
    async_mode: bool = False
    switch_interval_us: int = 500
    hostflag_lib: str = ""
    async_nap_us: int = 0
    async_spin_us: int = 1000
    async_watchdog_ms: int = 0
    async_proxy: str = "thread"
    async_respawns: int = 3
    async_respawn_reset_s: int = 3600
    fused_gather: bool | None = None
    n_out: str = "auto"
    trip: int = 3
    cooldown: int = 256
    sampled_top32: bool = False

    @property
    def watchdog_ms(self) -> int:
        """The async watchdog deadline actually used."""
        return self.async_watchdog_ms or max(4 * self.timeout_ms, 200)

    @property
    def keeps_local_drafter(self) -> bool:
        return self.policy in ("shadow", "resilience")

    @classmethod
    def from_env(cls, environ=None) -> "RemoteConfig":
        env = os.environ if environ is None else environ
        if not remote_requested(env):
            raise ValueError(f"{ENV_ENDPOINT} is not set")
        endpoint = parse_endpoint(env[ENV_ENDPOINT])
        bind = (env.get("VLLM_DSPARK_REMOTE_BIND") or "").strip()
        if bind:
            if endpoint.options.get("bind", bind) != bind:
                raise ValueError(f"VLLM_DSPARK_REMOTE_BIND={bind} conflicts with the endpoint's "
                                 f"?bind={endpoint.options['bind']}")
            endpoint.options["bind"] = bind
        policy = _choice(env, "VLLM_DSPARK_REMOTE_POLICY", "shadow", POLICIES)
        ident_raw = env.get("VLLM_DSPARK_REMOTE_IDENTITY", "")
        if not ident_raw.strip():
            raise ValueError(
                "VLLM_DSPARK_REMOTE_IDENTITY is required: the 64-hex drafter identity "
                "(offload/dspark/spark/tools/identity.py over lane S2's DSPARK_COPY_DONE.json)")
        drafter_identity = wire.parse_hex32(ident_raw, "VLLM_DSPARK_REMOTE_IDENTITY")
        markov_raw = env.get("VLLM_DSPARK_REMOTE_MARKOV_IDENTITY", "")
        if markov_raw.strip():
            markov_identity = wire.parse_hex32(markov_raw, "VLLM_DSPARK_REMOTE_MARKOV_IDENTITY")
        else:
            markov_identity = markov_identity_from_env(env)
        draft_ms_list = _float_list(env, "VLLM_DSPARK_REMOTE_DRAFT_MS")
        if draft_ms_list is not None and len(draft_ms_list) != 1:
            raise ValueError("VLLM_DSPARK_REMOTE_DRAFT_MS: one number (ms)")
        cfg = cls(
            endpoint=endpoint,
            policy=policy,
            drafter_identity=drafter_identity,
            markov_identity=markov_identity,
            timeout_ms=_int(env, "VLLM_DSPARK_REMOTE_TIMEOUT_MS", 50, lo=1),
            connect_timeout_ms=_int(env, "VLLM_DSPARK_REMOTE_CONNECT_TIMEOUT_MS", 1000, lo=1),
            backoff_ms=_int(env, "VLLM_DSPARK_REMOTE_BACKOFF_MS", 1000, lo=1),
            backoff_max_ms=_int(env, "VLLM_DSPARK_REMOTE_BACKOFF_MAX_MS", 30000, lo=1),
            on_fail=_choice(env, "VLLM_DSPARK_REMOTE_ON_FAIL", "target_only",
                            ("target_only", "raise")),
            strict_start=_bool(env, "VLLM_DSPARK_REMOTE_STRICT_START", False),
            shadow_use=_choice(env, "VLLM_DSPARK_REMOTE_SHADOW_USE", "local",
                               ("local", "remote")),
            want_margin=_bool(env, "VLLM_DSPARK_REMOTE_WANT_MARGIN", policy == "shadow"),
            remote_draft_ms=None if draft_ms_list is None else draft_ms_list[0],
            remote_round_ms=_float_list(env, "VLLM_DSPARK_REMOTE_ROUND_MS"),
            log_every=_int(env, "VLLM_DSPARK_REMOTE_LOG_EVERY", 1024, lo=0),
            stats_file=(env.get("VLLM_DSPARK_REMOTE_STATS_FILE")
                        or "/tmp/dsv41-remote-dspark.json"),
            async_mode=_bool(env, "VLLM_DSPARK_REMOTE_ASYNC", False),
            switch_interval_us=_int(env, "VLLM_DSPARK_REMOTE_SWITCH_INTERVAL_US", 500, lo=0),
            hostflag_lib=(env.get("VLLM_DSPARK_REMOTE_HOSTFLAG_LIB") or "").strip(),
            async_nap_us=_int(env, "VLLM_DSPARK_REMOTE_ASYNC_NAP_US", 0, lo=0),
            async_spin_us=_int(env, "VLLM_DSPARK_REMOTE_ASYNC_SPIN_US", 1000, lo=0),
            async_watchdog_ms=_int(env, "VLLM_DSPARK_REMOTE_ASYNC_WATCHDOG_MS", 0, lo=0),
            async_proxy=_choice(env, "VLLM_DSPARK_REMOTE_ASYNC_PROXY", "thread", ("thread", "process")),
            async_respawns=_int(env, "VLLM_DSPARK_REMOTE_ASYNC_RESPAWNS", 3, lo=0),
            async_respawn_reset_s=_int(env, "VLLM_DSPARK_REMOTE_ASYNC_RESPAWN_RESET_S", 3600, lo=0),
            fused_gather={"auto": None, "1": True, "0": False}[
                _choice(env, "VLLM_DSPARK_REMOTE_FUSED_GATHER", "auto", ("auto", "0", "1"))],
            n_out=_choice(env, "VLLM_DSPARK_REMOTE_N_OUT", "auto", ("auto", "0")),
            sampled_top32=_bool(env, "VLLM_DSPARK_SAMPLED_TOP32", False),
            trip=_int(env, "VLLM_DSPARK_REMOTE_TRIP", 3, lo=0),
            cooldown=_int(env, "VLLM_DSPARK_REMOTE_COOLDOWN", 256, lo=1),
        )
        if cfg.async_mode and cfg.policy != "capacity":
            raise ValueError("VLLM_DSPARK_REMOTE_ASYNC=1 needs VLLM_DSPARK_REMOTE_POLICY=capacity "
                             "(resilience/shadow decide on the host every round)")
        if cfg.async_mode and cfg.on_fail == "raise":
            raise ValueError("VLLM_DSPARK_REMOTE_ASYNC=1 cannot raise on a failed round "
                             "(the host never waits for it); use ON_FAIL=target_only")
        if cfg.backoff_max_ms < cfg.backoff_ms:
            raise ValueError("VLLM_DSPARK_REMOTE_BACKOFF_MAX_MS < VLLM_DSPARK_REMOTE_BACKOFF_MS")
        return cfg


def markov_identity_from_env(env) -> bytes:
    path = (env.get("VLLM_DSPARK_MARKOV_CACHE") or "").strip()
    if not path:
        return bytes(32)
    try:
        with open(path, "rb") as f:
            return hashlib.sha256(f.read()).digest()
    except OSError as e:
        raise ValueError(
            f"VLLM_DSPARK_MARKOV_CACHE={path!r} unreadable ({e}); set "
            "VLLM_DSPARK_REMOTE_MARKOV_IDENTITY explicitly") from e


def apply_cost_overrides(cfg: RemoteConfig, environ=None, depth_policy_on: bool = False,
                         depth_mode: str | None = None, num_spec: int = 5,
                         depth_file: str | None = None) -> dict[str, str]:
    """Make the remote round cost an explicit input of the depth policy (CONTRACT 10,
    brief "recalibration"). Writes VLLM_DSPARK_DRAFT_MS / VLLM_DSPARK_ROUND_MS in the
    process environment before the model runner builds DSparkDepthPolicy (it reads them
    in initialize_kv_cache, after the speculator exists; it is built only when
    VLLM_DSPARK_DEPTH_POLICY is on). Same env on both ranks, so the cost tables stay
    identical across TP ranks. Returns what was changed; raises before writing anything.

    The policy prices depth d as ROUND_MS[d] + DRAFT_MS (depth_policy.round_cost_ms). The
    live ROUND_MS includes the local drafter, so with the policy on, capacity/resilience
    need both remote inputs: a remote DRAFT_MS over the live table counts the drafter
    twice, a verify-only table under the live DRAFT_MS counts the wrong drafter. confr's
    table is part of the policy string and cannot take them at all. fixed:<d> never prices
    a depth, so it needs them only when a depth file can switch the mode at runtime."""
    env = os.environ if environ is None else environ
    changed: dict[str, str] = {}
    if cfg.policy == "shadow":
        return changed  # local drafter runs every round; timing is not representative
    if depth_policy_on and not (depth_mode == "fixed" and not depth_file):
        missing = [k for k, v in (("VLLM_DSPARK_REMOTE_DRAFT_MS", cfg.remote_draft_ms),
                                  ("VLLM_DSPARK_REMOTE_ROUND_MS", cfg.remote_round_ms))
                   if v is None]
        if missing:
            raise ValueError(
                f"remote DSpark ({cfg.policy}) with VLLM_DSPARK_DEPTH_POLICY needs both "
                "VLLM_DSPARK_REMOTE_DRAFT_MS (measured remote round, ms) and "
                "VLLM_DSPARK_REMOTE_ROUND_MS (verify-only table, ms by depth 0..k): missing "
                + " and ".join(missing) + ". The live VLLM_DSPARK_ROUND_MS includes the local "
                "drafter, so a partial override mixes two cost models")
        if depth_mode == "confr":
            raise ValueError(
                f"remote DSpark ({cfg.policy}): VLLM_DSPARK_DEPTH_POLICY=confr:<rows ms> carries "
                "its own step table (measured with the local drafter), which the remote cost "
                "inputs cannot replace; use conf or confb with VLLM_DSPARK_REMOTE_DRAFT_MS / "
                "VLLM_DSPARK_REMOTE_ROUND_MS")
    if cfg.remote_round_ms is not None:
        t = cfg.remote_round_ms
        if len(t) != num_spec + 1:
            raise ValueError(f"VLLM_DSPARK_REMOTE_ROUND_MS: {len(t)} values, expected "
                             f"{num_spec + 1} (verify ms for depth 0..{num_spec})")
        if any(v <= 0 for v in t):
            raise ValueError("VLLM_DSPARK_REMOTE_ROUND_MS: verify times must be > 0 ms")
        if any(b < a for a, b in zip(t, t[1:])):
            raise ValueError("VLLM_DSPARK_REMOTE_ROUND_MS: must be non-decreasing with the "
                             f"depth (more live rows never verify faster): {t}")
        changed["VLLM_DSPARK_ROUND_MS"] = ",".join(f"{v:g}" for v in t)
    if cfg.remote_draft_ms is not None:
        changed["VLLM_DSPARK_DRAFT_MS"] = f"{cfg.remote_draft_ms:g}"
    for k, v in changed.items():
        env[k] = v
    return changed


# depth-policy modes that zero the confidences at positions >= cap[num_reqs] before choosing
# depths (DSparkDepthPolicy.record); "fixed" and "off" verify by the scheduler window only
CAP_MASKING_DEPTH_MODES = ("conf", "confb", "confr")


def n_out_table(cfg: RemoteConfig, num_spec: int, depth_mode: str, depth_file: str | None,
                dense_cap: list[int] | None) -> tuple[list[int] | None, str]:
    """ReqEntry.n_out by request count (CONTRACT 6.3), or None (always all N positions),
    with the reason. dense_cap is build_dynamic_sd_schedule_lookup's table (index = number
    of requests, index 0 unused), as the model runner passes it to DSparkDepthPolicy.

    Positions >= cap[n] are only safe to drop when nothing reads them: the depth policy
    must zero them (a cap-masking mode, fixed for the life of the serve, so no depth file
    that could switch it to "fixed"/"off"), and shadow compares every position."""
    if cfg.policy == "shadow":
        return None, "shadow compares every position"
    if cfg.n_out == "0":
        return None, "VLLM_DSPARK_REMOTE_N_OUT=0"
    if depth_mode not in CAP_MASKING_DEPTH_MODES:
        return None, f"depth policy {depth_mode!r} does not mask positions >= cap"
    if depth_file:
        return None, "VLLM_DSPARK_DEPTH_FILE can switch the depth policy at runtime"
    if not dense_cap:
        return None, "no dynamic speculation schedule"
    table = [0 if i == 0 else min(num_spec, int(c)) for i, c in enumerate(dense_cap)]
    if all(c >= num_spec for c in table[1:]):
        return None, "schedule never caps below N"
    return table, "cap by request count"