diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py index 4df44699ed..e1fc19e17e 100644 --- a/tests/unit/test_graph.py +++ b/tests/unit/test_graph.py @@ -1,11 +1,17 @@ import base64 +import json +import msgpack import numpy as np import pytest +from renderers.client import parse_generate_response import verifiers.v1 as vf from verifiers.v1 import graph +from verifiers.v1.clients.train import response_from_generate from verifiers.v1.harnesses.utils.replay import drop_blank_text, strip_text +from verifiers.v1.routing import RoutingData +from verifiers.v1.serve.delta import DeltaStreamer, EpisodeAssembly, pack, unpack from verifiers.v1.types import TurnTokens @@ -36,6 +42,137 @@ def _routed_payload( } +def _full_routing_response(rows=4, *, prompt=None, completion=None): + payload = _routed_payload(rows, 0, 11, top_k=2) + weights = np.tile(np.array([0.12345679, 0.8765432], dtype=" dict | None: - """Integer array -> raw-bytes `__nd__` dict so it rides the wire (numpy can't JSON).""" - return None if arr is None else encode_ndarray(arr) + def serialize_routing_field( + self, data: RoutingData | np.ndarray | None + ) -> dict | None: + if data is None: + return None + return data.to_wire() if isinstance(data, RoutingData) else encode_ndarray(data) @field_validator("routed_experts", mode="before") @classmethod - def deserialize_ndarray_field(cls, value: Any) -> np.ndarray | None: - if value is None or isinstance(value, np.ndarray): + def deserialize_routing_field(cls, value: Any) -> RoutingData | np.ndarray | None: + if value is None or isinstance(value, RoutingData): return value + if isinstance(value, dict) and "__routing__" in value: + return RoutingData.from_wire(value) if isinstance(value, dict) and value.get("__nd__"): - return decode_ndarray(value) - raise TypeError(f"cannot build ndarray field from {type(value).__name__}") + value = decode_ndarray(value) + if isinstance(value, np.ndarray): + validate_ids(value) + return value + raise ValueError(f"cannot build routing field from {type(value).__name__}") @field_serializer("sampling_mask", "top_logprobs") def serialize_sampling_arrays( @@ -744,34 +757,31 @@ def _attribute_routed_experts( prefix_node_ids: list[int], new_node_ids: list[int], path_len: int, - payload: Any, + capture: tuple[RoutingData | np.ndarray, int] | None, ) -> None: - """Attach each new node's slice of this turn's MoE expert-routing array. The `generate` - payload's array covers the turn's prompt+completion from `payload["start"]` (0 = from token - 0); the nodes created this turn tile sequence positions `[path_len:]` in creation order, so - we hand each node `arr[off : off+len(node.token_ids)]` and advance. Reused-prefix nodes keep - the routing attributed when they were first created, except for the one position this turn - corrects (see `_replace_placeholder_routing_row`). A node whose slice falls outside the - array (a `start` past `path_len`, e.g. an unexpected prefix-cache delta) is left unset — the - branch then reports no routing rather than misaligning.""" - if payload is None: + """Own each new node's routing span. Full captures have already passed coverage and + reused-prefix guards. Keep the historical terminal padding rule only for legacy IDs.""" + if capture is None: return - raw = binascii.a2b_base64(payload["data"]) - arr = np.frombuffer(raw, dtype=np.dtype(payload.get("dtype", "uint8"))).reshape( - payload["shape"] - ) - off = path_len - int(payload.get("start", 0) or 0) - _replace_placeholder_routing_row(trace, prefix_node_ids, arr, off) + arr, start = capture + off = path_len - start + if isinstance(arr, np.ndarray): + _replace_placeholder_routing_row(trace, prefix_node_ids, arr, off) needed = off + sum(len(trace.nodes[nid].token_ids) for nid in new_node_ids) for nid in new_node_ids: n = len(trace.nodes[nid].token_ids) end = off + n - if n and 0 <= off and end <= arr.shape[0]: - # Own only this node's rows; a view would retain the turn's full-context array. - trace.nodes[nid].routed_experts = arr[off:end].copy() + if n and 0 <= off and end <= len(arr): + trace.nodes[nid].routed_experts = slice_routing(arr, off, end) + elif isinstance(arr, RoutingData): + if n: + raise ValueError( + "full routing node span is outside captured token coverage" + ) elif n and arr.shape[0] and 0 <= off and end == needed == arr.shape[0] + 1: - # No forward pass follows the turn's final position, so it gets a placeholder: a - # copy of the previous row, appended to this node's slice of the array. + # No forward pass follows the turn's final position, so legacy IDs get a placeholder: + # a copy of the previous row, appended to this node's slice. Never used for full + # coefficients, whose terminal row is marked invalid instead. trace.nodes[nid].routed_experts = np.concatenate( [arr[off:], arr[-1:]], axis=0 ) @@ -876,9 +886,6 @@ def _commit_turn(turn: PendingTurn, response: Response) -> int: trace = turn.trace prompt = turn.prompt tokens = response.tokens - # Constant per renderer, so re-stamping every turn is idempotent. - if tokens is not None and tokens.mm_token_type_id_map: - trace.mm_token_type_id_map = tokens.mm_token_type_id_map prompt_ids = np.asarray(tokens.prompt_ids if tokens else (), dtype=np.int32) # Logical renderer ids differ from `prompt_ids` only after multimodal expansion; otherwise # the nodes keep none and read `token_ids` (`MessageNode.logical_ids`). @@ -887,6 +894,49 @@ def _commit_turn(turn: PendingTurn, response: Response) -> int: if tokens and tokens.renderer_prompt_ids is not None else prompt_ids ) + # Decode and validate before adding nodes: malformed evidence must not partly commit. + capture = ( + decode_routing_payload(tokens.routed_experts) + if tokens is not None and tokens.routed_experts is not None + else None + ) + full_capture = capture is not None and isinstance(capture[0], RoutingData) + if full_capture: + data, start = capture + if start != 0: + raise ValueError( + "full routing replay does not support reused-prefix/delta capture; start must be 0" + ) + # Full routing must stay aligned even on the construct-only train-client + # path. Backtracking/out-of-range spans would duplicate tokens and fail + # attribution after mutating the graph. Reject them before adding nodes. + # Spans index the renderer prompt; projecting them onto vLLM tokens keeps order. + spans = tokens.message_spans + if spans is not None: + if len(spans) != len(prompt): + raise ValueError("full routing message spans must match the prompt") + end = 0 + for span in spans: + if span is None: + continue + if not ( + isinstance(span, (list, tuple)) + and len(span) == 2 + and all(type(value) is int for value in span) + and end <= span[0] <= span[1] <= len(renderer_prompt_ids) + ): + raise ValueError( + "full routing message spans must be ordered within prompt tokens" + ) + end = span[1] + capture = ( + complete_routing_capture( + data, + total_tokens=len(tokens.prompt_ids) + len(tokens.completion_ids), + completion_tokens=len(tokens.completion_ids), + ), + start, + ) expanded = renderer_prompt_ids is not prompt_ids renderer_spans = tokens.message_spans if tokens else None renderer_is_content = ( @@ -928,7 +978,8 @@ def _commit_turn(turn: PendingTurn, response: Response) -> int: spans, is_content = _project_prompt_attribution( renderer_prompt_ids, prompt_ids, - trace.mm_token_type_id_map, + # Stamped onto the trace only once the turn passes the routing guards below. + (tokens.mm_token_type_id_map if tokens else None) or trace.mm_token_type_id_map, tokens.mm_placeholders if tokens else None, renderer_spans, renderer_is_content, @@ -1003,6 +1054,22 @@ def _commit_turn(turn: PendingTurn, response: Response) -> int: path_len = end renderer_path_len = renderer_end + if prefix and ( + full_capture + or any( + isinstance(trace.nodes[nid].routed_experts, RoutingData) for nid in prefix + ) + ): + # Token identity does not prove route identity. In particular, a prior turn's + # invalid final row becomes live context here. Overlays/provenance are not in MVP. + raise ValueError( + "full routing replay does not support reused-prefix continuation or forks" + ) + + # Constant per renderer, so re-stamping every successful turn is idempotent. + if tokens is not None and tokens.mm_token_type_id_map: + trace.mm_token_type_id_map = tokens.mm_token_type_id_map + num_reused = len(prefix) parent = prefix[-1] if prefix else None cursor: int | None = None @@ -1080,9 +1147,7 @@ def _commit_turn(turn: PendingTurn, response: Response) -> int: # Attribute this turn's expert-routing array onto the nodes created this turn (new input # nodes in creation order, then the assistant node), each getting the routing for its tokens. # The prefix goes in too, so the position the previous turn could only pad can be corrected. - _attribute_routed_experts( - trace, prefix, new_node_ids, path_len, tokens.routed_experts if tokens else None - ) + _attribute_routed_experts(trace, prefix, new_node_ids, path_len, capture) # Sampling masks are completion-aligned, so only the sampled node carries them. _attribute_sampling_mask( diff --git a/verifiers/v1/routing.py b/verifiers/v1/routing.py new file mode 100644 index 0000000000..0faac73580 --- /dev/null +++ b/verifiers/v1/routing.py @@ -0,0 +1,294 @@ +"""Immutable paired MoE routing and codecs for the single-turn TRR v1 transport. + +Weights are the captured FP32 coefficients, not scores to normalize again. The MVP +uses Qwen3 normalized top-k weights (route scale 1). A false validity bit denotes +only a final sampled token which inference has not forwarded yet. +""" + +from __future__ import annotations + +import base64 +import binascii +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +import numpy as np + +FORMAT_VERSION = 1 +_ID_DTYPES = {"uint8": np.dtype("u1"), "uint16": np.dtype(" tuple[int, int, int]: + if ( + not isinstance(value, (list, tuple)) + or len(value) != 3 + or any(type(n) is not int for n in value) + or value[0] < 0 + or value[1] <= 0 + or value[2] <= 0 + ): + raise ValueError("routing shape must be [tokens >= 0, layers > 0, top_k > 0]") + return tuple(value) + + +def _id_dtype(value: Any) -> np.dtype: + if not isinstance(value, str) or value not in _ID_DTYPES: + raise ValueError("routing ID dtype must be uint8 or uint16 (little-endian)") + return _ID_DTYPES[value] + + +def validate_ids(ids: np.ndarray, num_experts: int | None = None) -> None: + if not isinstance(ids, np.ndarray): + raise ValueError("routing IDs must be a NumPy array") # noqa: TRY004 - ingress validation + _shape(ids.shape) + if ids.dtype not in _ID_DTYPES.values(): + raise ValueError("routing ID dtype must be uint8 or uint16 (little-endian)") + if num_experts is not None: + if type(num_experts) is not int or num_experts <= 0: + raise ValueError("num_experts must be a positive integer") + if ids.shape[2] > num_experts or (ids.size and int(ids.max()) >= num_experts): + raise ValueError("routing expert ID/top_k exceeds model expert range") + + +def _array_bytes(arr: np.ndarray) -> bytes: + """Reuse only a complete contiguous immutable buffer, never a larger allocation.""" + owner = arr + while isinstance(owner, (np.ndarray, memoryview)): + owner = owner.base if isinstance(owner, np.ndarray) else owner.obj + if isinstance(owner, bytes) and arr.flags.c_contiguous and len(owner) == arr.nbytes: + return owner + return arr.tobytes(order="C") + + +def _immutable(arr: np.ndarray) -> np.ndarray: + # Read-only alone is insufficient: the owner could enable writes again. Reuse + # complete bytes buffers (e.g. freshly decoded HTTP/msgpack), but copy partial + # slices so a small graph node cannot pin a full response. + return np.frombuffer(_array_bytes(arr), dtype=arr.dtype).reshape(arr.shape) + + +def _joined_array(parts: Sequence[np.ndarray]) -> np.ndarray: + # RoutingData fields are contiguous and already have matching dtypes/layouts. + # Join directly into immutable storage instead of concatenate -> tobytes. + shape = (sum(len(part) for part in parts), *parts[0].shape[1:]) + raw = b"".join(memoryview(part) for part in parts) + return np.frombuffer(raw, dtype=parts[0].dtype).reshape(shape) + + +@dataclass(frozen=True, eq=False) +class RoutingData: + """Read-only IDs/FP32 weights [T,L,K] and captured-row validity [T]. + + Mutable inputs and partial slices are copied. Complete immutable byte buffers + can be reused. No caller can change array data through the source or by enabling + NumPy writes. Model-specific bounds are checked by ``validate(num_experts=...)``. + """ + + ids: np.ndarray + weights: np.ndarray + valid: np.ndarray | None = None + + def __post_init__(self) -> None: + validate_ids(self.ids) + if ( + not isinstance(self.weights, np.ndarray) + or self.weights.dtype != _WEIGHT_DTYPE + ): + raise ValueError("routing weights dtype must be little-endian float32") + if self.weights.shape != self.ids.shape: + raise ValueError("routing IDs and weights must have equal [T,L,K] shape") + # Scalar reductions avoid full-size temporary masks. NaNs propagate. + minimum = self.weights.min(initial=0) + maximum = self.weights.max(initial=0) + if not (np.isfinite(minimum) and np.isfinite(maximum)): + raise ValueError("routing weights must be finite") + if minimum < 0 or maximum > 1: + raise ValueError("routing weights must be in [0, 1] for TRR v1") + valid = self.valid + if valid is None: + valid = np.frombuffer(b"\x01" * self.ids.shape[0], dtype=np.bool_) + if ( + not isinstance(valid, np.ndarray) + or valid.dtype != np.dtype("bool") + or valid.shape != (self.ids.shape[0],) + ): + raise ValueError("routing valid must be bool [T]") + if not valid[:-1].all(): + raise ValueError("only the final routing row may be invalid") + if len(valid) and not valid[-1] and np.any(self.weights[-1] != 0): + raise ValueError("invalid routing rows must have zero placeholder weights") + object.__setattr__(self, "ids", _immutable(self.ids)) + object.__setattr__(self, "weights", _immutable(self.weights)) + object.__setattr__(self, "valid", _immutable(valid)) + + def __len__(self) -> int: + return self.ids.shape[0] + + def __copy__(self) -> RoutingData: + return self + + def __deepcopy__(self, memo: dict) -> RoutingData: + return self + + def validate(self, *, num_experts: int) -> None: + """Check the model-dependent bound not available in HTTP/graph metadata.""" + validate_ids(self.ids, num_experts) + + def to_wire(self) -> dict[str, Any]: + """Encode raw bytes for graph msgpack (not the HTTP base64 envelope).""" + return { + "__routing__": True, + "format_version": FORMAT_VERSION, + "data": _array_bytes(self.ids), + "shape": list(self.ids.shape), + "dtype": str(self.ids.dtype), + "weights": {"data": _array_bytes(self.weights), "dtype": "float32"}, + "valid": _array_bytes(self.valid), + } + + @classmethod + def from_wire(cls, payload: Mapping[str, Any]) -> RoutingData: + if payload.get("__routing__") is not True: + raise ValueError("missing graph routing marker") + _version(payload, full=True) + shape = _shape(payload.get("shape")) + dtype = _id_dtype(payload.get("dtype")) + count = math.prod(shape) + ids = _raw_array(payload.get("data"), dtype, count).reshape(shape) + weights = _weights(payload, shape, base64_encoded=False) + validity = _raw_array(payload.get("valid"), np.dtype("u1"), shape[0]) + if np.any(validity > 1): + raise ValueError("routing valid bytes must be 0 or 1") + return cls(ids, weights, validity.view(np.bool_)) + + +def _version(payload: Mapping[str, Any], *, full: bool) -> None: + version = payload.get("format_version", 0) + if type(version) is not int or version != (FORMAT_VERSION if full else 0): + raise ValueError("unsupported routing format_version or missing weights") + + +def _raw_array(data: Any, dtype: np.dtype, count: int) -> np.ndarray: + if not isinstance(data, (bytes, bytearray, memoryview)): + raise ValueError("routing data must be bytes") # noqa: TRY004 - ingress validation + if memoryview(data).nbytes != count * dtype.itemsize: + raise ValueError("routing byte count does not match shape and dtype") + return np.frombuffer(data, dtype=dtype, count=count) + + +def _base64_array(data: Any, dtype: np.dtype, count: int) -> np.ndarray: + if not isinstance(data, (str, bytes, bytearray, memoryview)): + raise ValueError("routing data must be base64 text or bytes") # noqa: TRY004 - ingress validation + size = memoryview(data).nbytes if isinstance(data, memoryview) else len(data) + if size != 4 * ((count * dtype.itemsize + 2) // 3): + raise ValueError("routing base64 byte count does not match shape and dtype") + try: + raw = base64.b64decode(data, validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError("invalid routing base64 data") from exc + return _raw_array(raw, dtype, count) + + +def _weights( + payload: Mapping[str, Any], shape: tuple, *, base64_encoded: bool +) -> np.ndarray: + weights = payload.get("weights") + if not isinstance(weights, Mapping) or weights.get("dtype") != "float32": + raise ValueError("routing weights must include data and dtype=float32") + decode = _base64_array if base64_encoded else _raw_array + return decode(weights.get("data"), _WEIGHT_DTYPE, math.prod(shape)).reshape(shape) + + +def decode_routing_payload( + payload: Mapping[str, Any], + *, + require_weights: bool = False, + num_experts: int | None = None, +) -> tuple[RoutingData | np.ndarray, int]: + """Validate the Prime HTTP envelope and return paired or legacy routing + start. + + Only the legacy format permits omitted dtype/start (historically uint8/0). + Absence of weights is legacy, never an implicit zero coefficient stream. + """ + if not isinstance(payload, Mapping): + raise ValueError("routing payload must be an object") # noqa: TRY004 - ingress validation + full = "weights" in payload + if require_weights and not full: + raise ValueError("full routing replay requires captured weights") + _version(payload, full=full) + shape = _shape(payload.get("shape")) + dtype = _id_dtype(payload.get("dtype", None if full else "uint8")) + start = payload.get("start", None if full else 0) + if type(start) is not int or start < 0: + raise ValueError("routing start must be a nonnegative integer") + ids = _base64_array(payload.get("data"), dtype, math.prod(shape)).reshape(shape) + validate_ids(ids, num_experts) + if not full: + return ids, start + return RoutingData(ids, _weights(payload, shape, base64_encoded=True)), start + + +def slice_routing( + data: RoutingData | np.ndarray, start: int, stop: int +) -> RoutingData | np.ndarray: + """Own only a contiguous token span; do not retain a whole response allocation.""" + if not 0 <= start <= stop <= len(data): + raise ValueError("routing token slice is out of range") + if isinstance(data, RoutingData): + if start == 0 and stop == len(data): + return data + return RoutingData( + data.ids[start:stop], data.weights[start:stop], data.valid[start:stop] + ) + return data[start:stop].copy() + + +def concatenate_routing( + parts: Sequence[RoutingData | np.ndarray], +) -> RoutingData | np.ndarray: + if not parts: + raise ValueError("cannot concatenate empty routing parts") + full = [isinstance(part, RoutingData) for part in parts] + if not any(full): + return np.concatenate(parts, axis=0) + if not all(full): + raise ValueError("cannot mix full routing and legacy IDs-only routing") + first = parts[0] + if len(parts) == 1: + return first + if any( + part.ids.shape[1:] != first.ids.shape[1:] or part.ids.dtype != first.ids.dtype + for part in parts + ): + raise ValueError( + "routing parts must have matching layer/top_k layout and ID dtype" + ) + return RoutingData( + _joined_array([part.ids for part in parts]), + _joined_array([part.weights for part in parts]), + _joined_array([part.valid for part in parts]), + ) + + +def complete_routing_capture( + data: RoutingData, *, total_tokens: int, completion_tokens: int +) -> RoutingData: + """Allow only exact capture coverage or one unforwarded final sampled token.""" + if not data.valid.all(): + raise ValueError("capture input must contain only real routing rows") + if len(data) == total_tokens: + return data + if completion_tokens <= 0 or len(data) != total_tokens - 1: + raise ValueError( + "full routing capture must cover P+C or P+C-1 with a final sampled token" + ) + shape = (1, *data.ids.shape[1:]) + terminal = RoutingData( + np.zeros(shape, dtype=data.ids.dtype), + np.zeros(shape, dtype=_WEIGHT_DTYPE), + np.zeros(1, dtype=np.bool_), + ) + return concatenate_routing([data, terminal]) diff --git a/verifiers/v1/serve/delta.py b/verifiers/v1/serve/delta.py index 24b67c543c..2333dbec07 100644 --- a/verifiers/v1/serve/delta.py +++ b/verifiers/v1/serve/delta.py @@ -183,7 +183,8 @@ def _maybe_add_routing_repairs( """ repairs: dict[int, dict] = {} for index, node in enumerate(trace.nodes): - if node.routed_experts is None: + # Full captures are single-turn and immutable, so they are never repaired. + if not isinstance(node.routed_experts, np.ndarray): continue row = encode_ndarray(node.routed_experts[-1:]) packed = pack(row) diff --git a/verifiers/v1/trace.py b/verifiers/v1/trace.py index fca578ac62..265260da3a 100644 --- a/verifiers/v1/trace.py +++ b/verifiers/v1/trace.py @@ -20,6 +20,7 @@ from verifiers.v1.configs.sampling import Sampling from verifiers.v1.errors import ProviderError, stop_condition from verifiers.v1.graph import RECORD_FLOAT_DECIMALS, MessageNode +from verifiers.v1.routing import RoutingData, concatenate_routing from verifiers.v1.runtimes import RuntimeInfo from verifiers.v1.semantic import ACPInfo, ParentLink, SemanticEdgeSet from verifiers.v1.state import State, StateT @@ -317,14 +318,40 @@ def mm_token_type_ids(self) -> np.ndarray | None: return token_types if token_types.any() else None @property - def routed_experts(self) -> np.ndarray | None: - """uint8 `[tokens, layers, top_k]` routing; partial data returns None.""" + def routed_experts(self) -> RoutingData | np.ndarray | None: + """Routing [T,L,K]. Full mode rejects gaps/mixed modes; legacy partial data is None.""" + full = any(isinstance(n.routed_experts, RoutingData) for n in self.nodes) + if full: + for node in self.nodes: + data = node.routed_experts + if data is None: + continue + if not isinstance(data, RoutingData): + raise ValueError( # noqa: TRY004 - invalid mixed-mode evidence + "cannot mix full routing and legacy IDs-only routing" + ) + if len(data) != len(node.token_ids): + raise ValueError("full routing node must match its token span") nodes = [n for n in self.nodes if len(n.token_ids)] if not nodes or any(n.routed_experts is None for n in nodes): + if full: + raise ValueError("full routing branch has missing routing rows") return None - merged = np.concatenate([n.routed_experts for n in nodes], axis=0) + if full and sum(n.sampled for n in self.nodes) > 1: + raise ValueError("full routing replay does not support multi-turn branches") + merged = concatenate_routing([n.routed_experts for n in nodes]) total = sum(len(n.token_ids) for n in nodes) - return merged if merged.shape[0] == total else None + if isinstance(merged, RoutingData) and len(merged) and not merged.valid[-1]: + terminal = nodes[-1] + if ( + not terminal.sampled + or len(terminal.mask) != len(terminal.token_ids) + or not terminal.mask[-1] + ): + raise ValueError( + "invalid final routing row must belong to a sampled terminal token" + ) + return merged if len(merged) == total else None @property def sampling_mask(self) -> SamplingMask | None: diff --git a/verifiers/v1/types.py b/verifiers/v1/types.py index 9d79d837ab..4a64399282 100644 --- a/verifiers/v1/types.py +++ b/verifiers/v1/types.py @@ -1,10 +1,18 @@ import base64 from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, NotRequired import numpy as np -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_serializer +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + StrictInt, + StrictStr, + field_serializer, +) from pydantic.json_schema import WithJsonSchema from typing_extensions import TypedDict @@ -243,12 +251,26 @@ def total_tokens(self) -> int: return self.input_tokens + self.completion_tokens +class RoutedExpertWeights(TypedDict): + """Base64 little-endian FP32 coefficients; shares the outer routing shape/start.""" + + data: Any + dtype: StrictStr + + class RoutedExperts(TypedDict): - """Base64 uint8 `[tokens, layers, top_k]` routing and its prompt offset.""" + """Prime HTTP routing payload; absent weights preserves legacy ID-only behavior. + + Strict metadata types prevent Pydantic coercion from hiding malformed shapes or + versions. Graph ingress also validates construct-only train-client responses. + """ data: Any - shape: list[int] - start: int + shape: list[StrictInt] + start: NotRequired[StrictInt] + dtype: NotRequired[StrictStr] + format_version: NotRequired[StrictInt] + weights: NotRequired[RoutedExpertWeights] class WeightVersionSpan(BaseModel):