multimodalart HF Staff commited on
Commit
e76b846
·
verified ·
1 Parent(s): 2efa875

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,31 @@
1
  ---
2
- title: Timebraid
3
- emoji: 📈
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.29.1
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TimeBraid
3
+ emoji: 🧵
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.29.1
 
8
  app_file: app.py
9
+ short_description: Time series + language model for analysis and forecasting
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # TimeBraid
15
+
16
+ Interactive demo of **TimeBraid-2.5B** — a unified time-series + language model that
17
+ interleaves a Qwen3-1.7B language backbone with TimesFM 2.5 200M time-series experts
18
+ through Mixture-of-Transformers layers.
19
+
20
+ Paste numeric series (one series per line, values comma- or space-separated) and ask a
21
+ question, or set a forecast horizon to get a numeric point forecast plotted against the
22
+ observed history.
23
+
24
+ - Model: [XinyueWangg/TimeBraid-2.5B](https://huggingface.co/XinyueWangg/TimeBraid-2.5B) (Apache-2.0)
25
+ - Code: [CharonWangg/TimeBraid](https://github.com/CharonWangg/TimeBraid)
26
+ - The `timebraid/` package in this Space is the authors' inference package, shipped
27
+ verbatim so `AutoModelForCausalLM` resolves the custom architecture with
28
+ `trust_remote_code=False`.
29
+
30
+ Runs on ZeroGPU. Example inputs are drawn from the authors'
31
+ `examples/inference_tasks.ipynb` notebook (Apache-2.0).
app.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TimeBraid: unified time series + language demo on ZeroGPU."""
2
+
3
+ import os
4
+
5
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
6
+
7
+ import spaces # MUST come before any torch / CUDA-touching import
8
+ import time
9
+
10
+ import gradio as gr
11
+ import matplotlib
12
+
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ import torch
16
+ from matplotlib.ticker import MaxNLocator
17
+ from transformers import AutoModelForCausalLM, AutoProcessor
18
+
19
+ from timebraid import TimeBraidProcessor # noqa: F401 - registers HF AutoClasses
20
+
21
+ MODEL_ID = "XinyueWangg/TimeBraid-2.5B"
22
+
23
+ # Chart conventions from the authors' notebook (examples/inference_tasks.ipynb).
24
+ _INPUT_COLOR = "#0072B2"
25
+ _FORECAST_COLOR = "#D55E00"
26
+ _BOUNDARY_COLOR = "#555555"
27
+ _SERIES_COLORS = ("#0072B2", "#009E73", "#CC79A7", "#E69F00")
28
+
29
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=False)
30
+ model = (
31
+ AutoModelForCausalLM.from_pretrained(
32
+ MODEL_ID,
33
+ dtype=torch.bfloat16,
34
+ attn_implementation="flash_attention_2",
35
+ trust_remote_code=False,
36
+ use_safetensors=True,
37
+ )
38
+ .eval()
39
+ .to("cuda")
40
+ )
41
+
42
+
43
+ def parse_series(text: str) -> list[list[float]]:
44
+ """Parse one series per line; values comma- or whitespace-separated."""
45
+ if text is None:
46
+ return []
47
+ series: list[list[float]] = []
48
+ for line in text.splitlines():
49
+ line = line.strip().rstrip(",")
50
+ if not line:
51
+ continue
52
+ tokens = line.replace(",", " ").split()
53
+ values = [float(token) for token in tokens]
54
+ if not values:
55
+ raise ValueError("A series line parsed to zero values.")
56
+ series.append(values)
57
+ return series
58
+
59
+
60
+ def render_plot(
61
+ history: list[list[float]],
62
+ forecast: list[float] | None,
63
+ target_index: int | None,
64
+ ) -> matplotlib.figure.Figure:
65
+ """Draw observed history and, when present, the forecast continuation."""
66
+ n = len(history)
67
+ fig, axes = plt.subplots(
68
+ n, 1, figsize=(9.5, 2.9 * n), constrained_layout=True, squeeze=False
69
+ )
70
+ for index, (ax, values) in enumerate(zip(axes[:, 0], history)):
71
+ color = _INPUT_COLOR if forecast is not None else _SERIES_COLORS[index % 4]
72
+ x = list(range(len(values)))
73
+ ax.plot(
74
+ x,
75
+ values,
76
+ color=color,
77
+ linewidth=2.0,
78
+ marker="o" if len(values) <= 64 else None,
79
+ markersize=4,
80
+ label="Observed history" if forecast is not None else f"Series {index + 1}",
81
+ )
82
+ title = f"INPUT TIMESERIES · Series {index + 1}"
83
+ if forecast is not None and target_index == index:
84
+ future_x = list(range(len(values), len(values) + len(forecast)))
85
+ boundary = len(values) - 0.5
86
+ ax.axvspan(boundary, future_x[-1] + 0.5, color=_FORECAST_COLOR, alpha=0.06)
87
+ ax.plot(
88
+ (len(values) - 1, *future_x),
89
+ (values[-1], *forecast),
90
+ color=_FORECAST_COLOR,
91
+ linewidth=2.0,
92
+ linestyle="--",
93
+ label="Point forecast",
94
+ )
95
+ ax.scatter(future_x, forecast, color=_FORECAST_COLOR, s=24, zorder=3)
96
+ ax.axvline(
97
+ boundary,
98
+ color=_BOUNDARY_COLOR,
99
+ linewidth=1.2,
100
+ linestyle=":",
101
+ label="Forecast boundary",
102
+ )
103
+ ax.text(
104
+ boundary,
105
+ 1.01,
106
+ "forecast starts",
107
+ transform=ax.get_xaxis_transform(),
108
+ ha="center",
109
+ va="bottom",
110
+ fontsize=8,
111
+ color=_BOUNDARY_COLOR,
112
+ )
113
+ title = (
114
+ f"INPUT HISTORY + OUTPUT FORECAST · Series {index + 1} (forecast target)"
115
+ )
116
+ ax.set_title(title, loc="left", fontsize=11, fontweight="bold")
117
+ ax.set_xlabel("Observation / forecast index" if forecast is not None else "Observation index")
118
+ ax.set_ylabel("Value (raw scale)")
119
+ ax.xaxis.set_major_locator(MaxNLocator(integer=True))
120
+ ax.grid(axis="y", color="#D9DCE1", linewidth=0.8, alpha=0.7)
121
+ ax.set_axisbelow(True)
122
+ ax.spines["top"].set_visible(False)
123
+ ax.spines["right"].set_visible(False)
124
+ ax.legend(loc="upper left", frameon=False, ncol=3, fontsize=9)
125
+ return fig
126
+
127
+
128
+ @spaces.GPU(duration=120)
129
+ def run(
130
+ prompt: str,
131
+ series_text: str,
132
+ horizon: float,
133
+ target_series_index: float,
134
+ max_new_tokens: float,
135
+ progress=gr.Progress(track_tqdm=True),
136
+ ):
137
+ """Analyze or forecast a time series with TimeBraid.
138
+
139
+ Args:
140
+ prompt: Question or instruction about the series (or a plain text question).
141
+ series_text: Numeric series, one per line; values comma- or space-separated.
142
+ horizon: Number of future points to forecast; 0 = no forecast (understanding only).
143
+ target_series_index: 1-based index of the series to forecast (multi-series only).
144
+ max_new_tokens: Maximum generated text tokens.
145
+ Returns:
146
+ (answer text, forecast values text, plot, elapsed seconds text).
147
+ """
148
+ started = time.perf_counter()
149
+ try:
150
+ series = parse_series(series_text)
151
+ except ValueError as exc:
152
+ raise gr.Error(f"Could not parse the time series: {exc}")
153
+
154
+ horizon_i = int(horizon) if horizon and horizon > 0 else None
155
+ target_i: int | None = None
156
+ if horizon_i is not None:
157
+ if not series:
158
+ raise gr.Error("A forecast horizon needs at least one input series.")
159
+ if len(series) > 1:
160
+ if not target_series_index or target_series_index < 1:
161
+ raise gr.Error(
162
+ "With multiple series, pick which one to forecast (Target series)."
163
+ )
164
+ if int(target_series_index) > len(series):
165
+ raise gr.Error(
166
+ f"Target series is out of range: only {len(series)} series given."
167
+ )
168
+ target_i = int(target_series_index) - 1
169
+
170
+ messages = [{"role": "user", "content": prompt}]
171
+ inputs = processor.apply_chat_template(
172
+ messages,
173
+ timeseries=series if series else None,
174
+ horizon=horizon_i,
175
+ target_series_index=target_i,
176
+ add_generation_prompt=True,
177
+ tokenize=True,
178
+ return_dict=True,
179
+ return_tensors="pt",
180
+ ).to(model.device)
181
+ with torch.inference_mode():
182
+ outputs = model.generate(
183
+ **inputs,
184
+ max_new_tokens=int(max_new_tokens),
185
+ do_sample=False,
186
+ num_beams=1,
187
+ num_return_sequences=1,
188
+ )
189
+ result = processor.post_process_generation(outputs, model_inputs=inputs)
190
+ elapsed = time.perf_counter() - started
191
+
192
+ answer = (result.get("content") or "").strip()
193
+ if not answer and horizon_i is None:
194
+ answer = "(empty response)"
195
+ ts = result.get("timeseries")
196
+ forecast_values = None
197
+ forecast_text = "No numeric forecast (understanding route)."
198
+ if ts is not None and ts.get("values"):
199
+ forecast_values = [float(v) for v in ts["values"]]
200
+ forecast_text = ", ".join(f"{v:.4g}" for v in forecast_values)
201
+
202
+ plot = render_plot(series, forecast_values, result.get("target_series_index"))
203
+ stats = (
204
+ f"route: {'forecast' if horizon_i is not None else 'understanding'} · "
205
+ f"prompt tokens: {result.get('prompt_tokens')} · "
206
+ f"completion tokens: {result.get('completion_tokens')} · "
207
+ f"finish: {result.get('finish_reason')} · {elapsed:.1f}s on GPU"
208
+ )
209
+ return answer, forecast_text, plot, stats
210
+
211
+
212
+ CSS = """
213
+ #col-container { max-width: 1150px; margin: 0 auto; }
214
+ .dark .gradio-container { color: var(--body-text-color); }
215
+ """
216
+
217
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="TimeBraid") as demo:
218
+ with gr.Column(elem_id="col-container"):
219
+ gr.Markdown(
220
+ """
221
+ # 🧵 TimeBraid: Unifying Time Series and Language
222
+ Ask questions about numeric series, or request a point forecast.
223
+ One series per line, values comma- or space-separated. Leave the series
224
+ box empty for a pure text question. Set **Forecast horizon** to 0 for
225
+ analysis-only, or a positive number to get numeric forecast values.
226
+ **Model:** [XinyueWangg/TimeBraid-2.5B](https://huggingface.co/XinyueWangg/TimeBraid-2.5B) ·
227
+ **Code:** [GitHub](https://github.com/CharonWangg/TimeBraid) (Apache-2.0)
228
+ """
229
+ )
230
+ with gr.Row():
231
+ with gr.Column(scale=1):
232
+ prompt = gr.Textbox(
233
+ label="Prompt",
234
+ placeholder="e.g. Describe the dominant trend and turning points, or forecast the next 8 values.",
235
+ lines=3,
236
+ )
237
+ series_box = gr.Textbox(
238
+ label="Time series (one series per line)",
239
+ placeholder="101.2, 101.8, 102.1, 102.5, 103.0, 103.4\n200, 208, 215, ...",
240
+ lines=5,
241
+ )
242
+ horizon = gr.Number(
243
+ label="Forecast horizon (0 = no forecast)",
244
+ value=0,
245
+ precision=0,
246
+ minimum=0,
247
+ )
248
+ run_btn = gr.Button("Run", variant="primary")
249
+ with gr.Accordion("Advanced settings", open=False):
250
+ target_idx = gr.Number(
251
+ label="Target series (1-based; multi-series forecasts)",
252
+ value=1,
253
+ precision=0,
254
+ minimum=1,
255
+ )
256
+ max_new_tokens = gr.Slider(
257
+ label="Max new text tokens",
258
+ minimum=64,
259
+ maximum=1024,
260
+ value=256,
261
+ step=32,
262
+ )
263
+ with gr.Column(scale=1):
264
+ answer_out = gr.Textbox(label="Answer", lines=8, show_copy_button=True)
265
+ forecast_out = gr.Textbox(
266
+ label="Forecast values (raw scale)", show_copy_button=True
267
+ )
268
+ plot_out = gr.Plot(label="Series and forecast")
269
+ stats_out = gr.Markdown()
270
+ gr.Examples(
271
+ examples=[
272
+ [
273
+ "Forecast the next 8 values from the observed seasonal pattern.",
274
+ "100, 102, 105, 107, 103, 101, 99, 100, 103, 106, 108, 104, 102, 100, 101, 104",
275
+ 8,
276
+ 1,
277
+ 256,
278
+ ],
279
+ [
280
+ "A promotion starts at the first forecast step and is expected to lift demand above the recent seasonal baseline. Forecast the next 6 values.",
281
+ "200, 208, 215, 205, 198, 210, 218, 207, 201, 212, 220, 209",
282
+ 6,
283
+ 1,
284
+ 256,
285
+ ],
286
+ [
287
+ "Describe the dominant trend, major turning points, and whether the series becomes more volatile.",
288
+ "2.0, 2.1, 2.2, 2.4, 2.8, 3.1, 3.0, 2.9, 3.4, 3.8, 4.1, 4.3",
289
+ 0,
290
+ 1,
291
+ 256,
292
+ ],
293
+ [
294
+ "Does this series contain an isolated upward spike? Say only whether it occurs near the beginning, middle, or end; do not give a numeric index. Compare it with the neighboring level.",
295
+ "20.1, 20.0, 20.2, 20.1, 20.3, 34.8, 20.2, 20.1, 20.0, 20.2",
296
+ 0,
297
+ 1,
298
+ 256,
299
+ ],
300
+ [
301
+ "Series 1 is website visits and Series 2 is conversion rate. Compare their trends, volatility, and co-movement.",
302
+ "100, 104, 107, 111, 116, 120, 125, 129\n3.2, 3.1, 3.3, 3.4, 3.8, 3.7, 4.0, 4.2",
303
+ 0,
304
+ 1,
305
+ 256,
306
+ ],
307
+ [
308
+ "Explain in one sentence what a moving average reveals about a time series.",
309
+ "",
310
+ 0,
311
+ 1,
312
+ 256,
313
+ ],
314
+ ],
315
+ inputs=[prompt, series_box, horizon, target_idx, max_new_tokens],
316
+ outputs=[answer_out, forecast_out, plot_out, stats_out],
317
+ fn=run,
318
+ cache_examples=True,
319
+ cache_mode="lazy",
320
+ label="Examples (from the authors' inference notebook)",
321
+ )
322
+ run_btn.click(
323
+ run,
324
+ inputs=[prompt, series_box, horizon, target_idx, max_new_tokens],
325
+ outputs=[answer_out, forecast_out, plot_out, stats_out],
326
+ api_name="run",
327
+ concurrency_limit=1,
328
+ )
329
+
330
+ if __name__ == "__main__":
331
+ demo.launch(mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ transformers==4.57.6
2
+ accelerate==1.11.0
3
+ numpy==2.1.3
4
+ safetensors==0.5.3
5
+ matplotlib
6
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt211-cu130-cp312/flash_attn-2.8.3-cp312-cp312-linux_x86_64.whl
timebraid/__init__.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public TimeBraid package with lazy Hugging Face runtime exports."""
2
+
3
+ __all__ = [
4
+ # Runtime types
5
+ "GenerationRoute",
6
+ "TimeBraid",
7
+ "TimeBraidBatchFeature",
8
+ "TimeBraidConfig",
9
+ "TimeBraidGenerateOutput",
10
+ "TimeBraidLossBreakdown",
11
+ "TimeBraidOutput",
12
+ "TimeBraidProcessor",
13
+ # Registration
14
+ "register_timebraid_auto_classes",
15
+ # Loading and validation
16
+ "LoadedTimeBraid",
17
+ "ValidatedTimeBraidCheckpointSource",
18
+ "load_timebraid_checkpoint",
19
+ "load_timebraid_tokenizer",
20
+ "validate_timebraid_canonical_artifact",
21
+ "validate_timebraid_checkpoint_source",
22
+ "validate_timebraid_model",
23
+ ]
24
+
25
+
26
+ def __getattr__(name: str):
27
+ if name not in __all__:
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29
+ from .model import (
30
+ LoadedTimeBraid,
31
+ TimeBraid,
32
+ TimeBraidConfig,
33
+ TimeBraidGenerateOutput,
34
+ TimeBraidLossBreakdown,
35
+ TimeBraidOutput,
36
+ ValidatedTimeBraidCheckpointSource,
37
+ load_timebraid_checkpoint,
38
+ load_timebraid_tokenizer,
39
+ validate_timebraid_canonical_artifact,
40
+ validate_timebraid_checkpoint_source,
41
+ validate_timebraid_model,
42
+ )
43
+ from .model.mot.generation_route import GenerationRoute
44
+ from .model.timebraid import register_timebraid_auto_classes
45
+ from .processing_timebraid import TimeBraidBatchFeature, TimeBraidProcessor
46
+
47
+ # Importing any public name registers the Hugging Face auto classes, so
48
+ # `AutoModelForCausalLM.from_pretrained` resolves without remote code.
49
+ # `register_timebraid_auto_classes` is exported as well, because relying on
50
+ # an import side effect forces callers to suppress an unused-import warning.
51
+ register_timebraid_auto_classes()
52
+ return {
53
+ "GenerationRoute": GenerationRoute,
54
+ "LoadedTimeBraid": LoadedTimeBraid,
55
+ "TimeBraid": TimeBraid,
56
+ "TimeBraidBatchFeature": TimeBraidBatchFeature,
57
+ "TimeBraidConfig": TimeBraidConfig,
58
+ "TimeBraidGenerateOutput": TimeBraidGenerateOutput,
59
+ "TimeBraidLossBreakdown": TimeBraidLossBreakdown,
60
+ "TimeBraidOutput": TimeBraidOutput,
61
+ "TimeBraidProcessor": TimeBraidProcessor,
62
+ "ValidatedTimeBraidCheckpointSource": ValidatedTimeBraidCheckpointSource,
63
+ "load_timebraid_checkpoint": load_timebraid_checkpoint,
64
+ "load_timebraid_tokenizer": load_timebraid_tokenizer,
65
+ "register_timebraid_auto_classes": register_timebraid_auto_classes,
66
+ "validate_timebraid_canonical_artifact": validate_timebraid_canonical_artifact,
67
+ "validate_timebraid_checkpoint_source": validate_timebraid_checkpoint_source,
68
+ "validate_timebraid_model": validate_timebraid_model,
69
+ }[name]
timebraid/_vendor/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Third-party runtime code vendored for reproducible TimeBraid inference."""
timebraid/_vendor/timesfm/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Vendored TimesFM module surface used by TimeBraid."""
17
+
18
+ from .timesfm_2p5.timesfm_2p5_torch import TimesFM_2p5_200M_torch_module
19
+
20
+ __all__ = ["TimesFM_2p5_200M_torch_module"]
timebraid/_vendor/timesfm/configs.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Abstract configs for TimesFM layers."""
17
+
18
+ import dataclasses
19
+ from typing import Literal
20
+
21
+
22
+ @dataclasses.dataclass(frozen=True)
23
+ class ResidualBlockConfig:
24
+ """Framework-agnostic config for a residual block."""
25
+
26
+ input_dims: int
27
+ hidden_dims: int
28
+ output_dims: int
29
+ use_bias: bool
30
+ activation: Literal["relu", "swish", "none"]
31
+
32
+
33
+ @dataclasses.dataclass(frozen=True)
34
+ class RandomFourierFeaturesConfig:
35
+ """Framework-agnostic config for random fourier features."""
36
+
37
+ input_dims: int
38
+ output_dims: int
39
+ projection_stddev: float
40
+ use_bias: bool
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True)
44
+ class TransformerConfig:
45
+ """Framework-agnostic config for a transformer."""
46
+
47
+ model_dims: int
48
+ hidden_dims: int
49
+ num_heads: int
50
+ attention_norm: Literal["rms"]
51
+ feedforward_norm: Literal["rms"]
52
+ qk_norm: Literal["rms", "none"]
53
+ use_bias: bool
54
+ use_rotary_position_embeddings: bool
55
+ ff_activation: Literal["relu", "swish", "none"]
56
+ fuse_qkv: bool
57
+
58
+
59
+ @dataclasses.dataclass(frozen=True)
60
+ class StackedTransformersConfig:
61
+ """Framework-agnostic config for a stacked transformers."""
62
+
63
+ num_layers: int
64
+ transformer: TransformerConfig
timebraid/_vendor/timesfm/timesfm_2p5/timesfm_2p5_base.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """TimesFM 2p5 base implementation."""
17
+
18
+ import dataclasses
19
+
20
+ from .. import configs
21
+
22
+ ResidualBlockConfig = configs.ResidualBlockConfig
23
+ StackedTransformersConfig = configs.StackedTransformersConfig
24
+ TransformerConfig = configs.TransformerConfig
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True)
28
+ class TimesFM_2p5_200M_Definition:
29
+ """Framework-agnostic config of TimesFM 2.5."""
30
+
31
+ context_limit = 16384
32
+ input_patch_len: int = 32
33
+ output_patch_len: int = 128
34
+ output_quantile_len: int = 1024
35
+ quantiles: list[float] = dataclasses.field(
36
+ default_factory=lambda: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
37
+ )
38
+ decode_index: int = 5
39
+ tokenizer: ResidualBlockConfig = ResidualBlockConfig(
40
+ input_dims=64,
41
+ hidden_dims=1280,
42
+ output_dims=1280,
43
+ use_bias=True,
44
+ activation="swish",
45
+ )
46
+ stacked_transformers: StackedTransformersConfig = StackedTransformersConfig(
47
+ num_layers=20,
48
+ transformer=TransformerConfig(
49
+ model_dims=1280,
50
+ hidden_dims=1280,
51
+ num_heads=16,
52
+ attention_norm="rms",
53
+ feedforward_norm="rms",
54
+ qk_norm="rms",
55
+ use_bias=False,
56
+ use_rotary_position_embeddings=True,
57
+ ff_activation="swish",
58
+ fuse_qkv=True,
59
+ ),
60
+ )
61
+ output_projection_point: ResidualBlockConfig = ResidualBlockConfig(
62
+ input_dims=1280,
63
+ hidden_dims=1280,
64
+ output_dims=1280,
65
+ use_bias=False,
66
+ activation="swish",
67
+ )
68
+ output_projection_quantiles: ResidualBlockConfig = ResidualBlockConfig(
69
+ input_dims=1280,
70
+ hidden_dims=1280,
71
+ output_dims=10240,
72
+ use_bias=False,
73
+ activation="swish",
74
+ )
timebraid/_vendor/timesfm/timesfm_2p5/timesfm_2p5_torch.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """TimesFM models."""
16
+
17
+ from typing import Sequence
18
+
19
+ import numpy as np
20
+ import torch
21
+ from safetensors.torch import load_file
22
+ from torch import nn
23
+
24
+ from ..torch import dense, transformer, util
25
+ from . import timesfm_2p5_base
26
+
27
+ revin = util.revin
28
+
29
+
30
+ class TimesFM_2p5_200M_torch_module(nn.Module):
31
+ """TimesFM 2.5 with 200M parameters."""
32
+
33
+ config = timesfm_2p5_base.TimesFM_2p5_200M_Definition()
34
+
35
+ def __init__(self):
36
+ super().__init__()
37
+
38
+ # Names constants.
39
+ self.p = self.config.input_patch_len # 32
40
+ self.o = self.config.output_patch_len # 128
41
+ self.os = self.config.output_quantile_len # 1024
42
+ self.m = self.o // self.p # 4
43
+ self.x = self.config.stacked_transformers.num_layers # 20
44
+ self.h = self.config.stacked_transformers.transformer.num_heads # 16
45
+ self.md = self.config.stacked_transformers.transformer.model_dims # 1280
46
+ self.hd = self.md // self.h # 80
47
+ self.q = len(self.config.quantiles) + 1 # 10
48
+ self.aridx = self.config.decode_index # 5
49
+
50
+ # Layers.
51
+ self.tokenizer = dense.ResidualBlock(self.config.tokenizer)
52
+ self.stacked_xf = nn.ModuleList(
53
+ [
54
+ transformer.Transformer(self.config.stacked_transformers.transformer)
55
+ for _ in range(self.x)
56
+ ]
57
+ )
58
+ self.output_projection_point = dense.ResidualBlock(
59
+ self.config.output_projection_point
60
+ )
61
+ self.output_projection_quantiles = dense.ResidualBlock(
62
+ self.config.output_projection_quantiles
63
+ )
64
+
65
+ # Device.
66
+ if torch.cuda.is_available():
67
+ self.device = torch.device("cuda:0")
68
+ self.device_count = torch.cuda.device_count()
69
+ else:
70
+ self.device = torch.device("cpu")
71
+ self.device_count = 1
72
+
73
+ def load_checkpoint(self, path: str, **kwargs):
74
+ """Loads a PyTorch TimesFM model from a checkpoint."""
75
+ tensors = load_file(path)
76
+ self.load_state_dict(tensors, strict=True)
77
+ self.to(self.device)
78
+ torch_compile = True
79
+ if "torch_compile" in kwargs:
80
+ torch_compile = kwargs["torch_compile"]
81
+ if torch_compile:
82
+ print("Compiling model...")
83
+ self = torch.compile(self)
84
+
85
+ self.eval()
86
+
87
+ def forward(
88
+ self,
89
+ inputs: torch.Tensor,
90
+ masks: torch.Tensor,
91
+ decode_caches: list[util.DecodeCache] | None = None,
92
+ ):
93
+ tokenizer_inputs = torch.cat([inputs, masks.to(inputs.dtype)], dim=-1)
94
+ input_embeddings = self.tokenizer(tokenizer_inputs)
95
+
96
+ if decode_caches is None:
97
+ decode_caches = [None] * self.x
98
+
99
+ output_embeddings = input_embeddings
100
+ new_decode_caches = []
101
+ for i, layer in enumerate(self.stacked_xf):
102
+ output_embeddings, new_cache = layer(
103
+ output_embeddings, masks[..., -1], decode_caches[i]
104
+ )
105
+ new_decode_caches.append(new_cache)
106
+ output_ts = self.output_projection_point(output_embeddings)
107
+ output_quantile_spread = self.output_projection_quantiles(output_embeddings)
108
+
109
+ return (
110
+ input_embeddings,
111
+ output_embeddings,
112
+ output_ts,
113
+ output_quantile_spread,
114
+ ), new_decode_caches
115
+
116
+ def decode(self, horizon: int, inputs, masks):
117
+ """Decodes the time series."""
118
+
119
+ with torch.no_grad():
120
+ batch_size, context = inputs.shape[0], inputs.shape[1]
121
+ num_decode_steps = (horizon - 1) // self.o
122
+ num_input_patches = context // self.p
123
+ decode_cache_size = num_input_patches + num_decode_steps * self.m
124
+
125
+ # Prefill
126
+ patched_inputs = torch.reshape(inputs, (batch_size, -1, self.p))
127
+ patched_masks = torch.reshape(masks, (batch_size, -1, self.p))
128
+
129
+ # running stats
130
+ n = torch.zeros(batch_size, device=inputs.device)
131
+ mu = torch.zeros(batch_size, device=inputs.device)
132
+ sigma = torch.zeros(batch_size, device=inputs.device)
133
+ patch_mu = []
134
+ patch_sigma = []
135
+ for i in range(num_input_patches):
136
+ (n, mu, sigma), _ = util.update_running_stats(
137
+ n, mu, sigma, patched_inputs[:, i], patched_masks[:, i]
138
+ )
139
+ patch_mu.append(mu)
140
+ patch_sigma.append(sigma)
141
+ last_n, last_mu, last_sigma = n, mu, sigma
142
+ context_mu = torch.stack(patch_mu, dim=1)
143
+ context_sigma = torch.stack(patch_sigma, dim=1)
144
+
145
+ decode_caches = [
146
+ util.DecodeCache(
147
+ next_index=torch.zeros(batch_size, dtype=torch.int32, device=inputs.device),
148
+ num_masked=torch.zeros(batch_size, dtype=torch.int32, device=inputs.device),
149
+ key=torch.zeros(
150
+ batch_size,
151
+ decode_cache_size,
152
+ self.h,
153
+ self.hd,
154
+ device=inputs.device,
155
+ ),
156
+ value=torch.zeros(
157
+ batch_size,
158
+ decode_cache_size,
159
+ self.h,
160
+ self.hd,
161
+ device=inputs.device,
162
+ ),
163
+ )
164
+ for _ in range(self.x)
165
+ ]
166
+
167
+ normed_inputs = revin(patched_inputs, context_mu, context_sigma, reverse=False)
168
+ normed_inputs = torch.where(patched_masks, 0.0, normed_inputs)
169
+ (_, _, normed_outputs, normed_quantile_spread), decode_caches = self(
170
+ normed_inputs, patched_masks, decode_caches
171
+ )
172
+ renormed_outputs = torch.reshape(
173
+ revin(normed_outputs, context_mu, context_sigma, reverse=True),
174
+ (batch_size, -1, self.o, self.q),
175
+ )
176
+ renormed_quantile_spread = torch.reshape(
177
+ revin(normed_quantile_spread, context_mu, context_sigma, reverse=True),
178
+ (batch_size, -1, self.os, self.q),
179
+ )[:, -1, ...]
180
+
181
+ # Autogressive decode
182
+ ar_outputs = []
183
+ last_renormed_output = renormed_outputs[:, -1, :, self.aridx]
184
+
185
+ for _ in range(num_decode_steps):
186
+ new_patched_input = torch.reshape(
187
+ last_renormed_output, (batch_size, self.m, self.p)
188
+ )
189
+ new_mask = torch.zeros_like(new_patched_input, dtype=torch.bool)
190
+
191
+ n, mu, sigma = last_n, last_mu, last_sigma
192
+ new_mus, new_sigmas = [], []
193
+ for i in range(self.m):
194
+ (n, mu, sigma), _ = util.update_running_stats(
195
+ n, mu, sigma, new_patched_input[:, i], new_mask[:, i]
196
+ )
197
+ new_mus.append(mu)
198
+ new_sigmas.append(sigma)
199
+ last_n, last_mu, last_sigma = n, mu, sigma
200
+ new_mu = torch.stack(new_mus, dim=1)
201
+ new_sigma = torch.stack(new_sigmas, dim=1)
202
+
203
+ new_normed_input = revin(new_patched_input, new_mu, new_sigma, reverse=False)
204
+ (_, _, new_normed_output, _), decode_caches = self(
205
+ new_normed_input, new_mask, decode_caches
206
+ )
207
+
208
+ new_renormed_output = torch.reshape(
209
+ revin(new_normed_output, new_mu, new_sigma, reverse=True),
210
+ (batch_size, self.m, self.o, self.q),
211
+ )
212
+ ar_outputs.append(new_renormed_output[:, -1, ...])
213
+ last_renormed_output = new_renormed_output[:, -1, :, self.aridx]
214
+
215
+ if num_decode_steps > 0:
216
+ ar_renormed_outputs = torch.stack(ar_outputs, dim=1)
217
+ else:
218
+ ar_renormed_outputs = None
219
+
220
+ return renormed_outputs, renormed_quantile_spread, ar_renormed_outputs
221
+
222
+ def forecast_naive(
223
+ self, horizon: int, inputs: Sequence[np.ndarray]
224
+ ) -> list[np.ndarray]:
225
+ """Forecasts the time series.
226
+
227
+ This is a naive implementation for debugging purposes. No forecasting
228
+ flags are used here. Forecasting quality can be subpar.
229
+
230
+ Args:
231
+ horizon: The number of time points to forecast.
232
+ inputs: A sequence of numpy arrays, each representing a time series to
233
+ query forecast for.
234
+
235
+ Returns:
236
+ A list of numpy arrays of forecasts.
237
+ """
238
+ outputs = []
239
+ for each_input in inputs:
240
+ input_t = torch.tensor(each_input, dtype=torch.float32)
241
+ mask = torch.zeros_like(input_t, dtype=torch.bool)
242
+ len_front_mask = self.p - (len(each_input) % self.p)
243
+ if len_front_mask < self.p:
244
+ input_t = torch.cat(
245
+ [torch.zeros(len_front_mask, dtype=torch.float32), input_t], dim=0
246
+ )
247
+ mask = torch.cat([torch.ones(len_front_mask, dtype=torch.bool), mask], dim=0)
248
+ input_t = input_t[None, ...]
249
+ mask = mask[None, ...]
250
+ t_pf, _, t_ar = self.decode(horizon, input_t, mask)
251
+ to_concat = [t_pf[:, -1, ...]]
252
+ if t_ar is not None:
253
+ to_concat.append(t_ar.reshape(1, -1, self.q))
254
+ torch_forecast = torch.cat(to_concat, dim=1)[..., :horizon]
255
+ torch_forecast = torch_forecast.squeeze(0)
256
+ outputs.append(torch_forecast.detach().cpu().numpy())
257
+ return outputs
timebraid/_vendor/timesfm/torch/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
timebraid/_vendor/timesfm/torch/dense.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Dense layers for TimesFM."""
17
+
18
+ import torch
19
+ from torch import nn
20
+
21
+ from .. import configs
22
+
23
+
24
+ class ResidualBlock(nn.Module):
25
+ """Residual block with two linear layers and a linear residual connection."""
26
+
27
+ def __init__(self, config: configs.ResidualBlockConfig):
28
+ super().__init__()
29
+ self.config = config
30
+ self.hidden_layer = nn.Linear(
31
+ in_features=config.input_dims,
32
+ out_features=config.hidden_dims,
33
+ bias=config.use_bias,
34
+ )
35
+ self.output_layer = nn.Linear(
36
+ in_features=config.hidden_dims,
37
+ out_features=config.output_dims,
38
+ bias=config.use_bias,
39
+ )
40
+ self.residual_layer = nn.Linear(
41
+ in_features=config.input_dims,
42
+ out_features=config.output_dims,
43
+ bias=config.use_bias,
44
+ )
45
+ if config.activation == "relu":
46
+ self.activation = nn.ReLU()
47
+ elif config.activation == "swish":
48
+ self.activation = nn.SiLU()
49
+ elif config.activation == "none":
50
+ self.activation = nn.Identity()
51
+ else:
52
+ raise ValueError(f"Activation: {config.activation} not supported.")
53
+
54
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
55
+ return self.output_layer(
56
+ self.activation(self.hidden_layer(x))
57
+ ) + self.residual_layer(x)
58
+
59
+
60
+ class RandomFourierFeatures(nn.Module):
61
+ """Random Fourier features layer."""
62
+
63
+ def __init__(self, config: configs.RandomFourierFeaturesConfig):
64
+ super().__init__()
65
+ self.config = config
66
+
67
+ if config.output_dims % 4 != 0:
68
+ raise ValueError(
69
+ f"Output dims must be a multiple of 4: {config.output_dims} % 4 != 0."
70
+ )
71
+ num_projected_features = config.output_dims // 4
72
+
73
+ self.phase_shifts = nn.Parameter(torch.zeros(2, num_projected_features))
74
+ self.projection_layer = nn.Linear(
75
+ in_features=config.input_dims,
76
+ out_features=num_projected_features,
77
+ bias=config.use_bias,
78
+ )
79
+ self.residual_layer = nn.Linear(
80
+ in_features=config.input_dims,
81
+ out_features=config.output_dims,
82
+ bias=config.use_bias,
83
+ )
84
+
85
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
86
+ projected = self.projection_layer(x)
87
+ cos_features = torch.cos(projected)
88
+ sin_features = torch.sin(projected)
89
+ sq_wave_1 = torch.sign(torch.sin(projected + self.phase_shifts[0, :]))
90
+ sq_wave_2 = torch.sign(torch.sin(projected + self.phase_shifts[1, :]))
91
+ fourier_features = torch.cat(
92
+ [cos_features, sin_features, sq_wave_1, sq_wave_2], dim=-1
93
+ )
94
+ residual = self.residual_layer(x)
95
+ return fourier_features + residual
timebraid/_vendor/timesfm/torch/normalization.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Normalization layers for TimesFM."""
17
+
18
+ import torch
19
+ from torch import nn
20
+
21
+
22
+ class RMSNorm(nn.Module):
23
+ """RMS normalization."""
24
+
25
+ def __init__(
26
+ self,
27
+ num_features: int,
28
+ *,
29
+ epsilon: float = 1e-6,
30
+ ):
31
+ super().__init__()
32
+ self.scale = nn.Parameter(torch.zeros(num_features))
33
+ self.num_features = num_features
34
+ self.epsilon = epsilon
35
+
36
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
37
+ var = torch.mean(torch.square(inputs), dim=-1, keepdim=True)
38
+ normed_inputs = inputs * torch.rsqrt(var + self.epsilon)
39
+ normed_inputs = normed_inputs * self.scale
40
+ return normed_inputs
timebraid/_vendor/timesfm/torch/transformer.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Transformer layers for TimesFM."""
17
+
18
+ import math
19
+ from typing import Callable
20
+
21
+ import torch
22
+ import torch.nn.functional as F
23
+ from torch import nn
24
+
25
+ from .. import configs
26
+ from . import normalization, util
27
+
28
+ LayerNorm = nn.LayerNorm
29
+ RMSNorm = normalization.RMSNorm
30
+ DecodeCache = util.DecodeCache
31
+
32
+
33
+ def make_attn_mask(
34
+ query_length: int,
35
+ num_all_masked_kv: torch.Tensor,
36
+ query_index_offset: torch.Tensor | None = None,
37
+ kv_length: int = 0,
38
+ ) -> torch.Tensor:
39
+ """Makes attention mask."""
40
+ if kv_length == 0:
41
+ kv_length = query_length
42
+
43
+ q_index = torch.arange(query_length, device=num_all_masked_kv.device)[
44
+ None, None, :, None
45
+ ]
46
+ if query_index_offset is not None:
47
+ q_index = q_index + query_index_offset[:, None, None, None]
48
+ kv_index = torch.arange(kv_length, device=num_all_masked_kv.device)[
49
+ None, None, None, :
50
+ ]
51
+ return torch.logical_and(
52
+ q_index >= kv_index,
53
+ kv_index >= num_all_masked_kv[:, None, None, None],
54
+ )
55
+
56
+
57
+ class RotaryPositionalEmbedding(nn.Module):
58
+ """Rotary positional embedding."""
59
+
60
+ def __init__(
61
+ self,
62
+ embedding_dims: int,
63
+ min_timescale: float = 1.0,
64
+ max_timescale: float = 10000.0,
65
+ ):
66
+ super().__init__()
67
+ self.embedding_dims = embedding_dims
68
+ self.min_timescale = min_timescale
69
+ self.max_timescale = max_timescale
70
+
71
+ def forward(
72
+ self,
73
+ inputs: torch.Tensor,
74
+ position: torch.Tensor | None = None,
75
+ ):
76
+ """Generates a JTensor of sinusoids with different frequencies."""
77
+ if self.embedding_dims != inputs.shape[-1]:
78
+ raise ValueError(
79
+ "The embedding dims of the rotary position embedding"
80
+ "must match the hidden dimension of the inputs."
81
+ )
82
+ half_embedding_dim = self.embedding_dims // 2
83
+
84
+ # Keep RoPE numerics stable by building the sinusoid in fp32, but cast the
85
+ # final trigonometric factors back to the input dtype before mixing them
86
+ # with the activations. Without this, bf16 TimesFM activations get promoted
87
+ # to fp32 in the rotary path while V stays bf16, and fused SDPA then fails
88
+ # because Q/K/V no longer share a dtype.
89
+ rope_dtype = torch.float32
90
+ fraction = (
91
+ 2
92
+ * torch.arange(0, half_embedding_dim, device=inputs.device, dtype=rope_dtype)
93
+ / self.embedding_dims
94
+ )
95
+ timescale = (
96
+ self.min_timescale * (self.max_timescale / self.min_timescale) ** fraction
97
+ ).to(device=inputs.device, dtype=rope_dtype)
98
+ if position is None:
99
+ seq_length = inputs.shape[1]
100
+ position = torch.arange(seq_length, dtype=rope_dtype, device=inputs.device)[
101
+ None, :
102
+ ]
103
+ else:
104
+ position = position.to(device=inputs.device, dtype=rope_dtype)
105
+
106
+ if len(inputs.shape) == 4:
107
+ position = position[..., None, None]
108
+ timescale = timescale[None, None, None, :]
109
+ elif len(inputs.shape) == 3:
110
+ position = position[..., None]
111
+ timescale = timescale[None, None, :]
112
+ else:
113
+ raise ValueError("Inputs must be of rank 3 or 4.")
114
+
115
+ sinusoid_inp = position / timescale
116
+ sin = torch.sin(sinusoid_inp).to(dtype=inputs.dtype)
117
+ cos = torch.cos(sinusoid_inp).to(dtype=inputs.dtype)
118
+ first_half, second_half = torch.chunk(inputs, 2, dim=-1)
119
+ first_part = first_half * cos - second_half * sin
120
+ second_part = second_half * cos + first_half * sin
121
+ return torch.cat([first_part, second_part], dim=-1)
122
+
123
+
124
+ def _dot_product_attention(
125
+ query,
126
+ key,
127
+ value,
128
+ mask=None,
129
+ ):
130
+ """Computes dot-product attention given query, key, and value."""
131
+ attn_weights = torch.einsum("...qhd,...khd->...hqk", query, key)
132
+ if mask is not None:
133
+ attn_weights = torch.where(
134
+ mask, attn_weights, -torch.finfo(attn_weights.dtype).max / 2
135
+ )
136
+
137
+ attn_weights = F.softmax(attn_weights, dim=-1)
138
+
139
+ return torch.einsum("...hqk,...khd->...qhd", attn_weights, value)
140
+
141
+
142
+ def _torch_dot_product_attention(query, key, value, mask=None):
143
+ """
144
+ Performs the exact same (unscaled) attention as the above function,
145
+ but using the fast and fused F.scaled_dot_product_attention kernel.
146
+ """
147
+
148
+ # 1. Permute inputs from (B, L, H, D) to the expected (B, H, L, D)
149
+ query = query.permute(0, 2, 1, 3)
150
+ key = key.permute(0, 2, 1, 3)
151
+ value = value.permute(0, 2, 1, 3)
152
+
153
+ # 2. Call the fused attention kernel
154
+ # - Pass the mask to `attn_mask`.
155
+ # - Set `scale=1.0` to disable the default 1/sqrt(d_k) scaling.
156
+ output = F.scaled_dot_product_attention(query, key, value, attn_mask=mask, scale=1.0)
157
+
158
+ # 3. Permute the output back to the original (B, L, H, D) layout
159
+ output = output.permute(0, 2, 1, 3)
160
+
161
+ return output
162
+
163
+
164
+ class PerDimScale(nn.Module):
165
+ """Per-dimension scaling."""
166
+
167
+ def __init__(self, num_dims: int):
168
+ super().__init__()
169
+ self.num_dims = num_dims
170
+ self.per_dim_scale = nn.Parameter(torch.zeros(num_dims))
171
+
172
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
173
+ scale_factor = (
174
+ 1.442695041 / math.sqrt(self.num_dims) * F.softplus(self.per_dim_scale)
175
+ )
176
+ return x * scale_factor
177
+
178
+
179
+ class MultiHeadAttention(nn.Module):
180
+ """Multi-head attention."""
181
+
182
+ def __init__(
183
+ self,
184
+ num_heads: int,
185
+ in_features: int,
186
+ *,
187
+ use_per_dim_scale: bool = True,
188
+ use_rotary_position_embeddings: bool = True,
189
+ use_bias: bool = False,
190
+ attention_fn: Callable[..., torch.Tensor] = _torch_dot_product_attention,
191
+ qk_norm: str = "rms",
192
+ fuse_qkv: bool = False,
193
+ ):
194
+ super().__init__()
195
+ self.num_heads = num_heads
196
+ self.in_features = in_features
197
+ self.head_dim = in_features // num_heads
198
+ self.use_bias = use_bias
199
+ self.attention_fn = attention_fn
200
+ self.qk_norm = qk_norm
201
+ self.fuse_qkv = fuse_qkv
202
+
203
+ if self.in_features % self.num_heads != 0:
204
+ raise ValueError(
205
+ f"Memory dimension ({self.in_features}) must be divisible by "
206
+ f"'num_heads' heads ({self.num_heads})."
207
+ )
208
+
209
+ if self.fuse_qkv:
210
+ self.qkv_proj = nn.Linear(self.in_features, 3 * self.in_features, bias=use_bias)
211
+ else:
212
+ self.query = nn.Linear(self.in_features, self.in_features, bias=use_bias)
213
+ self.key = nn.Linear(self.in_features, self.in_features, bias=use_bias)
214
+ self.value = nn.Linear(self.in_features, self.in_features, bias=use_bias)
215
+ self.out = nn.Linear(self.in_features, self.in_features, bias=use_bias)
216
+
217
+ if self.qk_norm == "rms":
218
+ self.query_ln = RMSNorm(self.head_dim)
219
+ self.key_ln = RMSNorm(self.head_dim)
220
+ else:
221
+ self.query_ln = nn.Identity()
222
+ self.key_ln = nn.Identity()
223
+
224
+ self.use_rotary_position_embeddings = use_rotary_position_embeddings
225
+ if self.use_rotary_position_embeddings:
226
+ self.rotary_position_embedding = RotaryPositionalEmbedding(
227
+ embedding_dims=self.head_dim,
228
+ )
229
+
230
+ self.use_per_dim_scale = use_per_dim_scale
231
+ if use_per_dim_scale:
232
+ self.per_dim_scale = PerDimScale(num_dims=self.head_dim)
233
+
234
+ def forward(
235
+ self,
236
+ inputs_q: torch.Tensor,
237
+ *,
238
+ decode_cache: DecodeCache | None = None,
239
+ patch_mask: torch.Tensor | None = None,
240
+ ) -> tuple[torch.Tensor, DecodeCache | None]:
241
+ b, n_patches, _ = inputs_q.shape
242
+ if patch_mask is None:
243
+ patch_mask = torch.zeros(b, n_patches, dtype=torch.bool, device=inputs_q.device)
244
+
245
+ if self.fuse_qkv:
246
+ qkv = self.qkv_proj(inputs_q)
247
+ query, key, value = torch.chunk(qkv, 3, dim=-1)
248
+ query = query.view(b, n_patches, self.num_heads, self.head_dim)
249
+ key = key.view(b, n_patches, self.num_heads, self.head_dim)
250
+ value = value.view(b, n_patches, self.num_heads, self.head_dim)
251
+ else:
252
+ query = self.query(inputs_q).view(b, n_patches, self.num_heads, self.head_dim)
253
+ key = self.key(inputs_q).view(b, n_patches, self.num_heads, self.head_dim)
254
+ value = self.value(inputs_q).view(b, n_patches, self.num_heads, self.head_dim)
255
+
256
+ if decode_cache is None:
257
+ num_masked = torch.sum(patch_mask.to(torch.int32), dim=-1)
258
+ next_index = torch.zeros_like(num_masked, dtype=torch.int32)
259
+ else:
260
+ num_masked = (
261
+ torch.sum(patch_mask.to(torch.int32), dim=-1) + decode_cache.num_masked
262
+ )
263
+ next_index = decode_cache.next_index.clone()
264
+
265
+ if self.use_rotary_position_embeddings:
266
+ position = (
267
+ torch.arange(n_patches, device=inputs_q.device)[None, :]
268
+ + next_index[:, None]
269
+ - num_masked[:, None]
270
+ )
271
+ query = self.rotary_position_embedding(query, position)
272
+ key = self.rotary_position_embedding(key, position)
273
+
274
+ query = self.query_ln(query)
275
+ key = self.key_ln(key)
276
+
277
+ if self.use_per_dim_scale:
278
+ query = self.per_dim_scale(query)
279
+
280
+ if decode_cache is not None:
281
+ _, decode_cache_size, _, _ = decode_cache.value.shape
282
+
283
+ start = decode_cache.next_index[0]
284
+ end = start + n_patches
285
+
286
+ # Perform a single, vectorized slice assignment for the entire batch.
287
+ # This is vastly more efficient than a Python for-loop.
288
+
289
+ decode_cache.key[:, start:end] = key
290
+ decode_cache.value[:, start:end] = value
291
+
292
+ key = decode_cache.key
293
+ value = decode_cache.value
294
+ decode_cache.next_index += n_patches
295
+ decode_cache.num_masked = num_masked
296
+ attn_mask = make_attn_mask(
297
+ query_length=n_patches,
298
+ num_all_masked_kv=num_masked,
299
+ query_index_offset=next_index,
300
+ kv_length=decode_cache_size,
301
+ )
302
+ else:
303
+ attn_mask = make_attn_mask(query_length=n_patches, num_all_masked_kv=num_masked)
304
+
305
+ x = self.attention_fn(
306
+ query,
307
+ key,
308
+ value,
309
+ mask=attn_mask,
310
+ )
311
+
312
+ x = x.reshape(b, n_patches, self.in_features)
313
+ out = self.out(x)
314
+ return out, decode_cache
315
+
316
+
317
+ class Transformer(nn.Module):
318
+ """Classic Transformer used in TimesFM."""
319
+
320
+ def __init__(self, config: configs.TransformerConfig):
321
+ super().__init__()
322
+ self.config = config
323
+
324
+ if config.attention_norm == "rms":
325
+ self.pre_attn_ln = RMSNorm(num_features=config.model_dims)
326
+ self.post_attn_ln = RMSNorm(num_features=config.model_dims)
327
+ else:
328
+ raise ValueError(f"Layer norm: {config.attention_norm} not supported.")
329
+
330
+ self.attn = MultiHeadAttention(
331
+ num_heads=config.num_heads,
332
+ in_features=config.model_dims,
333
+ use_per_dim_scale=True,
334
+ use_rotary_position_embeddings=config.use_rotary_position_embeddings,
335
+ qk_norm=config.qk_norm,
336
+ fuse_qkv=config.fuse_qkv,
337
+ )
338
+
339
+ if config.feedforward_norm == "rms":
340
+ self.pre_ff_ln = RMSNorm(num_features=config.model_dims)
341
+ self.post_ff_ln = RMSNorm(num_features=config.model_dims)
342
+ else:
343
+ raise ValueError(f"Layer norm: {config.feedforward_norm} not supported.")
344
+
345
+ self.ff0 = nn.Linear(
346
+ in_features=config.model_dims,
347
+ out_features=config.hidden_dims,
348
+ bias=config.use_bias,
349
+ )
350
+ self.ff1 = nn.Linear(
351
+ in_features=config.hidden_dims,
352
+ out_features=config.model_dims,
353
+ bias=config.use_bias,
354
+ )
355
+ if config.ff_activation == "relu":
356
+ self.activation = nn.ReLU()
357
+ elif config.ff_activation == "swish":
358
+ self.activation = nn.SiLU()
359
+ elif config.ff_activation == "none":
360
+ self.activation = nn.Identity()
361
+ else:
362
+ raise ValueError(f"Activation: {config.ff_activation} not supported.")
363
+
364
+ def forward(
365
+ self,
366
+ input_embeddings: torch.Tensor,
367
+ patch_mask: torch.Tensor,
368
+ decode_cache: DecodeCache | None = None,
369
+ ) -> tuple[torch.Tensor, DecodeCache | None]:
370
+ attn_output, decode_cache = self.attn(
371
+ inputs_q=self.pre_attn_ln(input_embeddings),
372
+ decode_cache=decode_cache,
373
+ patch_mask=patch_mask,
374
+ )
375
+ attn_output = self.post_attn_ln(attn_output) + input_embeddings
376
+ output_embeddings = (
377
+ self.post_ff_ln(self.ff1(self.activation(self.ff0(self.pre_ff_ln(attn_output)))))
378
+ + attn_output
379
+ )
380
+ return output_embeddings, decode_cache
timebraid/_vendor/timesfm/torch/util.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified by TimeBraid contributors for the vendored PyTorch-only runtime.
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """PyTorch utility functions for TimesFM layers."""
17
+
18
+ import dataclasses
19
+ import torch
20
+
21
+ _TOLERANCE = 1e-6
22
+
23
+
24
+ @dataclasses.dataclass(frozen=False)
25
+ class DecodeCache:
26
+ """Cache for decoding."""
27
+
28
+ next_index: torch.Tensor
29
+ num_masked: torch.Tensor
30
+ key: torch.Tensor
31
+ value: torch.Tensor
32
+
33
+
34
+ def update_running_stats(
35
+ n: torch.Tensor,
36
+ mu: torch.Tensor,
37
+ sigma: torch.Tensor,
38
+ x: torch.Tensor,
39
+ mask: torch.Tensor,
40
+ ) -> tuple[
41
+ tuple[torch.Tensor, torch.Tensor, torch.Tensor],
42
+ tuple[torch.Tensor, torch.Tensor, torch.Tensor],
43
+ ]:
44
+ """Updates the running stats."""
45
+ is_legit = torch.logical_not(mask)
46
+ inc_n = torch.sum(is_legit.to(x.dtype), dim=-1)
47
+
48
+ inc_mu_numerator = torch.sum(x * is_legit, dim=-1)
49
+ inc_n_safe = torch.where(inc_n == 0, 1.0, inc_n)
50
+ inc_mu = inc_mu_numerator / inc_n_safe
51
+ inc_mu = torch.where(inc_n == 0, 0.0, inc_mu)
52
+
53
+ inc_var_numerator = torch.sum(
54
+ ((x - inc_mu.unsqueeze(-1)) ** 2) * is_legit, dim=-1
55
+ )
56
+ inc_var = inc_var_numerator / inc_n_safe
57
+ inc_var = torch.where(inc_n == 0, 0.0, inc_var)
58
+ inc_sigma = torch.sqrt(inc_var)
59
+
60
+ new_n = n + inc_n
61
+ new_n_safe = torch.where(new_n == 0, 1.0, new_n)
62
+
63
+ new_mu = (n * mu + inc_mu * inc_n) / new_n_safe
64
+ new_mu = torch.where(new_n == 0, 0.0, new_mu)
65
+
66
+ term1 = n * sigma.pow(2)
67
+ term2 = inc_n * inc_sigma.pow(2)
68
+ term3 = n * (mu - new_mu).pow(2)
69
+ term4 = inc_n * (inc_mu - new_mu).pow(2)
70
+
71
+ new_var = (term1 + term2 + term3 + term4) / new_n_safe
72
+ new_var = torch.where(new_n == 0, 0.0, new_var)
73
+ new_sigma = torch.sqrt(torch.clamp(new_var, min=0.0))
74
+
75
+ return (w := (new_n, new_mu, new_sigma), w)
76
+
77
+
78
+ def revin(
79
+ x: torch.Tensor,
80
+ mu: torch.Tensor,
81
+ sigma: torch.Tensor,
82
+ reverse: bool = False,
83
+ ):
84
+ """Reversible instance normalization."""
85
+ if len(mu.shape) == len(x.shape) - 1:
86
+ mu = mu[..., None]
87
+ sigma = sigma[..., None]
88
+ elif len(mu.shape) == len(x.shape) - 2:
89
+ mu = mu[..., None, None]
90
+ sigma = sigma[..., None, None]
91
+
92
+ if reverse:
93
+ return x * sigma + mu
94
+ else:
95
+ return (x - mu) / torch.where(sigma < _TOLERANCE, 1.0, sigma)
timebraid/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Inference tensorization internals for the TimeBraid processor."""
timebraid/data/mot_utils.py ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 the TimeBraid contributors.
2
+ """Inference-only validation and tensorization for TimeBraid time-series spans."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import torch
11
+
12
+ from ..model.mot.structures import ROLE_CONTEXT, ROLE_OBSERVED, ROLE_TARGET
13
+
14
+ # Derived from the model-side constants rather than restated. This module
15
+ # writes `role_id` and the MoT runtime reads it back, so two independent
16
+ # literals would route spans to the wrong expert the moment either side
17
+ # renumbered — with correctly shaped tensors and no error.
18
+ ROLE_TO_ID = {
19
+ "observed": ROLE_OBSERVED,
20
+ "context": ROLE_CONTEXT,
21
+ "target": ROLE_TARGET,
22
+ }
23
+ _GENERIC_OPEN_TAG = "<ts>"
24
+ _GENERIC_CLOSE_TAG = "</ts>"
25
+ _FLOAT32_MAX = float(torch.finfo(torch.float32).max)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class MoTNormalizedSpan:
30
+ """
31
+ One validated MoT span payload entry.
32
+
33
+ `loss_start` and `loss_roi_mask` remain part of the model payload ABI even
34
+ though the inference processor constructs conditioning-only spans.
35
+ """
36
+
37
+ length: int
38
+ role_id: int
39
+ segment_id: int
40
+ values: list[float]
41
+ loss_start: int = 0
42
+ loss_roi_mask: list[float] | None = None
43
+ text_start_token_idx: int | None = None
44
+ text_end_token_idx: int | None = None
45
+
46
+
47
+ def _normalize_special_token_entry(token: Any) -> str:
48
+ """
49
+ Normalize tokenizer special-token declarations to their textual surface.
50
+
51
+ Tokenizers expose special tokens through a mix of:
52
+ - raw strings
53
+ - dicts with `content`
54
+ - AddedToken-like objects with `.content`
55
+
56
+ The processor uses this normalization to derive the tokenizer's exact TS
57
+ delimiter inventory.
58
+ """
59
+ if isinstance(token, str):
60
+ return token
61
+ if isinstance(token, dict):
62
+ content = token.get("content")
63
+ if isinstance(content, str):
64
+ return content
65
+ raise RuntimeError(
66
+ f"Tokenizer special-token dict is missing string `content`: {token!r}"
67
+ )
68
+
69
+ content = getattr(token, "content", None)
70
+ if isinstance(content, str):
71
+ return content
72
+
73
+ raise RuntimeError(
74
+ "Unsupported tokenizer special-token entry while resolving MoT TS delimiters: "
75
+ f"{type(token)} {token!r}"
76
+ )
77
+
78
+
79
+ def _format_ts_tag(length: int | None, *, is_close: bool) -> str:
80
+ if length is not None:
81
+ raise RuntimeError(
82
+ f"Length-tagged TS delimiters are no longer supported: {length}."
83
+ )
84
+ return _GENERIC_CLOSE_TAG if is_close else _GENERIC_OPEN_TAG
85
+
86
+
87
+ def _parse_ts_tag(token: str) -> tuple[int | None, bool]:
88
+ """
89
+ Parse one textual TS delimiter into `(tagged_len, is_close)`.
90
+
91
+ Canonical generic tags are `<ts>` and `</ts>`.
92
+ """
93
+ if token == _GENERIC_OPEN_TAG:
94
+ return None, False
95
+ if token == _GENERIC_CLOSE_TAG:
96
+ return None, True
97
+
98
+ raise RuntimeError(f"Unsupported TS tag syntax: {token}")
99
+
100
+
101
+ def resolve_ts_delimiter_id_maps(
102
+ tokenizer: Any,
103
+ ) -> tuple[dict[int, int | None], dict[int, int | None]]:
104
+ """
105
+ Resolve tokenizer-visible TS delimiter ids to their optional tagged length.
106
+
107
+ The only supported tokenizer-visible TS delimiters are `<ts>` and `</ts>`.
108
+ """
109
+ candidate_sources: list[tuple[str, Any]] = []
110
+ for attr_name in (
111
+ "additional_special_tokens",
112
+ "extra_special_tokens",
113
+ "all_special_tokens",
114
+ ):
115
+ declared_tokens = getattr(tokenizer, attr_name, None)
116
+ if declared_tokens is not None:
117
+ candidate_sources.append((f"tokenizer.{attr_name}", declared_tokens))
118
+
119
+ added_tokens_decoder = getattr(tokenizer, "added_tokens_decoder", None)
120
+ if isinstance(added_tokens_decoder, dict) and len(added_tokens_decoder) > 0:
121
+ candidate_sources.append(
122
+ ("tokenizer.added_tokens_decoder", list(added_tokens_decoder.values()))
123
+ )
124
+
125
+ open_id_to_len: dict[int, int | None] = {}
126
+ close_id_to_len: dict[int, int | None] = {}
127
+ for source_name, declared_tokens in candidate_sources:
128
+ if isinstance(declared_tokens, dict):
129
+ iterable = declared_tokens.values()
130
+ elif isinstance(declared_tokens, (list, tuple, set)):
131
+ iterable = declared_tokens
132
+ else:
133
+ raise RuntimeError(
134
+ f"Unsupported tokenizer special-token collection while resolving MoT TS delimiters: "
135
+ f"{source_name} -> {type(declared_tokens)}"
136
+ )
137
+
138
+ for token in iterable:
139
+ token_str = _normalize_special_token_entry(token)
140
+ try:
141
+ tagged_len, is_close = _parse_ts_tag(token_str)
142
+ except RuntimeError:
143
+ continue
144
+
145
+ token_id = int(tokenizer.convert_tokens_to_ids(token_str))
146
+ target_map = close_id_to_len if is_close else open_id_to_len
147
+ previous = target_map.get(token_id)
148
+ if previous is not None or token_id in target_map:
149
+ if previous != tagged_len:
150
+ raise RuntimeError(
151
+ "Tokenizer resolves one TS delimiter id to multiple tagged lengths: "
152
+ f"token={token_str!r}, token_id={token_id}, existing_len={previous}, new_len={tagged_len}"
153
+ )
154
+ continue
155
+ target_map[token_id] = tagged_len
156
+
157
+ if not open_id_to_len or not close_id_to_len:
158
+ raise RuntimeError(
159
+ "MoT expected tokenizer-visible TS delimiters, but failed to resolve a complete id inventory. "
160
+ f"open_ids={sorted(open_id_to_len)}, close_ids={sorted(close_id_to_len)}."
161
+ )
162
+ return open_id_to_len, close_id_to_len
163
+
164
+
165
+ def find_tokenized_ts_spans(
166
+ token_ids: list[int],
167
+ *,
168
+ open_id_to_len: dict[int, int | None],
169
+ close_id_to_len: dict[int, int | None],
170
+ ) -> list[tuple[int, int, int | None]]:
171
+ """
172
+ Parse one tokenized text row into ordered `(start_idx, end_idx, tagged_len)` spans.
173
+
174
+ This mirrors the strict delimiter parser the runtime historically performed
175
+ inside every forward pass. Moving it into preprocessing lets us cache
176
+ the result as batch metadata without weakening any structural checks.
177
+ """
178
+ spans: list[tuple[int, int, int | None]] = []
179
+ stack: list[tuple[int | None, int]] = []
180
+ for idx, raw_tid in enumerate(token_ids):
181
+ tid = int(raw_tid)
182
+ if tid in open_id_to_len:
183
+ if stack:
184
+ raise RuntimeError("Nested TS spans are not supported.")
185
+ stack.append((open_id_to_len[tid], idx))
186
+ continue
187
+
188
+ if tid not in close_id_to_len:
189
+ continue
190
+
191
+ if not stack:
192
+ raise RuntimeError(
193
+ f"Found closing TS token without opening token at position {idx}."
194
+ )
195
+
196
+ tagged_len, start_idx = stack.pop()
197
+ close_len = close_id_to_len[tid]
198
+ if close_len != tagged_len:
199
+ raise RuntimeError(
200
+ "Mismatched TS delimiters: "
201
+ f"open={_format_ts_tag(tagged_len, is_close=False)}, "
202
+ f"close={_format_ts_tag(close_len, is_close=True)}"
203
+ )
204
+ spans.append((start_idx, idx, tagged_len))
205
+
206
+ if stack:
207
+ raise RuntimeError("Unclosed TS tags found in sequence.")
208
+ spans.sort(key=lambda item: item[0])
209
+ return spans
210
+
211
+
212
+ def _require_json_int(value: Any, *, owner: str, field_name: str) -> int:
213
+ """Require an actual JSON integer for schema fields that define supervision boundaries."""
214
+ if type(value) is not int:
215
+ raise RuntimeError(
216
+ f"{owner} `{field_name}` must be int, got {value!r} ({type(value)})."
217
+ )
218
+ return int(value)
219
+
220
+
221
+ def normalize_timeseries_spans(
222
+ spans: Any,
223
+ *,
224
+ sample_idx: int,
225
+ require_segment_id: bool,
226
+ default_segment_id: int,
227
+ max_spans_per_sample: int,
228
+ ) -> list[MoTNormalizedSpan]:
229
+ """
230
+ Validate and normalize one sample's `timeseries` payload.
231
+
232
+ Expected schema for each span:
233
+ {
234
+ "len": int > 0,
235
+ "role": "observed" | "context" | "target",
236
+ "values": list[float] with exact `len`,
237
+ "segment_id": int > 0 # optional when require_segment_id=False
238
+ "loss_start": int in [0, len] # required; len means no TS supervision
239
+ "loss_roi_mask": list[float] # optional; raw-point aligned soft ROI in [0, 1]
240
+ }
241
+ """
242
+ if spans is None:
243
+ return []
244
+ if not isinstance(spans, list):
245
+ raise RuntimeError(
246
+ f"Sample[{sample_idx}] `timeseries` must be list, got {type(spans)}."
247
+ )
248
+ if len(spans) > max_spans_per_sample:
249
+ raise RuntimeError(
250
+ f"Sample[{sample_idx}] spans exceed max limit: "
251
+ f"{len(spans)} > {max_spans_per_sample}."
252
+ )
253
+
254
+ normalized: list[MoTNormalizedSpan] = []
255
+ for span_idx, span in enumerate(spans):
256
+ if not isinstance(span, dict):
257
+ raise RuntimeError(
258
+ f"Sample[{sample_idx}] span[{span_idx}] must be dict, got {type(span)}."
259
+ )
260
+
261
+ for key in ("len", "role", "values", "loss_start"):
262
+ if key not in span:
263
+ raise RuntimeError(
264
+ f"Sample[{sample_idx}] span[{span_idx}] missing key `{key}`."
265
+ )
266
+
267
+ owner = f"Sample[{sample_idx}] span[{span_idx}]"
268
+ length = _require_json_int(span["len"], owner=owner, field_name="len")
269
+ if length <= 0:
270
+ raise RuntimeError(
271
+ f"Sample[{sample_idx}] span[{span_idx}] invalid len={length}."
272
+ )
273
+
274
+ role = str(span["role"])
275
+ if role not in ROLE_TO_ID:
276
+ raise RuntimeError(
277
+ f"Sample[{sample_idx}] span[{span_idx}] invalid role={role}."
278
+ )
279
+ role_id = ROLE_TO_ID[role]
280
+
281
+ values = span["values"]
282
+ if not isinstance(values, list):
283
+ raise RuntimeError(
284
+ f"Sample[{sample_idx}] span[{span_idx}] values must be list, got {type(values)}."
285
+ )
286
+ if len(values) != length:
287
+ raise RuntimeError(
288
+ f"Sample[{sample_idx}] span[{span_idx}] len mismatch: "
289
+ f"len={length}, values={len(values)}."
290
+ )
291
+
292
+ normalized_values: list[float] = []
293
+ for value_idx, value in enumerate(values):
294
+ if isinstance(value, bool):
295
+ raise RuntimeError(
296
+ f"Sample[{sample_idx}] span[{span_idx}] value[{value_idx}] is bool."
297
+ )
298
+ if isinstance(value, (int, float)):
299
+ casted = float(value)
300
+ else:
301
+ raise RuntimeError(
302
+ f"Sample[{sample_idx}] span[{span_idx}] value[{value_idx}] invalid type={type(value)}."
303
+ )
304
+ if not math.isfinite(casted):
305
+ raise RuntimeError(
306
+ f"Sample[{sample_idx}] span[{span_idx}] value[{value_idx}] is not finite: {casted}."
307
+ )
308
+ if abs(casted) > _FLOAT32_MAX:
309
+ raise RuntimeError(
310
+ f"Sample[{sample_idx}] span[{span_idx}] value[{value_idx}] exceeds float32 range: {casted}."
311
+ )
312
+ normalized_values.append(casted)
313
+
314
+ if "segment_id" in span and span["segment_id"] is not None:
315
+ segment_id = _require_json_int(
316
+ span["segment_id"],
317
+ owner=owner,
318
+ field_name="segment_id",
319
+ )
320
+ elif require_segment_id:
321
+ raise RuntimeError(
322
+ f"Sample[{sample_idx}] span[{span_idx}] missing `segment_id`."
323
+ )
324
+ else:
325
+ segment_id = int(default_segment_id)
326
+
327
+ if segment_id <= 0:
328
+ raise RuntimeError(
329
+ f"Sample[{sample_idx}] span[{span_idx}] invalid segment_id={segment_id}."
330
+ )
331
+
332
+ loss_start = _require_json_int(
333
+ span["loss_start"],
334
+ owner=owner,
335
+ field_name="loss_start",
336
+ )
337
+ if loss_start < 0 or loss_start > length:
338
+ raise RuntimeError(
339
+ f"Sample[{sample_idx}] span[{span_idx}] invalid loss_start={loss_start} for len={length}."
340
+ )
341
+
342
+ loss_roi_mask = None
343
+ if "loss_roi_mask" in span and span["loss_roi_mask"] is not None:
344
+ raw_loss_roi_mask = span["loss_roi_mask"]
345
+ if not isinstance(raw_loss_roi_mask, list):
346
+ raise RuntimeError(
347
+ f"Sample[{sample_idx}] span[{span_idx}] loss_roi_mask must be list, "
348
+ f"got {type(raw_loss_roi_mask)}."
349
+ )
350
+ if len(raw_loss_roi_mask) > 0:
351
+ if len(raw_loss_roi_mask) != length:
352
+ raise RuntimeError(
353
+ f"Sample[{sample_idx}] span[{span_idx}] loss_roi_mask length mismatch: "
354
+ f"len={length}, mask={len(raw_loss_roi_mask)}."
355
+ )
356
+ normalized_roi_mask: list[float] = []
357
+ for mask_idx, mask_value in enumerate(raw_loss_roi_mask):
358
+ if isinstance(mask_value, bool) or not isinstance(
359
+ mask_value, (int, float)
360
+ ):
361
+ raise RuntimeError(
362
+ f"Sample[{sample_idx}] span[{span_idx}] loss_roi_mask[{mask_idx}] "
363
+ f"invalid type={type(mask_value)}."
364
+ )
365
+ casted_mask = float(mask_value)
366
+ if (
367
+ not math.isfinite(casted_mask)
368
+ or casted_mask < 0.0
369
+ or casted_mask > 1.0
370
+ ):
371
+ raise RuntimeError(
372
+ f"Sample[{sample_idx}] span[{span_idx}] loss_roi_mask[{mask_idx}] "
373
+ f"must be finite and in [0, 1], got {casted_mask}."
374
+ )
375
+ normalized_roi_mask.append(casted_mask)
376
+ loss_roi_mask = normalized_roi_mask
377
+
378
+ text_start_token_idx = None
379
+ if "text_start_token_idx" in span and span["text_start_token_idx"] is not None:
380
+ text_start_token_idx = _require_json_int(
381
+ span["text_start_token_idx"],
382
+ owner=owner,
383
+ field_name="text_start_token_idx",
384
+ )
385
+ if text_start_token_idx < 0:
386
+ raise RuntimeError(
387
+ f"{owner} text_start_token_idx must be >= 0, got {text_start_token_idx}."
388
+ )
389
+ text_end_token_idx = None
390
+ if "text_end_token_idx" in span and span["text_end_token_idx"] is not None:
391
+ text_end_token_idx = _require_json_int(
392
+ span["text_end_token_idx"],
393
+ owner=owner,
394
+ field_name="text_end_token_idx",
395
+ )
396
+ if text_end_token_idx < 0:
397
+ raise RuntimeError(
398
+ f"{owner} text_end_token_idx must be >= 0, got {text_end_token_idx}."
399
+ )
400
+ if (text_start_token_idx is None) != (text_end_token_idx is None):
401
+ raise RuntimeError(
402
+ f"{owner} text_start_token_idx/text_end_token_idx must be provided together."
403
+ )
404
+ if (
405
+ text_start_token_idx is not None
406
+ and text_end_token_idx < text_start_token_idx
407
+ ):
408
+ raise RuntimeError(
409
+ f"{owner} text token bounds are inverted: start={text_start_token_idx}, end={text_end_token_idx}."
410
+ )
411
+
412
+ normalized.append(
413
+ MoTNormalizedSpan(
414
+ length=length,
415
+ role_id=role_id,
416
+ segment_id=segment_id,
417
+ values=normalized_values,
418
+ loss_start=loss_start,
419
+ loss_roi_mask=loss_roi_mask,
420
+ text_start_token_idx=text_start_token_idx,
421
+ text_end_token_idx=text_end_token_idx,
422
+ )
423
+ )
424
+ return normalized
425
+
426
+
427
+ def build_timeseries_tensors(
428
+ spans_by_sample: list[list[MoTNormalizedSpan]],
429
+ ) -> dict[str, torch.Tensor]:
430
+ batch_size = len(spans_by_sample)
431
+ max_spans = max((len(spans) for spans in spans_by_sample), default=0)
432
+ max_len = max(
433
+ (span.length for spans in spans_by_sample for span in spans), default=0
434
+ )
435
+
436
+ ts_values = torch.zeros((batch_size, max_spans, max_len), dtype=torch.float32)
437
+ ts_lengths = torch.zeros((batch_size, max_spans), dtype=torch.int32)
438
+ ts_loss_start_idxs = torch.zeros((batch_size, max_spans), dtype=torch.int32)
439
+ ts_loss_roi_masks = torch.zeros(
440
+ (batch_size, max_spans, max_len), dtype=torch.float32
441
+ )
442
+ ts_roles = torch.full((batch_size, max_spans), -1, dtype=torch.int32)
443
+ ts_segment_ids = torch.full((batch_size, max_spans), -1, dtype=torch.int32)
444
+ ts_span_mask = torch.zeros((batch_size, max_spans), dtype=torch.bool)
445
+
446
+ for sample_idx, spans in enumerate(spans_by_sample):
447
+ for span_idx, span in enumerate(spans):
448
+ ts_values[sample_idx, span_idx, : span.length] = torch.tensor(
449
+ span.values, dtype=torch.float32
450
+ )
451
+ ts_lengths[sample_idx, span_idx] = int(span.length)
452
+ ts_loss_start_idxs[sample_idx, span_idx] = int(span.loss_start)
453
+ if span.loss_roi_mask is not None:
454
+ ts_loss_roi_masks[sample_idx, span_idx, : span.length] = torch.tensor(
455
+ span.loss_roi_mask, dtype=torch.float32
456
+ )
457
+ ts_roles[sample_idx, span_idx] = int(span.role_id)
458
+ ts_segment_ids[sample_idx, span_idx] = int(span.segment_id)
459
+ ts_span_mask[sample_idx, span_idx] = True
460
+
461
+ return {
462
+ "ts_values": ts_values,
463
+ "ts_lengths": ts_lengths,
464
+ "ts_loss_start_idxs": ts_loss_start_idxs,
465
+ "ts_loss_roi_masks": ts_loss_roi_masks,
466
+ "ts_roles": ts_roles,
467
+ "ts_segment_ids": ts_segment_ids,
468
+ "ts_span_mask": ts_span_mask,
469
+ }
470
+
471
+
472
+ def build_timeseries_text_token_tensors(
473
+ *,
474
+ input_ids_by_sample: list[list[int]],
475
+ spans_by_sample: list[list[MoTNormalizedSpan]],
476
+ max_spans_per_sample: int,
477
+ open_id_to_len: dict[int, int | None],
478
+ close_id_to_len: dict[int, int | None],
479
+ ) -> dict[str, torch.Tensor]:
480
+ """
481
+ Build per-span text-token boundary tensors aligned with the TS payload slots.
482
+
483
+ These tensors are intentionally batch-static:
484
+ - they depend only on tokenizer-visible text and already-normalized
485
+ `timeseries` payload ordering
486
+ - they do *not* depend on model hidden states or current TS layer depth
487
+
488
+ The runtime consumes these exact token boundaries directly instead of
489
+ rescanning every input row for `<ts> ... </ts>` on every forward pass.
490
+ """
491
+ if len(input_ids_by_sample) != len(spans_by_sample):
492
+ raise RuntimeError(
493
+ "MoT text-span tensor builder expects input_ids and spans to share batch size, "
494
+ f"got input_rows={len(input_ids_by_sample)}, span_rows={len(spans_by_sample)}."
495
+ )
496
+
497
+ batch_size = len(spans_by_sample)
498
+ max_spans = max((len(spans) for spans in spans_by_sample), default=0)
499
+ if max_spans > max_spans_per_sample:
500
+ raise RuntimeError(
501
+ f"Too many TS spans in one sample for processor metadata: {max_spans} > {max_spans_per_sample}."
502
+ )
503
+
504
+ ts_text_start_token_idxs = torch.full(
505
+ (batch_size, max_spans), -1, dtype=torch.int32
506
+ )
507
+ ts_text_end_token_idxs = torch.full((batch_size, max_spans), -1, dtype=torch.int32)
508
+
509
+ for sample_idx, (token_ids, normalized_spans) in enumerate(
510
+ zip(input_ids_by_sample, spans_by_sample)
511
+ ):
512
+ if all(
513
+ span.text_start_token_idx is not None
514
+ and span.text_end_token_idx is not None
515
+ for span in normalized_spans
516
+ ):
517
+ for span_idx, span in enumerate(normalized_spans):
518
+ start_idx = int(span.text_start_token_idx)
519
+ end_idx = int(span.text_end_token_idx)
520
+ open_target = end_idx < 0 and int(span.role_id) == ROLE_TO_ID["target"]
521
+ if (
522
+ start_idx < 0
523
+ or (not open_target and end_idx < start_idx)
524
+ or (not open_target and end_idx >= len(token_ids))
525
+ or (open_target and start_idx >= len(token_ids))
526
+ ):
527
+ raise RuntimeError(
528
+ "Precomputed MoT text bounds are invalid inside the processor: "
529
+ f"sample={sample_idx}, span={span_idx}, start={start_idx}, end={end_idx}, "
530
+ f"tokens={len(token_ids)}."
531
+ )
532
+ ts_text_start_token_idxs[sample_idx, span_idx] = start_idx
533
+ ts_text_end_token_idxs[sample_idx, span_idx] = end_idx
534
+ continue
535
+
536
+ if any(
537
+ span.text_start_token_idx is not None or span.text_end_token_idx is not None
538
+ for span in normalized_spans
539
+ ):
540
+ raise RuntimeError(
541
+ f"Sample[{sample_idx}] mixes precomputed and tokenizer-derived TS text bounds."
542
+ )
543
+ parsed_spans = find_tokenized_ts_spans(
544
+ [int(token_id) for token_id in token_ids],
545
+ open_id_to_len=open_id_to_len,
546
+ close_id_to_len=close_id_to_len,
547
+ )
548
+ if len(parsed_spans) != len(normalized_spans):
549
+ raise RuntimeError(
550
+ "Parsed text spans and normalized TS payload spans disagree inside the processor: "
551
+ f"sample={sample_idx}, parsed={len(parsed_spans)}, payload={len(normalized_spans)}."
552
+ )
553
+
554
+ for span_idx, (span, (start_idx, end_idx, tagged_len)) in enumerate(
555
+ zip(normalized_spans, parsed_spans)
556
+ ):
557
+ if tagged_len is not None and int(span.length) != int(tagged_len):
558
+ raise RuntimeError(
559
+ "TS payload length mismatch with text delimiter tags in processor input: "
560
+ f"sample={sample_idx}, span={span_idx}, payload_len={span.length}, tag_len={tagged_len}."
561
+ )
562
+ ts_text_start_token_idxs[sample_idx, span_idx] = int(start_idx)
563
+ ts_text_end_token_idxs[sample_idx, span_idx] = int(end_idx)
564
+
565
+ return {
566
+ "ts_text_start_token_idxs": ts_text_start_token_idxs,
567
+ "ts_text_end_token_idxs": ts_text_end_token_idxs,
568
+ }
569
+
570
+
571
+ def build_mot_batch_timeseries_tensors(
572
+ *,
573
+ tokenizer: Any,
574
+ input_ids_by_sample: list[list[int]],
575
+ spans_by_sample: list[list[MoTNormalizedSpan]],
576
+ max_spans_per_sample: int,
577
+ ) -> dict[str, torch.Tensor]:
578
+ """
579
+ Build the full processor-side TS tensor bundle for one inference request.
580
+
581
+ The model consumes two aligned views:
582
+ - dense span/value tensors for the bridge path
583
+ - text-token boundary tensors for exact `<ts> ... </ts>` alignment
584
+ """
585
+
586
+ needs_tokenized_delimiter_parse = any(
587
+ any(
588
+ span.text_start_token_idx is None or span.text_end_token_idx is None
589
+ for span in spans
590
+ )
591
+ for spans in spans_by_sample
592
+ )
593
+ if needs_tokenized_delimiter_parse:
594
+ open_id_to_len, close_id_to_len = resolve_ts_delimiter_id_maps(tokenizer)
595
+ else:
596
+ open_id_to_len, close_id_to_len = {}, {}
597
+ batch = build_timeseries_tensors(spans_by_sample)
598
+ text_bounds = build_timeseries_text_token_tensors(
599
+ input_ids_by_sample=input_ids_by_sample,
600
+ spans_by_sample=spans_by_sample,
601
+ max_spans_per_sample=max_spans_per_sample,
602
+ open_id_to_len=open_id_to_len,
603
+ close_id_to_len=close_id_to_len,
604
+ )
605
+ batch.update(text_bounds)
606
+ return batch
607
+
608
+
609
+ __all__ = [
610
+ "build_mot_batch_timeseries_tensors",
611
+ "normalize_timeseries_spans",
612
+ ]
timebraid/model/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TimeBraid model core."""
2
+
3
+ from .loading import (
4
+ LoadedTimeBraid,
5
+ ValidatedTimeBraidCheckpointSource,
6
+ load_timebraid_checkpoint,
7
+ load_timebraid_tokenizer,
8
+ validate_timebraid_canonical_artifact,
9
+ validate_timebraid_checkpoint_source,
10
+ validate_timebraid_model,
11
+ )
12
+ from .mot import TimeBraidGenerateOutput
13
+ from .timebraid import (
14
+ TimeBraid,
15
+ TimeBraidConfig,
16
+ TimeBraidLossBreakdown,
17
+ TimeBraidOutput,
18
+ register_timebraid_auto_classes,
19
+ )
20
+
21
+ __all__ = [
22
+ "TimeBraid",
23
+ "TimeBraidConfig",
24
+ "TimeBraidGenerateOutput",
25
+ "TimeBraidLossBreakdown",
26
+ "TimeBraidOutput",
27
+ "LoadedTimeBraid",
28
+ "ValidatedTimeBraidCheckpointSource",
29
+ "register_timebraid_auto_classes",
30
+ "load_timebraid_checkpoint",
31
+ "load_timebraid_tokenizer",
32
+ "validate_timebraid_canonical_artifact",
33
+ "validate_timebraid_model",
34
+ "validate_timebraid_checkpoint_source",
35
+ ]
timebraid/model/loading.py ADDED
@@ -0,0 +1,1038 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict loading for local or Hub TimeBraid checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ import torch
12
+ from huggingface_hub import snapshot_download
13
+ from safetensors import safe_open
14
+ from transformers import (
15
+ AutoConfig,
16
+ AutoProcessor,
17
+ AutoTokenizer,
18
+ PreTrainedModel,
19
+ PreTrainedTokenizerBase,
20
+ )
21
+
22
+ from .mot.config_contract import collect_mot_hf_config_contract
23
+ from .timebraid import (
24
+ TimeBraid,
25
+ TimeBraidConfig,
26
+ _validate_finite_model_tensors,
27
+ register_timebraid_auto_classes,
28
+ )
29
+
30
+ _MODEL_INDEX_NAME = "model.safetensors.index.json"
31
+ _SINGLE_MODEL_NAME = "model.safetensors"
32
+ # Keep structural preflight bounded independently of untrusted checkpoint
33
+ # metadata; supported runtime payloads use a much smaller configured span cap.
34
+ _MAX_SPANS_PER_SAMPLE = 2048
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class LoadedTimeBraid:
39
+ """A frozen TimeBraid model, its tokenizer, and explicit load provenance."""
40
+
41
+ model: PreTrainedModel
42
+ tokenizer: PreTrainedTokenizerBase
43
+ source: str
44
+ weight_dtype: str
45
+ compute_dtype: str
46
+ device_map: str | dict[str, str] | None
47
+ physical_tensor_dtypes: tuple[tuple[str, str], ...] = ()
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ValidatedTimeBraidCheckpointSource:
52
+ """CPU-only structural preflight for one complete TimeBraid checkpoint."""
53
+
54
+ source: str
55
+ config: TimeBraidConfig
56
+ tokenizer: PreTrainedTokenizerBase
57
+ tensor_count: int
58
+ physical_tensor_dtypes: tuple[tuple[str, str], ...]
59
+ mot_ts_open_token_id: int
60
+ mot_ts_close_token_id: int
61
+
62
+
63
+ def _loading_info_key(item: Any) -> str:
64
+ if isinstance(item, (tuple, list)) and item:
65
+ return str(item[0])
66
+ return str(item)
67
+
68
+
69
+ def _normalize_source(source: str | Path, *, field_name: str) -> str:
70
+ if not isinstance(source, (str, Path)):
71
+ raise TypeError(
72
+ f"{field_name} must be a local directory path or Hub ID, got {type(source).__name__}."
73
+ )
74
+ normalized = str(source).strip()
75
+ if not normalized:
76
+ raise ValueError(f"{field_name} must be non-empty.")
77
+ return normalized
78
+
79
+
80
+ def _resolve_hf_source(
81
+ source: str,
82
+ *,
83
+ revision: str | None,
84
+ token: str | None,
85
+ cache_dir: str | None,
86
+ local_files_only: bool,
87
+ ) -> str:
88
+ """Resolve an existing directory or one standard Hugging Face Hub repo."""
89
+ path = Path(source).expanduser()
90
+ if path.is_dir():
91
+ return str(path.resolve(strict=True))
92
+ if path.exists():
93
+ raise RuntimeError(f"source must be a directory or Hub ID, got {source!r}.")
94
+ return snapshot_download(
95
+ repo_id=source,
96
+ revision=revision,
97
+ token=token,
98
+ cache_dir=cache_dir,
99
+ local_files_only=local_files_only,
100
+ )
101
+
102
+
103
+ def _strict_json_object(path: Path, *, owner: str) -> dict[str, Any]:
104
+ """Read one strict JSON object without duplicate or non-finite values."""
105
+
106
+ def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
107
+ result: dict[str, Any] = {}
108
+ for key, value in pairs:
109
+ if key in result:
110
+ raise RuntimeError(f"{owner} contains duplicate key {key!r}: {path}")
111
+ result[key] = value
112
+ return result
113
+
114
+ def reject_constant(value: str) -> None:
115
+ raise RuntimeError(f"{owner} contains non-finite JSON number {value!r}: {path}")
116
+
117
+ def parse_finite_float(value: str) -> float:
118
+ parsed = float(value)
119
+ if not math.isfinite(parsed):
120
+ raise RuntimeError(
121
+ f"{owner} contains overflowing JSON number {value!r}: {path}"
122
+ )
123
+ return parsed
124
+
125
+ value = json.loads(
126
+ path.read_text(encoding="utf-8"),
127
+ object_pairs_hook=reject_duplicates,
128
+ parse_constant=reject_constant,
129
+ parse_float=parse_finite_float,
130
+ )
131
+ if not isinstance(value, dict):
132
+ raise RuntimeError(f"{owner} must contain one JSON object: {path}")
133
+ return value
134
+
135
+
136
+ def _safe_top_level_name(value: object, *, owner: str) -> str:
137
+ if (
138
+ not isinstance(value, str)
139
+ or not value
140
+ or Path(value).name != value
141
+ or "/" in value
142
+ or "\\" in value
143
+ or value in {".", ".."}
144
+ ):
145
+ raise RuntimeError(f"{owner} must be a top-level filename, got {value!r}.")
146
+ return value
147
+
148
+
149
+ def _resolved_source_hf_kwargs(*, trust_remote_code: bool) -> dict[str, Any]:
150
+ """Build reader kwargs after a Hub repo has resolved to one local snapshot."""
151
+ if trust_remote_code is not False:
152
+ raise ValueError(
153
+ "TimeBraid loading requires trust_remote_code=False; install and import "
154
+ "the TimeBraid package to register its Hugging Face classes."
155
+ )
156
+ return {
157
+ "local_files_only": True,
158
+ "trust_remote_code": False,
159
+ }
160
+
161
+
162
+ def _reject_auto_map(value: Any, *, owner: str) -> None:
163
+ """Reject repository-provided Python entrypoints anywhere in HF metadata.
164
+
165
+ This is deliberately stricter than `trust_remote_code=False`: it refuses an
166
+ artifact that merely *carries* remote-code entrypoints, rather than only
167
+ declining to run them. That makes `load_timebraid_checkpoint` a loader for
168
+ canonical artifacts, which have no `auto_map`.
169
+
170
+ The published Hub artifact is not such an artifact — it ships `auto_map` on
171
+ purpose, so users who have not installed this package can load it with
172
+ `trust_remote_code=True`. Those users, and installed-package users alike,
173
+ go through `AutoModelForCausalLM.from_pretrained`, which works either way.
174
+ """
175
+ pending = [value]
176
+ while pending:
177
+ current = pending.pop()
178
+ if isinstance(current, dict):
179
+ if "auto_map" in current:
180
+ raise RuntimeError(
181
+ f"{owner} must not contain auto_map; install and import the "
182
+ "TimeBraid package instead."
183
+ )
184
+ pending.extend(current.values())
185
+ elif isinstance(current, list):
186
+ pending.extend(current)
187
+
188
+
189
+ def _local_safetensors_tensor_dtypes(root: str) -> dict[str, str]:
190
+ directory = Path(root)
191
+ index_path = directory / _MODEL_INDEX_NAME
192
+ single_path = directory / _SINGLE_MODEL_NAME
193
+ unsafe_weight_files = sorted(
194
+ path.name
195
+ for path in directory.iterdir()
196
+ if path.name == "pytorch_model.bin"
197
+ or path.name == "pytorch_model.bin.index.json"
198
+ or (path.name.startswith("pytorch_model-") and path.name.endswith(".bin"))
199
+ )
200
+ if unsafe_weight_files:
201
+ raise RuntimeError(
202
+ f"Strict loading rejects pickle-backed weights: {unsafe_weight_files}."
203
+ )
204
+ selectors = (index_path.is_file(), single_path.is_file())
205
+ if sum(selectors) != 1:
206
+ raise RuntimeError(
207
+ "Resolved source must contain exactly one safetensors selector: "
208
+ "model.safetensors or model.safetensors.index.json."
209
+ )
210
+
211
+ declared: dict[str, str] | None = None
212
+ if selectors[0]:
213
+ index = _strict_json_object(index_path, owner="Model safetensors index")
214
+ weight_map = index.get("weight_map")
215
+ if not isinstance(weight_map, dict) or not weight_map:
216
+ raise RuntimeError(
217
+ "Model safetensors index must contain a non-empty weight_map."
218
+ )
219
+ declared = {}
220
+ for tensor_name, shard_value in weight_map.items():
221
+ if not isinstance(tensor_name, str) or not tensor_name:
222
+ raise RuntimeError(
223
+ "Model safetensors index tensor names must be non-empty strings."
224
+ )
225
+ shard_name = _safe_top_level_name(
226
+ shard_value, owner=f"Shard for {tensor_name!r}"
227
+ )
228
+ if not shard_name.endswith(".safetensors"):
229
+ raise RuntimeError(
230
+ f"Model shard must use safetensors, got {shard_name!r}."
231
+ )
232
+ declared[tensor_name] = shard_name
233
+ shard_names = sorted(set(declared.values()))
234
+ else:
235
+ shard_names = [_SINGLE_MODEL_NAME]
236
+
237
+ observed: dict[str, tuple[str, str]] = {}
238
+ for shard_name in shard_names:
239
+ shard_path = directory / shard_name
240
+ if not shard_path.is_file():
241
+ raise RuntimeError(
242
+ f"Model shard must resolve to a regular file: {shard_path}"
243
+ )
244
+ try:
245
+ with safe_open(shard_path, framework="pt", device="cpu") as reader:
246
+ shard_keys = list(reader.keys())
247
+ for tensor_name in shard_keys:
248
+ tensor_slice = reader.get_slice(tensor_name)
249
+ if tensor_name in observed:
250
+ raise RuntimeError(
251
+ f"Safetensors tensor appears in multiple shards: {tensor_name!r}."
252
+ )
253
+ observed[tensor_name] = (
254
+ shard_name,
255
+ str(tensor_slice.get_dtype()),
256
+ )
257
+ except RuntimeError:
258
+ raise
259
+ except Exception as exc:
260
+ raise RuntimeError(
261
+ f"Could not validate safetensors shard: {shard_path}"
262
+ ) from exc
263
+ if not observed:
264
+ raise RuntimeError("Model safetensors payload contains no tensors.")
265
+ observed_placement = {
266
+ tensor_name: shard_name
267
+ for tensor_name, (shard_name, _dtype) in observed.items()
268
+ }
269
+ if declared is not None and observed_placement != declared:
270
+ missing = sorted(set(declared) - set(observed))
271
+ extra = sorted(set(observed) - set(declared))
272
+ misplaced = {
273
+ name: {"declared": declared[name], "observed": observed_placement[name]}
274
+ for name in sorted(set(declared) & set(observed))
275
+ if declared[name] != observed_placement[name]
276
+ }
277
+ raise RuntimeError(
278
+ "Safetensors index does not match shard tensor placement: "
279
+ f"missing={missing}, extra={extra}, misplaced={misplaced}."
280
+ )
281
+ observed_shards = {
282
+ path.name for path in directory.iterdir() if path.name.endswith(".safetensors")
283
+ }
284
+ if observed_shards != set(shard_names):
285
+ raise RuntimeError(
286
+ "Model source contains undeclared safetensors shards: "
287
+ f"declared={shard_names!r}, observed={sorted(observed_shards)!r}."
288
+ )
289
+ return {
290
+ tensor_name: dtype for tensor_name, (_shard_name, dtype) in observed.items()
291
+ }
292
+
293
+
294
+ def _reject_noncanonical_tied_aliases(
295
+ config_payload: dict[str, Any], tensor_names: set[str]
296
+ ) -> None:
297
+ model_type = config_payload.get("model_type")
298
+ if model_type == TimeBraidConfig.model_type:
299
+ llm_config = config_payload.get("llm_config")
300
+ if not isinstance(llm_config, dict):
301
+ raise RuntimeError(
302
+ "TimeBraid config must contain a nested llm_config object."
303
+ )
304
+ tie_word_embeddings = llm_config.get("tie_word_embeddings", False)
305
+ lm_head_alias = "llm.lm_head.weight"
306
+ else:
307
+ tie_word_embeddings = config_payload.get("tie_word_embeddings", False)
308
+ lm_head_alias = "lm_head.weight"
309
+ if type(tie_word_embeddings) is not bool:
310
+ raise RuntimeError(
311
+ f"Model config tie_word_embeddings must be bool, got {tie_word_embeddings!r}."
312
+ )
313
+ forbidden = set()
314
+ if tie_word_embeddings and lm_head_alias in tensor_names:
315
+ forbidden.add(lm_head_alias)
316
+ if forbidden:
317
+ raise RuntimeError(
318
+ "Model safetensors contains noncanonical tied-weight aliases that HF may silently ignore: "
319
+ f"{sorted(forbidden)!r}."
320
+ )
321
+
322
+
323
+ def _validate_local_model_payload(root: str) -> dict[str, str]:
324
+ directory = Path(root)
325
+ for forbidden_name in ("adapter_config.json", "additional_chat_templates"):
326
+ forbidden_path = directory / forbidden_name
327
+ try:
328
+ forbidden_path.lstat()
329
+ except FileNotFoundError:
330
+ continue
331
+ raise RuntimeError(
332
+ "Strict full-checkpoint loading rejects loader redirection or nested "
333
+ f"tokenizer templates: {forbidden_path}"
334
+ )
335
+
336
+ config_path = directory / "config.json"
337
+ if not config_path.is_file():
338
+ raise RuntimeError(
339
+ f"Model config must resolve to a regular file: {config_path}"
340
+ )
341
+ config_payload = _strict_json_object(config_path, owner="Model config")
342
+ _reject_auto_map(config_payload, owner="Model config")
343
+ tensor_dtypes = _local_safetensors_tensor_dtypes(root)
344
+ _reject_noncanonical_tied_aliases(config_payload, set(tensor_dtypes))
345
+ return tensor_dtypes
346
+
347
+
348
+ def _validate_complete_checkpoint_load(
349
+ model: TimeBraid, loading_info: dict[str, Any]
350
+ ) -> None:
351
+ """Reject every tensor mismatch except live tied-weight aliases omitted by HF saves."""
352
+ tied_targets = set(getattr(model, "_tied_weights_keys", {}))
353
+ missing = sorted(
354
+ key
355
+ for item in (loading_info.get("missing_keys") or [])
356
+ if (key := _loading_info_key(item)) not in tied_targets
357
+ )
358
+ unexpected = sorted(
359
+ _loading_info_key(item) for item in (loading_info.get("unexpected_keys") or [])
360
+ )
361
+ mismatched = sorted(
362
+ str(item) for item in (loading_info.get("mismatched_keys") or [])
363
+ )
364
+ error_messages = [str(item) for item in (loading_info.get("error_msgs") or [])]
365
+ if missing or unexpected or mismatched or error_messages:
366
+ raise RuntimeError(
367
+ "TimeBraid checkpoint did not load exactly: "
368
+ f"missing={missing}, unexpected={unexpected}, mismatched={mismatched}, "
369
+ f"errors={error_messages}"
370
+ )
371
+
372
+
373
+ def _validate_tokenizer_protocol(
374
+ tokenizer: PreTrainedTokenizerBase,
375
+ *,
376
+ token_id_upper_bound: int,
377
+ ) -> dict[str, int]:
378
+ if len(tokenizer) > token_id_upper_bound:
379
+ raise RuntimeError(
380
+ "TimeBraid tokenizer vocabulary exceeds the checkpoint embedding table: "
381
+ f"tokenizer={len(tokenizer)}, embeddings={token_id_upper_bound}."
382
+ )
383
+ delimiter_ids: dict[str, int] = {}
384
+ for delimiter in ("<ts>", "</ts>"):
385
+ token_ids = tokenizer(delimiter, add_special_tokens=False)["input_ids"]
386
+ if not isinstance(token_ids, list) or len(token_ids) != 1:
387
+ raise RuntimeError(
388
+ f"TimeBraid tokenizer must encode {delimiter!r} as exactly one token, got {token_ids!r}."
389
+ )
390
+ token_id = int(token_ids[0])
391
+ converted_id = tokenizer.convert_tokens_to_ids(delimiter)
392
+ if converted_id is None or int(converted_id) != token_id:
393
+ raise RuntimeError(
394
+ f"TimeBraid tokenizer has inconsistent ID resolution for {delimiter!r}: "
395
+ f"encode={token_id}, convert={converted_id!r}."
396
+ )
397
+ if tokenizer.unk_token_id is not None and token_id == int(
398
+ tokenizer.unk_token_id
399
+ ):
400
+ raise RuntimeError(
401
+ f"TimeBraid tokenizer resolves {delimiter!r} to unk_token_id={token_id}."
402
+ )
403
+ if token_id < 0 or token_id >= token_id_upper_bound:
404
+ raise RuntimeError(
405
+ f"TimeBraid tokenizer encodes {delimiter!r} outside the embedding table: {token_id}."
406
+ )
407
+ delimiter_ids[delimiter] = token_id
408
+ if delimiter_ids["<ts>"] == delimiter_ids["</ts>"]:
409
+ raise RuntimeError(
410
+ "TimeBraid tokenizer maps <ts> and </ts> to the same token ID: "
411
+ f"{delimiter_ids['<ts>']}."
412
+ )
413
+ return delimiter_ids
414
+
415
+
416
+ def _load_exact_tokenizer(
417
+ source: str,
418
+ *,
419
+ use_fast_tokenizer: bool,
420
+ hf_kwargs: dict[str, Any],
421
+ ) -> PreTrainedTokenizerBase:
422
+ tokenizer_config_path = Path(source) / "tokenizer_config.json"
423
+ tokenizer_config = _strict_json_object(
424
+ tokenizer_config_path,
425
+ owner="Tokenizer config",
426
+ )
427
+ _reject_auto_map(tokenizer_config, owner="Tokenizer config")
428
+ extra_special_tokens_kwargs: dict[str, Any] = {}
429
+ if "extra_special_tokens" in tokenizer_config:
430
+ extra_special_tokens = tokenizer_config["extra_special_tokens"]
431
+ if isinstance(extra_special_tokens, dict):
432
+ pass
433
+ elif isinstance(extra_special_tokens, list) and not extra_special_tokens:
434
+ # Older Qwen checkpoints serialized the empty model-specific token
435
+ # mapping as []. Transformers 4.57 calls .keys() on this value; an
436
+ # explicit {} preserves the same empty set without changing bytes.
437
+ extra_special_tokens_kwargs["extra_special_tokens"] = {}
438
+ elif isinstance(extra_special_tokens, list):
439
+ raise RuntimeError(
440
+ "tokenizer_config.json extra_special_tokens must be an object or []; "
441
+ f"got list[{len(extra_special_tokens)}]."
442
+ )
443
+ else:
444
+ raise RuntimeError(
445
+ "tokenizer_config.json extra_special_tokens must be an object or []; "
446
+ f"got {type(extra_special_tokens).__name__}."
447
+ )
448
+ tokenizer = AutoTokenizer.from_pretrained(
449
+ source,
450
+ use_fast=use_fast_tokenizer,
451
+ padding_side="right",
452
+ # Preserve the serialized Qwen regex exactly; an automatic Mistral
453
+ # rewrite changes tokenizer identity and invalidates tokenized caches.
454
+ fix_mistral_regex=False,
455
+ **extra_special_tokens_kwargs,
456
+ **hf_kwargs,
457
+ )
458
+ if tokenizer.padding_side != "right":
459
+ raise RuntimeError(
460
+ f"TimeBraid tokenizer must use right padding, got {tokenizer.padding_side!r}."
461
+ )
462
+ return tokenizer
463
+
464
+
465
+ def _validate_timebraid_config_surface(
466
+ config: TimeBraidConfig,
467
+ ) -> tuple[int, dict[str, Any]]:
468
+ if config.model_type != TimeBraidConfig.model_type:
469
+ raise RuntimeError(
470
+ "Strict TimeBraid checkpoint loading requires model_type='timebraid', "
471
+ f"got {config.model_type!r}."
472
+ )
473
+ mot_contract = collect_mot_hf_config_contract(config)
474
+ if str(getattr(config.llm_config, "model_type", "")) != "qwen3":
475
+ raise RuntimeError(
476
+ "TimeBraid currently requires a nested Qwen3 backbone config, got "
477
+ f"{getattr(config.llm_config, 'model_type', None)!r}."
478
+ )
479
+ config_vocab_size = getattr(config.llm_config, "vocab_size", 0)
480
+ if type(config_vocab_size) is not int or config_vocab_size <= 0:
481
+ raise RuntimeError(
482
+ f"TimeBraid config has invalid vocab_size={config_vocab_size}."
483
+ )
484
+ return config_vocab_size, mot_contract
485
+
486
+
487
+ def _validate_config_tokenizer_delimiters(
488
+ *, mot_contract: dict[str, Any], tokenizer_ids: dict[str, int]
489
+ ) -> None:
490
+ configured = (
491
+ mot_contract["mot_ts_open_token_id"],
492
+ mot_contract["mot_ts_close_token_id"],
493
+ )
494
+ observed = (tokenizer_ids["<ts>"], tokenizer_ids["</ts>"])
495
+ if configured != observed:
496
+ raise RuntimeError(
497
+ "TimeBraid config TS delimiter IDs disagree with the tokenizer: "
498
+ f"config={configured}, tokenizer={observed}."
499
+ )
500
+
501
+
502
+ def _load_timebraid_config(
503
+ source: str,
504
+ *,
505
+ hf_kwargs: dict[str, Any],
506
+ ) -> TimeBraidConfig:
507
+ """Load one current TimeBraid config without mutating serialized fields."""
508
+ AutoConfig.register(TimeBraidConfig.model_type, TimeBraidConfig, exist_ok=True)
509
+ config = AutoConfig.from_pretrained(source, **hf_kwargs)
510
+ if not isinstance(config, TimeBraidConfig):
511
+ raise RuntimeError(
512
+ "TimeBraid checkpoint must resolve to TimeBraidConfig, got "
513
+ f"{type(config).__name__}."
514
+ )
515
+ return config
516
+
517
+
518
+ def validate_timebraid_checkpoint_source(
519
+ source: str | Path,
520
+ *,
521
+ revision: str | None = None,
522
+ token: str | None = None,
523
+ cache_dir: str | None = None,
524
+ local_files_only: bool = False,
525
+ trust_remote_code: bool = False,
526
+ use_fast_tokenizer: bool = True,
527
+ ) -> ValidatedTimeBraidCheckpointSource:
528
+ """Validate config, tokenizer, and safetensors without loading model tensors."""
529
+ hf_kwargs = _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
530
+ source_str = _resolve_hf_source(
531
+ _normalize_source(source, field_name="source"),
532
+ revision=revision,
533
+ token=token,
534
+ cache_dir=cache_dir,
535
+ local_files_only=local_files_only,
536
+ )
537
+ tensor_dtypes = _validate_local_model_payload(source_str)
538
+ tokenizer = _load_exact_tokenizer(
539
+ source_str,
540
+ use_fast_tokenizer=use_fast_tokenizer,
541
+ hf_kwargs=hf_kwargs,
542
+ )
543
+ config = _load_timebraid_config(
544
+ source_str,
545
+ hf_kwargs=hf_kwargs,
546
+ )
547
+ config_vocab_size, mot_contract = _validate_timebraid_config_surface(config)
548
+ token_ids = _validate_tokenizer_protocol(
549
+ tokenizer,
550
+ token_id_upper_bound=config_vocab_size,
551
+ )
552
+ _validate_config_tokenizer_delimiters(
553
+ mot_contract=mot_contract,
554
+ tokenizer_ids=token_ids,
555
+ )
556
+
557
+ return ValidatedTimeBraidCheckpointSource(
558
+ source=source_str,
559
+ config=config,
560
+ tokenizer=tokenizer,
561
+ tensor_count=len(tensor_dtypes),
562
+ physical_tensor_dtypes=tuple(sorted(tensor_dtypes.items())),
563
+ mot_ts_open_token_id=token_ids["<ts>"],
564
+ mot_ts_close_token_id=token_ids["</ts>"],
565
+ )
566
+
567
+
568
+ def load_timebraid_tokenizer(
569
+ source: str | Path,
570
+ *,
571
+ revision: str | None = None,
572
+ token: str | None = None,
573
+ cache_dir: str | None = None,
574
+ local_files_only: bool = False,
575
+ trust_remote_code: bool = False,
576
+ use_fast_tokenizer: bool = True,
577
+ ) -> PreTrainedTokenizerBase:
578
+ """Load the exact tokenizer from one local directory or Hub repo."""
579
+ hf_kwargs = _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
580
+ source_str = _resolve_hf_source(
581
+ _normalize_source(source, field_name="source"),
582
+ revision=revision,
583
+ token=token,
584
+ cache_dir=cache_dir,
585
+ local_files_only=local_files_only,
586
+ )
587
+ tokenizer = _load_exact_tokenizer(
588
+ source_str,
589
+ use_fast_tokenizer=use_fast_tokenizer,
590
+ hf_kwargs=hf_kwargs,
591
+ )
592
+ # This source-only preflight fails before model allocation. The model loader
593
+ # repeats it against the actual embedding rows.
594
+ _validate_tokenizer_protocol(
595
+ tokenizer,
596
+ token_id_upper_bound=len(tokenizer),
597
+ )
598
+ return tokenizer
599
+
600
+
601
+ def _validate_tied_weights(model: TimeBraid) -> None:
602
+ for target_name, source_name in getattr(model, "_tied_weights_keys", {}).items():
603
+ try:
604
+ target = model.get_parameter(target_name)
605
+ source = model.get_parameter(source_name)
606
+ except AttributeError as exc:
607
+ raise RuntimeError(
608
+ f"TimeBraid tied-weight path is missing: {target_name!r} -> {source_name!r}."
609
+ ) from exc
610
+ if target.data_ptr() != source.data_ptr():
611
+ raise RuntimeError(
612
+ f"TimeBraid tied weights do not share storage: {target_name!r} -> {source_name!r}."
613
+ )
614
+
615
+
616
+ def _validate_no_meta_tensors(model: PreTrainedModel, *, owner: str) -> None:
617
+ meta_tensors = sorted(
618
+ name
619
+ for name, tensor in tuple(model.named_parameters())
620
+ + tuple(model.named_buffers())
621
+ if tensor.is_meta
622
+ )
623
+ if meta_tensors:
624
+ raise RuntimeError(
625
+ f"{owner} left meta tensors after loading: {meta_tensors[:50]}."
626
+ )
627
+
628
+
629
+ def _validate_model(
630
+ model: TimeBraid,
631
+ tokenizer: PreTrainedTokenizerBase,
632
+ ) -> None:
633
+ input_embeddings = model.get_input_embeddings()
634
+ if input_embeddings is None or not hasattr(input_embeddings, "weight"):
635
+ raise RuntimeError("Loaded TimeBraid model has no input embedding weight.")
636
+ token_ids = _validate_tokenizer_protocol(
637
+ tokenizer,
638
+ token_id_upper_bound=int(input_embeddings.weight.shape[0]),
639
+ )
640
+ _validate_tied_weights(model)
641
+ _validate_no_meta_tensors(model, owner="TimeBraid checkpoint")
642
+ configured = (
643
+ getattr(model, "ts_open_token_id", None),
644
+ getattr(model, "ts_close_token_id", None),
645
+ )
646
+ observed = (token_ids["<ts>"], token_ids["</ts>"])
647
+ if configured != observed:
648
+ raise RuntimeError(
649
+ "TimeBraid TS delimiter IDs disagree with the tokenizer: "
650
+ f"model={configured}, tokenizer={observed}."
651
+ )
652
+
653
+
654
+ def validate_timebraid_model(
655
+ model: TimeBraid,
656
+ tokenizer: PreTrainedTokenizerBase,
657
+ ) -> None:
658
+ """Validate the loaded TimeBraid tensor, tokenizer, and component surface."""
659
+ _validate_model(model, tokenizer)
660
+
661
+
662
+ def _resolve_dtype(
663
+ value: torch.dtype | str | None, *, field_name: str
664
+ ) -> torch.dtype | Literal["auto"]:
665
+ if value is None or value == "auto":
666
+ return "auto"
667
+ if isinstance(value, torch.dtype):
668
+ return value
669
+ aliases = {
670
+ "bf16": torch.bfloat16,
671
+ "bfloat16": torch.bfloat16,
672
+ "fp16": torch.float16,
673
+ "float16": torch.float16,
674
+ "fp32": torch.float32,
675
+ "float32": torch.float32,
676
+ }
677
+ resolved = aliases.get(str(value).strip().lower())
678
+ if resolved is None:
679
+ raise ValueError(
680
+ f"{field_name} must be auto, bf16, fp16, or fp32, got {value!r}."
681
+ )
682
+ return resolved
683
+
684
+
685
+ def _resolve_compute_dtype(
686
+ value: torch.dtype | str | None,
687
+ *,
688
+ model_dtype: torch.dtype | str | None,
689
+ accelerator_is_cuda: bool,
690
+ ) -> torch.dtype:
691
+ resolved = _resolve_dtype(value, field_name="compute_dtype")
692
+ if resolved != "auto":
693
+ return resolved
694
+ if not accelerator_is_cuda:
695
+ return torch.float32
696
+ normalized_model_dtype = (
697
+ str(model_dtype).removeprefix("torch.") if model_dtype is not None else None
698
+ )
699
+ if torch.cuda.is_bf16_supported() and normalized_model_dtype in {None, "bfloat16"}:
700
+ return torch.bfloat16
701
+ return torch.float16
702
+
703
+
704
+ def _dtype_name(value: torch.dtype | Literal["auto"]) -> str:
705
+ return value if value == "auto" else str(value).removeprefix("torch.")
706
+
707
+
708
+ def _resolve_device_map(
709
+ device: str | torch.device | None,
710
+ *,
711
+ low_cpu_mem_usage: bool,
712
+ ) -> tuple[str | dict[str, str] | None, bool]:
713
+ device_is_auto = isinstance(device, str) and device.strip().lower() == "auto"
714
+ if device_is_auto:
715
+ raise ValueError(
716
+ "device='auto' is unsupported because TimeBraid requires one explicit model device."
717
+ )
718
+ try:
719
+ requested_device = None if device is None else torch.device(device)
720
+ except (RuntimeError, TypeError) as exc:
721
+ raise ValueError(
722
+ f"device must be cpu, cuda, or cuda:N, got {device!r}."
723
+ ) from exc
724
+ accelerator_is_cuda = bool(
725
+ requested_device is not None and requested_device.type == "cuda"
726
+ )
727
+ if device is None:
728
+ return None, accelerator_is_cuda
729
+ if not low_cpu_mem_usage:
730
+ raise ValueError("device loading requires low_cpu_mem_usage=true.")
731
+ if requested_device is None:
732
+ raise RuntimeError("Explicit device resolution produced no device.")
733
+ if requested_device.type not in {"cpu", "cuda"}:
734
+ raise ValueError(f"device must be cpu or cuda, got {requested_device.type!r}.")
735
+ if requested_device.type == "cuda" and not torch.cuda.is_available():
736
+ raise RuntimeError(
737
+ f"CUDA device requested but CUDA is unavailable: {requested_device}."
738
+ )
739
+ return {"": str(requested_device)}, accelerator_is_cuda
740
+
741
+
742
+ def _model_config_dtype(config: Any) -> torch.dtype | str | None:
743
+ return getattr(config, "dtype", None)
744
+
745
+
746
+ def load_timebraid_checkpoint(
747
+ source: str | Path,
748
+ *,
749
+ compute_dtype: torch.dtype | str | None = "auto",
750
+ weight_dtype: torch.dtype | str | None = "auto",
751
+ device: str | torch.device | None = None,
752
+ attention_implementation: Literal["eager", "sdpa", "flash_attention_2"]
753
+ | None = None,
754
+ revision: str | None = None,
755
+ token: str | None = None,
756
+ cache_dir: str | None = None,
757
+ local_files_only: bool = False,
758
+ trust_remote_code: bool = False,
759
+ use_fast_tokenizer: bool = True,
760
+ low_cpu_mem_usage: bool = True,
761
+ max_spans_per_sample: int = 1024,
762
+ ) -> LoadedTimeBraid:
763
+ """Load one local or Hub safetensors-only TimeBraid checkpoint."""
764
+ _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
765
+ resolved_device_map, accelerator_is_cuda = _resolve_device_map(
766
+ device,
767
+ low_cpu_mem_usage=low_cpu_mem_usage,
768
+ )
769
+ validated = validate_timebraid_checkpoint_source(
770
+ source,
771
+ revision=revision,
772
+ token=token,
773
+ cache_dir=cache_dir,
774
+ local_files_only=local_files_only,
775
+ trust_remote_code=trust_remote_code,
776
+ use_fast_tokenizer=use_fast_tokenizer,
777
+ )
778
+ source_str = validated.source
779
+ tokenizer = validated.tokenizer
780
+ config = validated.config
781
+ hf_kwargs = _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
782
+ if attention_implementation is not None:
783
+ config.llm_config._attn_implementation = attention_implementation
784
+
785
+ if type(max_spans_per_sample) is not int:
786
+ raise ValueError(
787
+ f"max_spans_per_sample must be an exact integer, got {max_spans_per_sample!r}."
788
+ )
789
+ if not 1 <= max_spans_per_sample <= _MAX_SPANS_PER_SAMPLE:
790
+ raise ValueError(
791
+ "max_spans_per_sample must stay in the maintained runtime range "
792
+ f"[1, {_MAX_SPANS_PER_SAMPLE}], got {max_spans_per_sample}."
793
+ )
794
+ resolved_weight_dtype = _resolve_dtype(weight_dtype, field_name="weight_dtype")
795
+ resolved_compute_dtype = _resolve_compute_dtype(
796
+ compute_dtype,
797
+ model_dtype=_model_config_dtype(config.llm_config),
798
+ accelerator_is_cuda=accelerator_is_cuda,
799
+ )
800
+ runtime_options: dict[str, Any] = {
801
+ "mot_max_spans_per_sample": max_spans_per_sample,
802
+ }
803
+ # Transformers also forwards unknown model kwargs to GenerationConfig.
804
+ # Keep this custom value JSON-safe while the runtime accepts the same
805
+ # canonical dtype spelling.
806
+ runtime_options["mot_compute_dtype"] = _dtype_name(resolved_compute_dtype)
807
+
808
+ model_kwargs: dict[str, Any] = {
809
+ **hf_kwargs,
810
+ "config": config,
811
+ "low_cpu_mem_usage": low_cpu_mem_usage,
812
+ "output_loading_info": True,
813
+ "runtime_options": runtime_options,
814
+ "dtype": resolved_weight_dtype,
815
+ "use_safetensors": True,
816
+ }
817
+ if resolved_device_map is not None:
818
+ model_kwargs["device_map"] = resolved_device_map
819
+
820
+ model, loading_info = TimeBraid.from_pretrained(source_str, **model_kwargs)
821
+ _validate_complete_checkpoint_load(model, loading_info)
822
+ _validate_model(model, tokenizer)
823
+ model.requires_grad_(False)
824
+ model.eval()
825
+ return LoadedTimeBraid(
826
+ model=model,
827
+ tokenizer=tokenizer,
828
+ source=source_str,
829
+ weight_dtype=_dtype_name(resolved_weight_dtype),
830
+ compute_dtype=_dtype_name(resolved_compute_dtype),
831
+ device_map=resolved_device_map,
832
+ physical_tensor_dtypes=validated.physical_tensor_dtypes,
833
+ )
834
+
835
+
836
+ def _safetensors_dtype_code(dtype: torch.dtype) -> str:
837
+ codes = {
838
+ torch.bool: "BOOL",
839
+ torch.uint8: "U8",
840
+ torch.int8: "I8",
841
+ torch.int16: "I16",
842
+ torch.int32: "I32",
843
+ torch.int64: "I64",
844
+ torch.float16: "F16",
845
+ torch.bfloat16: "BF16",
846
+ torch.float32: "F32",
847
+ torch.float64: "F64",
848
+ }
849
+ code = codes.get(dtype)
850
+ if code is None:
851
+ raise RuntimeError(f"TimeBraid release tensor has unsupported dtype {dtype}.")
852
+ return code
853
+
854
+
855
+ def _validate_timebraid_processor_artifact(
856
+ source: str,
857
+ *,
858
+ use_fast_tokenizer: bool,
859
+ trust_remote_code: bool,
860
+ ) -> None:
861
+ """Require one self-contained HF processor package at a resolved source."""
862
+ hf_kwargs = _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
863
+ root = Path(source)
864
+ processor_config_path = root / "processor_config.json"
865
+ if not processor_config_path.is_file():
866
+ raise RuntimeError(
867
+ "TimeBraid release artifact must contain processor_config.json: "
868
+ f"{processor_config_path}"
869
+ )
870
+ processor_config = _strict_json_object(
871
+ processor_config_path,
872
+ owner="Processor config",
873
+ )
874
+ tokenizer_config = _strict_json_object(
875
+ root / "tokenizer_config.json",
876
+ owner="Tokenizer config",
877
+ )
878
+ _reject_auto_map(processor_config, owner="Processor config")
879
+ _reject_auto_map(tokenizer_config, owner="Tokenizer config")
880
+ if processor_config.get("processor_class") != "TimeBraidProcessor":
881
+ raise RuntimeError(
882
+ "processor_config.json processor_class must be 'TimeBraidProcessor', got "
883
+ f"{processor_config.get('processor_class')!r}."
884
+ )
885
+ max_spans_per_sample = processor_config.get("max_spans_per_sample")
886
+ if (
887
+ type(max_spans_per_sample) is not int
888
+ or not 1 <= max_spans_per_sample <= _MAX_SPANS_PER_SAMPLE
889
+ ):
890
+ raise RuntimeError(
891
+ "processor_config.json max_spans_per_sample must be an integer in "
892
+ f"[1, {_MAX_SPANS_PER_SAMPLE}], got {max_spans_per_sample!r}."
893
+ )
894
+ normalization_epsilon = processor_config.get("normalization_epsilon")
895
+ if (
896
+ isinstance(normalization_epsilon, bool)
897
+ or not isinstance(normalization_epsilon, (int, float))
898
+ or not math.isfinite(float(normalization_epsilon))
899
+ or float(normalization_epsilon) <= 0.0
900
+ ):
901
+ raise RuntimeError(
902
+ "processor_config.json normalization_epsilon must be finite and positive, "
903
+ f"got {normalization_epsilon!r}."
904
+ )
905
+ if tokenizer_config.get("processor_class") != "TimeBraidProcessor":
906
+ raise RuntimeError(
907
+ "tokenizer_config.json processor_class must be 'TimeBraidProcessor', got "
908
+ f"{tokenizer_config.get('processor_class')!r}."
909
+ )
910
+ extra_special_tokens = tokenizer_config.get("extra_special_tokens")
911
+ if not isinstance(extra_special_tokens, dict):
912
+ raise RuntimeError(
913
+ "tokenizer_config.json extra_special_tokens must be an object in a release "
914
+ f"artifact, got {type(extra_special_tokens).__name__}."
915
+ )
916
+ if tokenizer_config.get("fix_mistral_regex") is not False:
917
+ raise RuntimeError(
918
+ "tokenizer_config.json fix_mistral_regex must be false to preserve the "
919
+ "trained tokenizer identity."
920
+ )
921
+
922
+ # Registration is process-local. The artifact itself stays free of remote
923
+ # auto_map code, while this gate exercises the same bare AutoProcessor call
924
+ # available to an installed TimeBraid package user.
925
+ register_timebraid_auto_classes()
926
+ from ..processing_timebraid import TimeBraidProcessor
927
+
928
+ processor = AutoProcessor.from_pretrained(
929
+ source,
930
+ use_fast=use_fast_tokenizer,
931
+ **hf_kwargs,
932
+ )
933
+ if not isinstance(processor, TimeBraidProcessor):
934
+ raise RuntimeError(
935
+ f"AutoProcessor must load TimeBraidProcessor, got {type(processor).__name__}."
936
+ )
937
+ if processor.max_spans_per_sample != max_spans_per_sample:
938
+ raise RuntimeError(
939
+ "AutoProcessor max_spans_per_sample disagrees with processor_config.json: "
940
+ f"{processor.max_spans_per_sample} != {max_spans_per_sample}."
941
+ )
942
+ if processor.normalization_epsilon != float(normalization_epsilon):
943
+ raise RuntimeError(
944
+ "AutoProcessor normalization_epsilon disagrees with processor_config.json: "
945
+ f"{processor.normalization_epsilon} != {normalization_epsilon}."
946
+ )
947
+
948
+
949
+ def validate_timebraid_canonical_artifact(
950
+ source: str | Path,
951
+ *,
952
+ revision: str | None = None,
953
+ token: str | None = None,
954
+ cache_dir: str | None = None,
955
+ local_files_only: bool = False,
956
+ trust_remote_code: bool = False,
957
+ use_fast_tokenizer: bool = True,
958
+ low_cpu_mem_usage: bool = True,
959
+ ) -> LoadedTimeBraid:
960
+ """Run the one-time exact-load and finite-state release check."""
961
+ _resolved_source_hf_kwargs(trust_remote_code=trust_remote_code)
962
+ resolved_source = _resolve_hf_source(
963
+ _normalize_source(source, field_name="source"),
964
+ revision=revision,
965
+ token=token,
966
+ cache_dir=cache_dir,
967
+ local_files_only=local_files_only,
968
+ )
969
+ _validate_timebraid_processor_artifact(
970
+ resolved_source,
971
+ use_fast_tokenizer=use_fast_tokenizer,
972
+ trust_remote_code=trust_remote_code,
973
+ )
974
+ loaded = load_timebraid_checkpoint(
975
+ resolved_source,
976
+ compute_dtype="auto",
977
+ weight_dtype="auto",
978
+ device=None,
979
+ local_files_only=True,
980
+ trust_remote_code=trust_remote_code,
981
+ use_fast_tokenizer=use_fast_tokenizer,
982
+ low_cpu_mem_usage=low_cpu_mem_usage,
983
+ )
984
+ nested_config = getattr(getattr(loaded.model, "config", None), "llm_config", None)
985
+ declared_dtype = str(getattr(nested_config, "dtype", None)).removeprefix("torch.")
986
+ if declared_dtype != "float32":
987
+ raise RuntimeError(
988
+ "TimeBraid release artifact must declare llm_config.dtype='float32', "
989
+ f"got {declared_dtype!r}."
990
+ )
991
+ non_f32_tensors = {
992
+ tensor_name: physical_dtype
993
+ for tensor_name, physical_dtype in loaded.physical_tensor_dtypes
994
+ if physical_dtype != "F32"
995
+ }
996
+ if non_f32_tensors:
997
+ sample = dict(list(sorted(non_f32_tensors.items()))[:20])
998
+ raise RuntimeError(
999
+ "TimeBraid release safetensors must physically contain only F32 tensors; "
1000
+ f"non_f32={sample}."
1001
+ )
1002
+ state = loaded.model.state_dict()
1003
+ dtype_mismatches = {}
1004
+ for tensor_name, physical_dtype in loaded.physical_tensor_dtypes:
1005
+ tensor = state.get(tensor_name)
1006
+ if tensor is None:
1007
+ raise RuntimeError(
1008
+ f"TimeBraid release tensor is absent after exact load: {tensor_name!r}."
1009
+ )
1010
+ loaded_dtype = _safetensors_dtype_code(tensor.dtype)
1011
+ if physical_dtype != loaded_dtype:
1012
+ dtype_mismatches[tensor_name] = {
1013
+ "physical": physical_dtype,
1014
+ "loaded": loaded_dtype,
1015
+ }
1016
+ if dtype_mismatches:
1017
+ sample = dict(list(sorted(dtype_mismatches.items()))[:20])
1018
+ raise RuntimeError(
1019
+ "TimeBraid release safetensors must physically match their loaded dtypes; "
1020
+ f"mismatches={sample}. Validate the final F32 artifact without "
1021
+ "load-time dtype conversion."
1022
+ )
1023
+ _validate_finite_model_tensors(
1024
+ loaded.model,
1025
+ owner="TimeBraid release artifact",
1026
+ )
1027
+ return loaded
1028
+
1029
+
1030
+ __all__ = [
1031
+ "LoadedTimeBraid",
1032
+ "ValidatedTimeBraidCheckpointSource",
1033
+ "load_timebraid_checkpoint",
1034
+ "load_timebraid_tokenizer",
1035
+ "validate_timebraid_canonical_artifact",
1036
+ "validate_timebraid_model",
1037
+ "validate_timebraid_checkpoint_source",
1038
+ ]
timebraid/model/mot/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Public output type for native TimeBraid generation."""
2
+
3
+ from .model import TimeBraidGenerateOutput
4
+
5
+ __all__ = ["TimeBraidGenerateOutput"]
timebraid/model/mot/attention.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mixed language and time-series attention for TimeBraid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+
11
+ class _GlobalCausalJointSelfAttention(nn.Module):
12
+ """Projection-free packed-attention kernel shared by native and residual paths."""
13
+
14
+ def __init__(
15
+ self,
16
+ q_heads: int,
17
+ q_head_dim: int,
18
+ *,
19
+ lang_hidden_size: Optional[int] = None,
20
+ ):
21
+ super().__init__()
22
+ self.q_heads = q_heads
23
+ self.q_head_dim = q_head_dim
24
+ self.q_attention_hidden_size = self.q_heads * self.q_head_dim
25
+ # Residual fusion projects language, generation-TS and understanding-TS
26
+ # into one shared Qwen head geometry before packing, so its softmax scale
27
+ # is tied to `q_head_dim`. Native language attention keeps Qwen's
28
+ # `self_attn.scaling`; native TimesFM replay uses its own head dimension
29
+ # for the fixed attention-kernel scale.
30
+ self.scale = float(self.q_head_dim) ** -0.5
31
+ self.lang_hidden_size = int(lang_hidden_size or self.q_attention_hidden_size)
32
+ if self.lang_hidden_size <= 0:
33
+ raise ValueError(
34
+ f"`lang_hidden_size` must be positive, got {self.lang_hidden_size}."
35
+ )
36
+ self._flash_attn_varlen_func = None
37
+ self._fa_peft_integration_check = None
38
+
39
+ def _get_flash_attn_varlen_func(self):
40
+ if self._flash_attn_varlen_func is not None:
41
+ return self._flash_attn_varlen_func
42
+ try:
43
+ from flash_attn import flash_attn_varlen_func
44
+ except Exception as exc:
45
+ raise RuntimeError(
46
+ "MoT mixed attention is FA2-only but `flash_attn` could not be imported. "
47
+ "Install flash-attn in the active TimeBraid Python environment."
48
+ ) from exc
49
+ self._flash_attn_varlen_func = flash_attn_varlen_func
50
+ return self._flash_attn_varlen_func
51
+
52
+ def _get_fa_peft_integration_check(self):
53
+ if self._fa_peft_integration_check is not None:
54
+ return self._fa_peft_integration_check
55
+ try:
56
+ from transformers.modeling_flash_attention_utils import (
57
+ fa_peft_integration_check,
58
+ )
59
+ except Exception as exc:
60
+ raise RuntimeError(
61
+ "Failed to import transformers.modeling_flash_attention_utils.fa_peft_integration_check. "
62
+ "This runtime patch follows HF FA2 dtype reconciliation logic and requires this symbol."
63
+ ) from exc
64
+ self._fa_peft_integration_check = fa_peft_integration_check
65
+ return self._fa_peft_integration_check
66
+
67
+ def _infer_fa2_target_dtype_like_hf(self, q: torch.Tensor) -> Optional[torch.dtype]:
68
+ """Mirror HF FA2 target-dtype inference for packed-attention calls."""
69
+ target_dtype: Optional[torch.dtype] = None
70
+ if q.dtype == torch.float32:
71
+ if torch.is_autocast_enabled():
72
+ target_dtype = torch.get_autocast_gpu_dtype()
73
+ elif hasattr(self, "config") and hasattr(
74
+ self.config, "_pre_quantization_dtype"
75
+ ):
76
+ target_dtype = self.config._pre_quantization_dtype
77
+ else:
78
+ first_linear = next(
79
+ (layer for layer in self.modules() if isinstance(layer, nn.Linear)),
80
+ None,
81
+ )
82
+ if first_linear is None:
83
+ raise RuntimeError(
84
+ "Cannot infer FA2 target dtype because no nn.Linear module was found in bridge layer."
85
+ )
86
+ target_dtype = first_linear.weight.dtype
87
+ return target_dtype
88
+
89
+ def _reconcile_fa2_qkv(
90
+ self,
91
+ *,
92
+ q: torch.Tensor,
93
+ k: torch.Tensor,
94
+ v: torch.Tensor,
95
+ mode_label: str,
96
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
97
+ target_dtype = self._infer_fa2_target_dtype_like_hf(q)
98
+ fa_peft_integration_check = self._get_fa_peft_integration_check()
99
+ q_fa, k_fa, v_fa = fa_peft_integration_check(q, k, v, target_dtype)
100
+ if q_fa.dtype not in {torch.float16, torch.bfloat16}:
101
+ raise RuntimeError(
102
+ f"MoT mixed FA2 {mode_label} requires fp16/bf16 Q/K/V after HF-style dtype reconciliation, "
103
+ f"but got dtype={q_fa.dtype}, original_dtype={q.dtype}, "
104
+ f"target_dtype={target_dtype}, autocast_enabled={torch.is_autocast_enabled()}."
105
+ )
106
+ return q_fa, k_fa, v_fa
107
+
108
+ def _reconcile_fast_qkv(
109
+ self,
110
+ *,
111
+ q: torch.Tensor,
112
+ k: torch.Tensor,
113
+ v: torch.Tensor,
114
+ mode_label: str,
115
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
116
+ """Normalize Q/K/V for the shared FA2 packed-attention kernel."""
117
+ return self._reconcile_fa2_qkv(q=q, k=k, v=v, mode_label=mode_label)
118
+
119
+ def _cu_seqlens(self, *, seqlens: list[int], device: torch.device) -> torch.Tensor:
120
+ cu = [0]
121
+ for seq_len in seqlens:
122
+ cu.append(cu[-1] + int(seq_len))
123
+ return torch.tensor(cu, device=device, dtype=torch.int32)
124
+
125
+ def _seqlens_from_cu_seqlens(self, *, cu_seqlens: torch.Tensor) -> list[int]:
126
+ if cu_seqlens.ndim != 1 or cu_seqlens.shape[0] == 0:
127
+ raise RuntimeError(
128
+ "Expected a non-empty 1D cu_seqlens tensor, got "
129
+ f"shape={tuple(cu_seqlens.shape)}."
130
+ )
131
+ cu_values = cu_seqlens.detach().to(device="cpu", dtype=torch.long).tolist()
132
+ return [
133
+ int(end) - int(start) for start, end in zip(cu_values[:-1], cu_values[1:])
134
+ ]
135
+
136
+ def _run_varlen_flash_attention(
137
+ self,
138
+ *,
139
+ q_sorted: torch.Tensor,
140
+ k_sorted: torch.Tensor,
141
+ v_sorted: torch.Tensor,
142
+ q_positions_sorted: torch.Tensor,
143
+ k_positions_sorted: torch.Tensor,
144
+ softmax_scale: float,
145
+ dropout_p: float,
146
+ mode_label: str,
147
+ q_seqlens: Optional[list[int]] = None,
148
+ k_seqlens: Optional[list[int]] = None,
149
+ cu_seqlens_q: Optional[torch.Tensor] = None,
150
+ cu_seqlens_k: Optional[torch.Tensor] = None,
151
+ max_seqlen_q: Optional[int] = None,
152
+ max_seqlen_k: Optional[int] = None,
153
+ ) -> torch.Tensor:
154
+ if q_sorted.ndim != 3 or k_sorted.ndim != 3 or v_sorted.ndim != 3:
155
+ raise RuntimeError(
156
+ "Fast MoT attention expects packed [N,H,D] Q/K/V tensors, got "
157
+ f"q={tuple(q_sorted.shape)}, k={tuple(k_sorted.shape)}, v={tuple(v_sorted.shape)}."
158
+ )
159
+ if k_sorted.shape != v_sorted.shape:
160
+ raise RuntimeError(
161
+ "Fast MoT attention expects aligned K/V shapes, got "
162
+ f"k={tuple(k_sorted.shape)}, v={tuple(v_sorted.shape)}."
163
+ )
164
+ if (
165
+ q_positions_sorted.ndim != 1
166
+ or q_positions_sorted.shape[0] != q_sorted.shape[0]
167
+ ):
168
+ raise RuntimeError(
169
+ "Fast MoT attention expects 1D query positions aligned to Q, got "
170
+ f"positions={tuple(q_positions_sorted.shape)}, q_tokens={q_sorted.shape[0]}."
171
+ )
172
+ if (
173
+ k_positions_sorted.ndim != 1
174
+ or k_positions_sorted.shape[0] != k_sorted.shape[0]
175
+ ):
176
+ raise RuntimeError(
177
+ "Fast MoT attention expects 1D key positions aligned to K, got "
178
+ f"positions={tuple(k_positions_sorted.shape)}, k_tokens={k_sorted.shape[0]}."
179
+ )
180
+
181
+ if q_seqlens is None:
182
+ if cu_seqlens_q is None:
183
+ raise RuntimeError(
184
+ "Fast MoT attention requires either q_seqlens or cu_seqlens_q."
185
+ )
186
+ q_seqlens = self._seqlens_from_cu_seqlens(cu_seqlens=cu_seqlens_q)
187
+ if k_seqlens is None:
188
+ if cu_seqlens_k is None:
189
+ raise RuntimeError(
190
+ "Fast MoT attention requires either k_seqlens or cu_seqlens_k."
191
+ )
192
+ k_seqlens = self._seqlens_from_cu_seqlens(cu_seqlens=cu_seqlens_k)
193
+ if max_seqlen_q is None:
194
+ max_seqlen_q = max(q_seqlens) if q_seqlens else 0
195
+ if max_seqlen_k is None:
196
+ max_seqlen_k = max(k_seqlens) if k_seqlens else 0
197
+
198
+ if q_sorted.device.type != "cuda":
199
+ raise RuntimeError(
200
+ f"MoT FA2 {mode_label} requires CUDA device, got {q_sorted.device.type}."
201
+ )
202
+ q_fast, k_fast, v_fast = self._reconcile_fast_qkv(
203
+ q=q_sorted,
204
+ k=k_sorted,
205
+ v=v_sorted,
206
+ mode_label=mode_label,
207
+ )
208
+ if cu_seqlens_q is None:
209
+ cu_seqlens_q = self._cu_seqlens(seqlens=q_seqlens, device=q_sorted.device)
210
+ if cu_seqlens_k is None:
211
+ cu_seqlens_k = self._cu_seqlens(seqlens=k_seqlens, device=q_sorted.device)
212
+ return self._get_flash_attn_varlen_func()(
213
+ q_fast,
214
+ k_fast,
215
+ v_fast,
216
+ cu_seqlens_q,
217
+ cu_seqlens_k,
218
+ max_seqlen_q,
219
+ max_seqlen_k,
220
+ dropout_p=dropout_p,
221
+ softmax_scale=softmax_scale,
222
+ causal=True,
223
+ )
224
+
225
+ def run_packed_self_attention(
226
+ self,
227
+ *,
228
+ q_sorted: torch.Tensor,
229
+ k_sorted: torch.Tensor,
230
+ v_sorted: torch.Tensor,
231
+ cu_seqlens: torch.Tensor,
232
+ max_seqlen: int,
233
+ rope_positions_sorted: torch.Tensor,
234
+ softmax_scale: float,
235
+ dropout_p: float,
236
+ mode_label: str,
237
+ ) -> torch.Tensor:
238
+ """Shared FA2 packed-attention entrypoint for native and residual callers."""
239
+ return self._run_varlen_flash_attention(
240
+ q_sorted=q_sorted,
241
+ k_sorted=k_sorted,
242
+ v_sorted=v_sorted,
243
+ q_positions_sorted=rope_positions_sorted,
244
+ k_positions_sorted=rope_positions_sorted,
245
+ softmax_scale=softmax_scale,
246
+ dropout_p=dropout_p,
247
+ mode_label=mode_label,
248
+ cu_seqlens_q=cu_seqlens,
249
+ cu_seqlens_k=cu_seqlens,
250
+ max_seqlen_q=max_seqlen,
251
+ max_seqlen_k=max_seqlen,
252
+ )
253
+
254
+
255
+ class ResidualStreamProjection(nn.Module):
256
+ """Project one stream into and out of the shared residual-attention heads."""
257
+
258
+ def __init__(
259
+ self,
260
+ *,
261
+ input_size: int,
262
+ attention_heads: int,
263
+ head_dim: int,
264
+ ) -> None:
265
+ super().__init__()
266
+ if input_size <= 0:
267
+ raise ValueError(f"input_size must be positive, got {input_size}.")
268
+ if attention_heads <= 0:
269
+ raise ValueError(
270
+ f"attention_heads must be positive, got {attention_heads}."
271
+ )
272
+ if head_dim <= 0:
273
+ raise ValueError(f"head_dim must be positive, got {head_dim}.")
274
+
275
+ self.input_size = int(input_size)
276
+ self.attention_heads = int(attention_heads)
277
+ self.head_dim = int(head_dim)
278
+ attention_size = self.attention_heads * self.head_dim
279
+
280
+ # Keep registration order identical across all streams. Q/K use the
281
+ # Qwen-style post-projection head norm; V remains the raw projection.
282
+ #
283
+ # The epsilon is fixed at 1e-6 on purpose rather than mirrored from the
284
+ # host LM's `rms_norm_eps`. These norms carry trained weights, so the
285
+ # value they were trained under is part of this module's topology, the
286
+ # same as its head dimension; reading it from whatever backbone happens
287
+ # to be loaded would change a trained module's numerics. It matches
288
+ # Qwen3's default, so the two agree in practice today. Persisting it in
289
+ # the MoT restore contract is the proper fix and is deferred to the
290
+ # change that can update the training tree and the published configs
291
+ # together.
292
+ self.input_norm = nn.RMSNorm(
293
+ self.input_size, eps=1.0e-6, elementwise_affine=True
294
+ )
295
+ self.q_proj = nn.Linear(self.input_size, attention_size, bias=False)
296
+ self.q_norm = nn.RMSNorm(self.head_dim, eps=1.0e-6, elementwise_affine=True)
297
+ self.k_proj = nn.Linear(self.input_size, attention_size, bias=False)
298
+ self.k_norm = nn.RMSNorm(self.head_dim, eps=1.0e-6, elementwise_affine=True)
299
+ self.v_proj = nn.Linear(self.input_size, attention_size, bias=False)
300
+ self.out_proj = nn.Linear(attention_size, self.input_size, bias=False)
301
+ nn.init.zeros_(self.out_proj.weight)
302
+
303
+ @staticmethod
304
+ def _apply_head_norm(states: torch.Tensor, norm: nn.Module) -> torch.Tensor:
305
+ normalized = norm(states.to(dtype=norm.weight.dtype))
306
+ return normalized.to(dtype=states.dtype)
307
+
308
+ def project_qkv(
309
+ self, hidden: torch.Tensor
310
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
311
+ if hidden.ndim != 2 or int(hidden.shape[-1]) != self.input_size:
312
+ raise RuntimeError(
313
+ "Residual stream projection expects [N,H], got "
314
+ f"shape={tuple(hidden.shape)}, expected_hidden={self.input_size}."
315
+ )
316
+ if int(hidden.shape[0]) == 0:
317
+ empty = hidden.new_empty((0, self.attention_heads, self.head_dim))
318
+ return empty, empty, empty
319
+
320
+ normalized = self.input_norm(hidden.to(dtype=self.input_norm.weight.dtype)).to(
321
+ dtype=self.q_proj.weight.dtype
322
+ )
323
+ token_count = int(normalized.shape[0])
324
+ head_shape = (token_count, self.attention_heads, self.head_dim)
325
+ q = self.q_proj(normalized).view(head_shape)
326
+ k = self.k_proj(normalized).view(head_shape)
327
+ v = self.v_proj(normalized).view(head_shape)
328
+ q = self._apply_head_norm(q, self.q_norm)
329
+ k = self._apply_head_norm(k, self.k_norm)
330
+ return q.contiguous(), k.contiguous(), v.contiguous()
331
+
332
+ def project_output(
333
+ self, out_heads: torch.Tensor, *, output_dtype: torch.dtype
334
+ ) -> torch.Tensor:
335
+ expected_head_shape = (self.attention_heads, self.head_dim)
336
+ if out_heads.ndim != 3 or tuple(out_heads.shape[1:]) != expected_head_shape:
337
+ raise RuntimeError(
338
+ "Residual stream output expects [N,H,D], got "
339
+ f"shape={tuple(out_heads.shape)}, expected_heads={expected_head_shape}."
340
+ )
341
+ flat = out_heads.reshape(out_heads.shape[0], -1)
342
+ delta = self.out_proj(flat.to(dtype=self.out_proj.weight.dtype))
343
+ return delta.to(dtype=output_dtype)
344
+
345
+
346
+ class GlobalResidualAttention(_GlobalCausalJointSelfAttention):
347
+ """One joint residual-attention block over language and paired TS streams."""
348
+
349
+ def __init__(
350
+ self,
351
+ *,
352
+ language_hidden_size: int,
353
+ generation_hidden_size: Optional[int],
354
+ understanding_hidden_size: Optional[int],
355
+ attention_heads: int,
356
+ head_dim: int,
357
+ ) -> None:
358
+ if generation_hidden_size is None and understanding_hidden_size is None:
359
+ raise ValueError(
360
+ "GlobalResidualAttention requires generation or understanding."
361
+ )
362
+ super().__init__(
363
+ q_heads=attention_heads,
364
+ q_head_dim=head_dim,
365
+ lang_hidden_size=language_hidden_size,
366
+ )
367
+
368
+ # Module registration order is part of the checkpoint schema.
369
+ self.language = ResidualStreamProjection(
370
+ input_size=language_hidden_size,
371
+ attention_heads=attention_heads,
372
+ head_dim=head_dim,
373
+ )
374
+ self.generation = (
375
+ ResidualStreamProjection(
376
+ input_size=generation_hidden_size,
377
+ attention_heads=attention_heads,
378
+ head_dim=head_dim,
379
+ )
380
+ if generation_hidden_size is not None
381
+ else None
382
+ )
383
+ self.understanding = (
384
+ ResidualStreamProjection(
385
+ input_size=understanding_hidden_size,
386
+ attention_heads=attention_heads,
387
+ head_dim=head_dim,
388
+ )
389
+ if understanding_hidden_size is not None
390
+ else None
391
+ )
392
+ self.residual_attention_mode = True
393
+
394
+ @staticmethod
395
+ def _require_stream(
396
+ stream: Optional[ResidualStreamProjection], *, name: str
397
+ ) -> ResidualStreamProjection:
398
+ if stream is None:
399
+ raise RuntimeError(
400
+ f"GlobalResidualAttention has no {name} stream on this layer."
401
+ )
402
+ return stream
403
+
404
+ def project_language_qkv(
405
+ self, hidden: torch.Tensor
406
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
407
+ return self.language.project_qkv(hidden)
408
+
409
+ def project_generation_qkv(
410
+ self, hidden: torch.Tensor
411
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
412
+ return self._require_stream(self.generation, name="generation").project_qkv(
413
+ hidden
414
+ )
415
+
416
+ def project_understanding_qkv(
417
+ self, hidden: torch.Tensor
418
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
419
+ return self._require_stream(
420
+ self.understanding, name="understanding"
421
+ ).project_qkv(hidden)
422
+
423
+ def project_language_delta(
424
+ self, out_heads: torch.Tensor, *, output_dtype: torch.dtype
425
+ ) -> torch.Tensor:
426
+ return self.language.project_output(out_heads, output_dtype=output_dtype)
427
+
428
+ def project_generation_delta(
429
+ self, out_heads: torch.Tensor, *, output_dtype: torch.dtype
430
+ ) -> torch.Tensor:
431
+ return self._require_stream(self.generation, name="generation").project_output(
432
+ out_heads, output_dtype=output_dtype
433
+ )
434
+
435
+ def project_understanding_delta(
436
+ self, out_heads: torch.Tensor, *, output_dtype: torch.dtype
437
+ ) -> torch.Tensor:
438
+ return self._require_stream(
439
+ self.understanding, name="understanding"
440
+ ).project_output(out_heads, output_dtype=output_dtype)
441
+
442
+
443
+ __all__ = [
444
+ "GlobalResidualAttention",
445
+ "ResidualStreamProjection",
446
+ "_GlobalCausalJointSelfAttention",
447
+ ]
timebraid/model/mot/bridge_runtime.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initialize TimeBraid's registered TimesFM and global-attention components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import math
7
+ from typing import List, Optional
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+ from .attention import GlobalResidualAttention, _GlobalCausalJointSelfAttention
13
+ from .pairing import (
14
+ _build_pair_map,
15
+ _resolve_pairable_q_layer_indices,
16
+ )
17
+ from .runtime_state import MoTRuntimeOptions
18
+ from .structures import build_timebraid_layer_plan
19
+
20
+
21
+ def _build_upsampled_tsfm_layer_plan(source_depth: int, target_depth: int) -> List[int]:
22
+ """Build a concrete source-layer copy plan for an aligned TS stack."""
23
+ if source_depth <= 0:
24
+ raise ValueError(f"source_depth must be positive, got {source_depth}")
25
+ if target_depth <= 0:
26
+ raise ValueError(f"target_depth must be positive, got {target_depth}")
27
+ if target_depth < source_depth:
28
+ raise ValueError(
29
+ "TS layer upsampling expects target_depth >= source_depth, got "
30
+ f"source_depth={source_depth}, target_depth={target_depth}"
31
+ )
32
+ if target_depth == source_depth:
33
+ return list(range(source_depth))
34
+ if source_depth == 1:
35
+ return [0] * target_depth
36
+
37
+ source_last = source_depth - 1
38
+ target_last = target_depth - 1
39
+ plan: List[int] = []
40
+ for runtime_idx in range(target_depth):
41
+ source_idx = round(runtime_idx * source_last / target_last)
42
+ plan.append(int(source_idx))
43
+ return plan
44
+
45
+
46
+ def _build_dsfp_tsfm_layer_plan(
47
+ source_depth: int, target_depth: int
48
+ ) -> tuple[List[int], List[int]]:
49
+ """Return the copied source layer and original-layer positions for DSFP."""
50
+ if source_depth <= 0:
51
+ raise ValueError(f"source_depth must be positive, got {source_depth}")
52
+ if target_depth < source_depth:
53
+ raise ValueError(
54
+ "DSFP expects target_depth >= source_depth, got "
55
+ f"source_depth={source_depth}, target_depth={target_depth}."
56
+ )
57
+
58
+ source_pair_map = _build_pair_map(
59
+ num_q_layers=target_depth,
60
+ num_t_layers=source_depth,
61
+ pairing_mode="interleaved",
62
+ )
63
+ source_positions = sorted(source_pair_map)
64
+ copy_plan = _build_upsampled_tsfm_layer_plan(
65
+ source_depth=source_depth, target_depth=target_depth
66
+ )
67
+ for runtime_idx, source_idx in source_pair_map.items():
68
+ if copy_plan[runtime_idx] != source_idx:
69
+ raise RuntimeError(
70
+ "DSFP source placement disagrees with the deterministic copy plan: "
71
+ f"runtime_idx={runtime_idx}, source_idx={source_idx}, "
72
+ f"copy_source_idx={copy_plan[runtime_idx]}."
73
+ )
74
+ return copy_plan, source_positions
75
+
76
+
77
+ def _build_dsfp_tsfm_layers(
78
+ source_layers: List[nn.Module],
79
+ *,
80
+ target_depth: int,
81
+ ) -> nn.ModuleList:
82
+ """Keep source blocks once and fill every gap with a direct full-block copy."""
83
+ copy_plan, source_positions = _build_dsfp_tsfm_layer_plan(
84
+ source_depth=len(source_layers),
85
+ target_depth=target_depth,
86
+ )
87
+ source_position_set = set(source_positions)
88
+ layers: List[nn.Module] = []
89
+ for runtime_idx, source_idx in enumerate(copy_plan):
90
+ if runtime_idx in source_position_set:
91
+ layer = source_layers[source_idx]
92
+ else:
93
+ layer = copy.deepcopy(source_layers[source_idx])
94
+ layers.append(layer)
95
+
96
+ runtime_layers = nn.ModuleList(layers)
97
+ for param in runtime_layers.parameters():
98
+ param.requires_grad = False
99
+ return runtime_layers
100
+
101
+
102
+ def initialize_timebraid_components(
103
+ owner: nn.Module,
104
+ llm_config,
105
+ runtime_options=None,
106
+ ) -> None:
107
+ """Register TimeBraid's canonical components directly on its model owner."""
108
+ if not isinstance(owner, nn.Module):
109
+ raise TypeError(
110
+ f"owner must be an initialized nn.Module, got {type(owner).__name__}."
111
+ )
112
+ if not hasattr(owner, "_modules"):
113
+ raise RuntimeError(
114
+ "owner nn.Module.__init__ must run before component initialization."
115
+ )
116
+ self = owner
117
+ config = llm_config
118
+ options = MoTRuntimeOptions(runtime_options)
119
+ # Host (Qwen) architecture info.
120
+ self.hidden_size = int(config.hidden_size)
121
+ self.num_heads = int(config.num_attention_heads)
122
+ self.num_q_layers = int(config.num_hidden_layers)
123
+ # Some Qwen configs expose only a subset of pairable full-attention layers.
124
+ self.q_pairable_layer_indices = _resolve_pairable_q_layer_indices(
125
+ config, self.num_q_layers
126
+ )
127
+ self.q_head_dim = int(
128
+ getattr(config, "head_dim", self.hidden_size // self.num_heads)
129
+ )
130
+ self.num_kv_heads = int(getattr(config, "num_key_value_heads", self.num_heads))
131
+ if self.num_heads % self.num_kv_heads != 0:
132
+ raise ValueError(
133
+ f"Qwen heads must be divisible by kv heads, got {self.num_heads}/{self.num_kv_heads}"
134
+ )
135
+ self.num_kv_groups = self.num_heads // self.num_kv_heads
136
+
137
+ self.pairing_mode = options.value("mot_pairing_mode").strip().lower()
138
+ if self.pairing_mode not in {"interleaved", "dsfp"}:
139
+ raise ValueError(
140
+ "`mot_pairing_mode` must be 'interleaved' or 'dsfp', got "
141
+ f"{self.pairing_mode!r}."
142
+ )
143
+ self.source_num_t_layers = options.value("mot_t_layers")
144
+ self.ts_gradient_checkpointing = options.value("mot_ts_gradient_checkpointing")
145
+ self.ts_gradient_checkpointing_use_reentrant = options.value(
146
+ "mot_ts_gradient_checkpointing_use_reentrant"
147
+ )
148
+ self.patch_size = options.value("mot_patch_size")
149
+ if self.patch_size <= 0:
150
+ raise ValueError(f"`mot_patch_size` must be positive, got {self.patch_size}.")
151
+ self.dsfp = self.pairing_mode == "dsfp"
152
+ # Optional understanding separation keeps the existing generation TS
153
+ # path intact, but adds a second TS tower for user/context-side spans.
154
+ # The depth selects the source TimesFM prefix. Both towers use the same
155
+ # pairing mode so one topology field owns every Qwen/TimesFM schedule.
156
+ self.understanding_pair_depth = options.value("mot_understanding_pair_depth")
157
+ if self.understanding_pair_depth < 0:
158
+ raise ValueError(
159
+ "`mot_understanding_pair_depth` must be >= 0, got "
160
+ f"{self.understanding_pair_depth}"
161
+ )
162
+ self.understanding_separation = self.understanding_pair_depth > 0
163
+ if self.understanding_separation:
164
+ if self.understanding_pair_depth > self.source_num_t_layers:
165
+ raise ValueError(
166
+ "`mot_understanding_pair_depth` cannot exceed `mot_t_layers`, got "
167
+ f"understanding_pair_depth={self.understanding_pair_depth}, "
168
+ f"source_t_layers={self.source_num_t_layers}"
169
+ )
170
+ if self.understanding_pair_depth > self.num_q_layers:
171
+ raise ValueError(
172
+ "Understanding TS pairing expects the Qwen depth to be at least the selected "
173
+ "source prefix depth, got "
174
+ f"understanding_pair_depth={self.understanding_pair_depth}, q_layers={self.num_q_layers}"
175
+ )
176
+ self.num_t_layers = int(self.source_num_t_layers)
177
+ if self.dsfp:
178
+ if len(self.q_pairable_layer_indices) != self.num_q_layers:
179
+ raise ValueError(
180
+ "`mot_pairing_mode=dsfp` requires every Qwen layer to be pairable, but "
181
+ "some layers are excluded by `config.layer_types`: "
182
+ f"pairable={len(self.q_pairable_layer_indices)}, total={self.num_q_layers}."
183
+ )
184
+ if self.source_num_t_layers != 20:
185
+ raise ValueError(
186
+ "`mot_pairing_mode=dsfp` requires the standard 20-layer TimesFM source stack, got "
187
+ f"mot_t_layers={self.source_num_t_layers}."
188
+ )
189
+ if self.source_num_t_layers > self.num_q_layers:
190
+ raise ValueError(
191
+ "`mot_pairing_mode=dsfp` expects the source TimesFM stack to fit inside Qwen depth, got "
192
+ f"source_t_layers={self.source_num_t_layers}, q_layers={self.num_q_layers}."
193
+ )
194
+ self.num_t_layers = int(self.num_q_layers)
195
+ self.num_understanding_t_layers = (
196
+ self.num_q_layers
197
+ if self.understanding_separation and self.dsfp
198
+ else self.understanding_pair_depth
199
+ )
200
+ # Packed rows can contain many short TS spans from multiple original samples.
201
+ self.max_spans_per_sample = options.value("mot_max_spans_per_sample")
202
+ if self.max_spans_per_sample <= 0:
203
+ raise ValueError(
204
+ f"max_spans_per_sample must be positive, got {self.max_spans_per_sample}."
205
+ )
206
+ # TimesFM structural defaults (2.5-200M).
207
+ self.tsfm_num_heads = options.value("mot_tsfm_num_heads")
208
+ self.tsfm_head_dim = options.value("mot_tsfm_head_dim")
209
+ self.tsfm_hidden_size = options.value("mot_tsfm_hidden_size")
210
+ if self.tsfm_hidden_size != self.tsfm_num_heads * self.tsfm_head_dim:
211
+ raise ValueError(
212
+ "TimesFM hidden/head mismatch: "
213
+ f"hidden={self.tsfm_hidden_size}, heads={self.tsfm_num_heads}, head_dim={self.tsfm_head_dim}"
214
+ )
215
+ self.understanding_tsfm_num_heads = self.tsfm_num_heads
216
+ self.understanding_tsfm_head_dim = self.tsfm_head_dim
217
+ self.understanding_tsfm_hidden_size = self.tsfm_hidden_size
218
+ # Mixed/Qwen positions are configurable at the span boundary.
219
+ # `patch_slot` reserves one mixed slot per realized TS patch;
220
+ # `span_slot` is the historical coarse lattice where one TS span
221
+ # advances later text by one slot and all patches share anchor+1.
222
+ # Native TS rotary state resets to local 0..N-1 inside each span either
223
+ # way. The restore contract always supplies this key, so the default
224
+ # only covers hand-built option maps and must match the trained lattice.
225
+ self.mixed_position_mode = options.value("mot_mixed_position_mode").strip().lower()
226
+ if self.mixed_position_mode not in {"span_slot", "patch_slot"}:
227
+ raise ValueError(
228
+ "`mot_mixed_position_mode` must be one of {'span_slot', 'patch_slot'}, got "
229
+ f"{self.mixed_position_mode!r}."
230
+ )
231
+ self.pair_map = _build_pair_map(
232
+ num_q_layers=self.num_q_layers,
233
+ num_t_layers=self.num_t_layers,
234
+ pairing_mode=self.pairing_mode,
235
+ q_layer_indices=self.q_pairable_layer_indices,
236
+ )
237
+ if self.understanding_separation:
238
+ self.understanding_pair_map = _build_pair_map(
239
+ num_q_layers=self.num_q_layers,
240
+ num_t_layers=self.num_understanding_t_layers,
241
+ pairing_mode=self.pairing_mode,
242
+ q_layer_indices=self.q_pairable_layer_indices,
243
+ )
244
+ else:
245
+ self.understanding_pair_map = {}
246
+ self.layer_plan = build_timebraid_layer_plan(
247
+ num_llm_layers=self.num_q_layers,
248
+ generation_tsfm_by_llm_layer=self.pair_map,
249
+ understanding_tsfm_by_llm_layer=self.understanding_pair_map,
250
+ )
251
+ # The delimiter ABI is persisted in config; no tokenizer object is
252
+ # required by the numeric runtime.
253
+ self._timesfm_util = None
254
+ self.ts_open_token_id = options.value("mot_ts_open_token_id")
255
+ self.ts_close_token_id = options.value("mot_ts_close_token_id")
256
+ if self.ts_open_token_id < 0 or self.ts_close_token_id < 0:
257
+ raise ValueError("TimeBraid runtime requires persisted TS delimiter token IDs.")
258
+ if self.ts_open_token_id == self.ts_close_token_id:
259
+ raise ValueError(
260
+ "TimeBraid runtime TS delimiter token IDs must be distinct, got "
261
+ f"{self.ts_open_token_id}."
262
+ )
263
+ # Complete TimeBraid checkpoints restore the registered TimesFM towers through
264
+ # HF loading; TimeBraid inference never resolves a second expert checkpoint.
265
+ self._timesfm_target_dtype = options.value("mot_compute_dtype")
266
+ self.ts_loss_weight = options.value("mot_ts_loss_weight")
267
+ if self.ts_loss_weight < 0:
268
+ raise ValueError(f"`mot_ts_loss_weight` must be >=0, got {self.ts_loss_weight}")
269
+ self.lm_loss_weight = options.value("mot_lm_loss_weight")
270
+ if self.lm_loss_weight < 0:
271
+ raise ValueError(f"`mot_lm_loss_weight` must be >=0, got {self.lm_loss_weight}")
272
+ self.ts_roi_mse_alpha = options.value("mot_ts_roi_mse_alpha")
273
+ if self.ts_roi_mse_alpha < 0:
274
+ raise ValueError(
275
+ f"`mot_ts_roi_mse_alpha` must be >= 0, got {self.ts_roi_mse_alpha}"
276
+ )
277
+ self.ts_understanding_loss_weight = options.value(
278
+ "mot_ts_understanding_loss_weight"
279
+ )
280
+ self.has_understanding_head = options.value("has_understanding_head")
281
+ if (
282
+ not math.isfinite(self.ts_understanding_loss_weight)
283
+ or self.ts_understanding_loss_weight < 0
284
+ ):
285
+ raise ValueError(
286
+ "`mot_ts_understanding_loss_weight` must be null or a finite value >= 0, got "
287
+ f"{self.ts_understanding_loss_weight}."
288
+ )
289
+ if self.has_understanding_head and not self.understanding_separation:
290
+ raise ValueError(
291
+ "has_understanding_head requires an understanding TimesFM tower."
292
+ )
293
+ if self.ts_understanding_loss_weight > 0.0 and not self.has_understanding_head:
294
+ raise ValueError(
295
+ "ts_understanding_loss_weight > 0 requires has_understanding_head=true."
296
+ )
297
+ self.tsfm_output_patch_len = 0
298
+ self.tsfm_q_channels = 0
299
+ self.tsfm_decode_index = 0
300
+ self.tsfm_quantile_taus: List[float] = []
301
+ self.generation_tsfm: Optional[nn.Module] = None
302
+ self.understanding_tsfm: Optional[nn.Module] = None
303
+ _init_timesfm_eager(self)
304
+ if self.generation_tsfm is not None:
305
+ native_patch_size = int(self.generation_tsfm.p)
306
+ if native_patch_size <= 0:
307
+ raise RuntimeError(
308
+ f"TimesFM must expose a positive input patch size, got p={native_patch_size}."
309
+ )
310
+ if self.patch_size != native_patch_size:
311
+ raise RuntimeError(
312
+ "`mot_patch_size` must match the loaded TimesFM input patch size, got "
313
+ f"mot_patch_size={self.patch_size}, timesfm.p={native_patch_size}."
314
+ )
315
+ if self.understanding_tsfm is not None:
316
+ understanding_patch_size = int(self.understanding_tsfm.p)
317
+ if understanding_patch_size != native_patch_size:
318
+ raise RuntimeError(
319
+ "Generation and understanding TimesFM input patch sizes must match, got "
320
+ f"generation={native_patch_size}, understanding={understanding_patch_size}."
321
+ )
322
+ self.global_residual_attention = nn.ModuleDict(
323
+ {
324
+ str(step.llm_layer): GlobalResidualAttention(
325
+ language_hidden_size=self.hidden_size,
326
+ generation_hidden_size=(
327
+ self.tsfm_hidden_size
328
+ if step.generation_tsfm_layer is not None
329
+ else None
330
+ ),
331
+ understanding_hidden_size=(
332
+ self.understanding_tsfm_hidden_size
333
+ if step.understanding_tsfm_layer is not None
334
+ else None
335
+ ),
336
+ attention_heads=self.num_heads,
337
+ head_dim=self.q_head_dim,
338
+ )
339
+ for step in self.layer_plan
340
+ if step.generation_tsfm_layer is not None
341
+ or step.understanding_tsfm_layer is not None
342
+ }
343
+ )
344
+ self.packed_attention = _GlobalCausalJointSelfAttention(
345
+ q_heads=self.num_heads,
346
+ q_head_dim=self.q_head_dim,
347
+ lang_hidden_size=self.hidden_size,
348
+ )
349
+ self.understanding_head: Optional[nn.Module] = None
350
+ if self.has_understanding_head:
351
+ if self.generation_tsfm is None:
352
+ raise RuntimeError(
353
+ "TS understanding reconstruction head requires initialized TimesFM."
354
+ )
355
+ # `has_understanding_head` persists this fixed-shape topology in the HF
356
+ # config. The TSFM-hidden MLP reconstructs one native TimesFM input patch;
357
+ # `ts_understanding_loss_weight` separately weights its fixed 2:8
358
+ # time-domain/FFT objective in the routed-loss helper.
359
+ self.understanding_head = nn.Sequential(
360
+ nn.Linear(int(self.tsfm_hidden_size), int(self.tsfm_hidden_size)),
361
+ nn.GELU(),
362
+ nn.Linear(int(self.tsfm_hidden_size), native_patch_size),
363
+ )
364
+ if not any(param.is_meta for param in self.understanding_head.parameters()):
365
+ self.understanding_head.to(
366
+ device=torch.device("cpu"), dtype=self._timesfm_target_dtype
367
+ )
368
+
369
+
370
+ def _build_timesfm2p5_model():
371
+ from ..._vendor.timesfm.timesfm_2p5.timesfm_2p5_torch import (
372
+ TimesFM_2p5_200M_torch_module,
373
+ )
374
+ from ..._vendor.timesfm.torch import util as timesfm_torch_util
375
+
376
+ # TimeBraid checkpoints already contain this module's complete state. Construct
377
+ # the registered tower directly; the removed upstream Hub wrapper would
378
+ # allocate a second class-level 200M model and expose an unrelated download
379
+ # path before Transformers restores the embedded weights.
380
+ timesfm_model = TimesFM_2p5_200M_torch_module()
381
+
382
+ for param in timesfm_model.parameters():
383
+ param.requires_grad = False
384
+ timesfm_model.eval()
385
+ if not any(param.is_meta for param in timesfm_model.parameters()):
386
+ timesfm_model.to("cpu")
387
+ return timesfm_model, timesfm_torch_util
388
+
389
+
390
+ def _init_timesfm_eager(self) -> None:
391
+ # Build TimesFM immediately so the complete registered component surface exists.
392
+ if self.generation_tsfm is not None:
393
+ return
394
+
395
+ self.generation_tsfm, self._timesfm_util = _build_timesfm2p5_model()
396
+
397
+ # Validate expected source structure before optional TS-depth upsampling
398
+ # replaces the registered TimesFM layer stack.
399
+ if len(self.generation_tsfm.stacked_xf) < self.source_num_t_layers:
400
+ raise RuntimeError(
401
+ "TimesFM has fewer source layers than requested by the active MoT runtime: "
402
+ f"got {len(self.generation_tsfm.stacked_xf)}, required {self.source_num_t_layers}"
403
+ )
404
+ source_layers = list(self.generation_tsfm.stacked_xf[: self.source_num_t_layers])
405
+ if self.understanding_separation:
406
+ # Deep-copy the complete expert first, then take the understanding
407
+ # prefix from that independent object graph. DSFP deliberately keeps
408
+ # its source-position modules rather than copying them, so passing
409
+ # generation modules into the expansion helper would otherwise make
410
+ # the two registered towers share parameters and tensor storage.
411
+ understanding_model = copy.deepcopy(self.generation_tsfm)
412
+ understanding_source_layers = list(
413
+ understanding_model.stacked_xf[: self.understanding_pair_depth]
414
+ )
415
+ if self.dsfp:
416
+ understanding_layers = _build_dsfp_tsfm_layers(
417
+ understanding_source_layers,
418
+ target_depth=self.num_q_layers,
419
+ )
420
+ else:
421
+ understanding_layers = nn.ModuleList(understanding_source_layers)
422
+ for param in understanding_model.parameters():
423
+ param.requires_grad = False
424
+ for param in understanding_layers.parameters():
425
+ param.requires_grad = False
426
+ understanding_layers.eval()
427
+ if not any(param.is_meta for param in understanding_layers.parameters()):
428
+ understanding_layers.to(
429
+ device=torch.device("cpu"), dtype=self._timesfm_target_dtype
430
+ )
431
+ understanding_model.stacked_xf = understanding_layers
432
+ understanding_model.x = self.num_understanding_t_layers
433
+ understanding_model.eval()
434
+ if not any(param.is_meta for param in understanding_model.parameters()):
435
+ understanding_model.to("cpu")
436
+ self.understanding_tsfm = understanding_model
437
+ if self.dsfp:
438
+ dsfp_layers = _build_dsfp_tsfm_layers(
439
+ source_layers,
440
+ target_depth=self.num_q_layers,
441
+ )
442
+ dsfp_layers.eval()
443
+ if not any(param.is_meta for param in dsfp_layers.parameters()):
444
+ dsfp_layers.to(device=torch.device("cpu"), dtype=self._timesfm_target_dtype)
445
+ self.generation_tsfm.stacked_xf = dsfp_layers
446
+ self.generation_tsfm.x = self.num_t_layers
447
+
448
+ self.tsfm_output_patch_len = int(self.generation_tsfm.o)
449
+ self.tsfm_q_channels = int(self.generation_tsfm.q)
450
+ self.tsfm_decode_index = int(self.generation_tsfm.aridx)
451
+ quantiles = list(self.generation_tsfm.config.quantiles)
452
+ if len(quantiles) + 1 != self.tsfm_q_channels:
453
+ raise RuntimeError(
454
+ f"TimesFM quantile channel mismatch: q_channels={self.tsfm_q_channels}, quantiles={len(quantiles)}"
455
+ )
456
+ if not (0 <= self.tsfm_decode_index < self.tsfm_q_channels):
457
+ raise RuntimeError(
458
+ f"TimesFM decode index out of range: decode_idx={self.tsfm_decode_index}, q={self.tsfm_q_channels}"
459
+ )
460
+ # Build channel->tau mapping, reserving decode channel for point forecast.
461
+ quantile_iter = iter(quantiles)
462
+ channel_taus: List[float] = []
463
+ for channel_idx in range(self.tsfm_q_channels):
464
+ if channel_idx == self.tsfm_decode_index:
465
+ channel_taus.append(-1.0)
466
+ else:
467
+ channel_taus.append(float(next(quantile_iter)))
468
+ self.tsfm_quantile_taus = channel_taus
469
+
470
+
471
+ __all__ = ["initialize_timebraid_components"]
timebraid/model/mot/bridge_runtime_forecast.py ADDED
@@ -0,0 +1,1317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forecast materialization and loss helpers for TimeBraid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional
7
+
8
+ import torch
9
+
10
+ from .structures import (
11
+ ROLE_TARGET,
12
+ TS_ROUTE_GENERATION,
13
+ TS_ROUTE_UNDERSTANDING,
14
+ build_mot_forecast_loss_accounting,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from .bridge_runtime_mot import _MoTForecastTarget, _MoTRuntime
19
+
20
+ _FP32_SINH_LIMIT = math.asinh(torch.finfo(torch.float32).max)
21
+ _MOT_TS_GLOBAL_LOSS_DETACHED_CAP = 2.0
22
+ _MOT_TS_ROI_LOSS_DETACHED_CAP = 2.0
23
+
24
+
25
+ class _FlatForecastLossParts(NamedTuple):
26
+ point: torch.Tensor
27
+ quantile: torch.Tensor
28
+ global_point: torch.Tensor
29
+ global_quantile: torch.Tensor
30
+ roi_point: torch.Tensor
31
+ roi_weighted_point: torch.Tensor
32
+ global_uncapped: torch.Tensor
33
+ global_cap_scale: torch.Tensor
34
+ roi_uncapped: torch.Tensor
35
+ roi_weighted_uncapped: torch.Tensor
36
+ roi_cap_scale: torch.Tensor
37
+ roi_supervised_points: torch.Tensor
38
+ roi_supervised_targets: torch.Tensor
39
+
40
+
41
+ def _finite_stats(tensor: torch.Tensor) -> str:
42
+ """Return compact finite-value stats for hard forecast diagnostics."""
43
+ detached = tensor.detach()
44
+ finite = detached[torch.isfinite(detached)]
45
+ if finite.numel() == 0:
46
+ return f"shape={tuple(detached.shape)}, dtype={detached.dtype}, finite=0"
47
+ finite_f32 = finite.to(dtype=torch.float32)
48
+ return (
49
+ f"shape={tuple(detached.shape)}, dtype={detached.dtype}, "
50
+ f"finite={int(finite.numel())}/{int(detached.numel())}, "
51
+ f"min={float(finite_f32.min().item()):.6g}, "
52
+ f"max={float(finite_f32.max().item()):.6g}, "
53
+ f"absmax={float(finite_f32.abs().max().item()):.6g}"
54
+ )
55
+
56
+
57
+ def _first_bad_index(mask: torch.Tensor) -> tuple[int, ...]:
58
+ bad = torch.nonzero(mask, as_tuple=False)
59
+ if int(bad.numel()) == 0:
60
+ return ()
61
+ return tuple(int(value) for value in bad[0].detach().cpu().tolist())
62
+
63
+
64
+ def _target_debug_record(target: Any) -> str:
65
+ return (
66
+ f"sample={int(target.sample_idx)}, slot={int(target.slot_idx)}, "
67
+ f"role={int(target.role_id)}, segment={int(target.segment_id)}, "
68
+ f"ts_range=[{int(target.ts_start)},{int(target.ts_end)}), "
69
+ f"loss_start={int(target.loss_start_idx)}, "
70
+ f"generation_start={int(target.generation_start_index)}"
71
+ )
72
+
73
+
74
+ def _require_finite_tensor(
75
+ tensor: torch.Tensor, *, label: str, targets: List[Any]
76
+ ) -> None:
77
+ if bool(torch.all(torch.isfinite(tensor))):
78
+ return
79
+ bad_index = _first_bad_index(torch.logical_not(torch.isfinite(tensor)))
80
+ target_text = ""
81
+ if bad_index:
82
+ target_axis = int(bad_index[0]) if len(bad_index) >= 1 else -1
83
+ if 0 <= target_axis < len(targets):
84
+ target_text = f", target=({_target_debug_record(targets[target_axis])})"
85
+ raise RuntimeError(
86
+ f"Forecast tensor became non-finite at {label}: "
87
+ f"bad_index={bad_index}, {_finite_stats(tensor)}{target_text}."
88
+ )
89
+
90
+
91
+ def _require_sinh_safe(
92
+ tensor: torch.Tensor,
93
+ *,
94
+ targets: List[Any],
95
+ backend_label: str,
96
+ target_axis: int = 0,
97
+ ) -> None:
98
+ """Raise loudly when normalized-space head outputs would overflow fp32 sinh."""
99
+ unsafe = tensor.detach().to(dtype=torch.float32).abs() > _FP32_SINH_LIMIT
100
+ if not bool(torch.any(unsafe)):
101
+ return
102
+ bad_index = _first_bad_index(unsafe)
103
+ target_text = ""
104
+ if bad_index and 0 <= int(target_axis) < len(bad_index):
105
+ bad_target = int(bad_index[int(target_axis)])
106
+ if 0 <= bad_target < len(targets):
107
+ target_text = f", target=({_target_debug_record(targets[bad_target])})"
108
+ raise RuntimeError(
109
+ f"{backend_label} head output would overflow fp32 sinh during forecast "
110
+ f"inverse transform: bad_index={bad_index}, "
111
+ f"limit={_FP32_SINH_LIMIT:.6g}, "
112
+ f"value={float(tensor[bad_index].detach().to(torch.float32).item()):.6g}, "
113
+ f"{_finite_stats(tensor)}{target_text}."
114
+ )
115
+
116
+
117
+ def _point_forecast_loss_values(
118
+ pred_point: torch.Tensor, target: torch.Tensor
119
+ ) -> torch.Tensor:
120
+ """Compute elementwise MSE on the TimesFM decode channel."""
121
+ pred_point = pred_point.to(dtype=torch.float32)
122
+ target = target.to(device=pred_point.device, dtype=torch.float32)
123
+ return (pred_point - target).square()
124
+
125
+
126
+ def _detached_per_item_loss_cap_scale(
127
+ losses: torch.Tensor, cap_value: float
128
+ ) -> torch.Tensor:
129
+ """Return detached per-item cap scales for already-normalized record losses."""
130
+ if losses.ndim != 1:
131
+ raise RuntimeError(
132
+ f"Per-item loss cap expects a rank-1 tensor, got {tuple(losses.shape)}."
133
+ )
134
+ losses = losses.to(dtype=torch.float32)
135
+ if float(cap_value) <= 0.0:
136
+ return torch.ones_like(losses)
137
+ return torch.clamp(
138
+ losses.new_tensor(float(cap_value)) / losses.detach().clamp_min(1e-12), max=1.0
139
+ )
140
+
141
+
142
+ def _apply_timesfm_value_transform(values: torch.Tensor) -> torch.Tensor:
143
+ """Apply the fixed post-scale asinh transform before TimesFM consumes values."""
144
+ return torch.asinh(values)
145
+
146
+
147
+ def _inverse_timesfm_value_transform(
148
+ values: torch.Tensor,
149
+ *,
150
+ targets: Optional[List[Any]] = None,
151
+ backend_label: str = "TS value transform",
152
+ target_axis: int = 0,
153
+ ) -> torch.Tensor:
154
+ """Invert the fixed asinh transform and fail before fp32 sinh overflow."""
155
+ _require_sinh_safe(
156
+ values,
157
+ targets=[] if targets is None else targets,
158
+ backend_label=backend_label,
159
+ target_axis=target_axis,
160
+ )
161
+ return values.to(dtype=torch.float32).sinh()
162
+
163
+
164
+ def _transform_forecast_targets_for_loss(
165
+ targets: torch.Tensor,
166
+ *,
167
+ owner_mu: torch.Tensor,
168
+ owner_sigma: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """Move raw forecast targets into the same scaled value frame as tower predictions."""
171
+ scaled = (
172
+ targets.to(dtype=torch.float32) - owner_mu.unsqueeze(1)
173
+ ) / owner_sigma.unsqueeze(1).clamp_min(1e-6)
174
+ return _apply_timesfm_value_transform(scaled)
175
+
176
+
177
+ def _global_count_tensor(
178
+ global_count: Optional[object],
179
+ *,
180
+ reference: torch.Tensor,
181
+ label: str,
182
+ ) -> Optional[torch.Tensor]:
183
+ if global_count is None:
184
+ return None
185
+ count = torch.as_tensor(
186
+ global_count, device=reference.device, dtype=reference.dtype
187
+ )
188
+ if int(count.numel()) != 1:
189
+ raise RuntimeError(
190
+ f"{label} denominator must be scalar, got shape={tuple(count.shape)}."
191
+ )
192
+ count = count.reshape(())
193
+ if bool(count <= 0):
194
+ raise RuntimeError(
195
+ f"{label} denominator must be positive, got {float(count.detach().cpu().item())}."
196
+ )
197
+ return count
198
+
199
+
200
+ def _reduce_per_record_loss(
201
+ per_record: torch.Tensor,
202
+ *,
203
+ global_count: Optional[object],
204
+ label: str,
205
+ ) -> torch.Tensor:
206
+ if per_record.ndim != 1:
207
+ raise RuntimeError(
208
+ f"{label} reduction expects a rank-1 per-record tensor, got {tuple(per_record.shape)}."
209
+ )
210
+ if int(per_record.numel()) == 0:
211
+ raise RuntimeError(f"{label} reduction requires at least one local record.")
212
+ per_record = per_record.to(dtype=torch.float32)
213
+ count = _global_count_tensor(global_count, reference=per_record, label=label)
214
+ if count is None:
215
+ return per_record.mean()
216
+ return per_record.sum() / count
217
+
218
+
219
+ def _global_count_for_horizon(
220
+ counts_by_horizon: Optional[object], horizon: int
221
+ ) -> Optional[object]:
222
+ if counts_by_horizon is None:
223
+ return None
224
+ if isinstance(counts_by_horizon, dict):
225
+ if int(horizon) not in counts_by_horizon:
226
+ raise RuntimeError(
227
+ "TS global denominator mapping is missing a forecast horizon: "
228
+ f"horizon={int(horizon)}, available={sorted(int(key) for key in counts_by_horizon.keys())}."
229
+ )
230
+ return counts_by_horizon[int(horizon)]
231
+ return counts_by_horizon
232
+
233
+
234
+ def _roi_loss_from_value_losses(
235
+ *,
236
+ value_losses: torch.Tensor,
237
+ roi_records: torch.Tensor,
238
+ keep_mask: torch.Tensor,
239
+ record_target_idx: torch.Tensor,
240
+ target_count: int,
241
+ global_target_count: Optional[object] = None,
242
+ ) -> tuple[torch.Tensor, torch.Tensor]:
243
+ """Reduce a per-value loss over ROI points, normalized inside each target span."""
244
+ if value_losses.ndim != 2:
245
+ raise RuntimeError(
246
+ f"ROI loss expects per-value losses [record,horizon], got {tuple(value_losses.shape)}."
247
+ )
248
+ if roi_records.shape != value_losses.shape or keep_mask.shape != value_losses.shape:
249
+ raise RuntimeError(
250
+ "ROI loss expects mask/value shapes to match: "
251
+ f"loss={tuple(value_losses.shape)}, roi={tuple(roi_records.shape)}, keep={tuple(keep_mask.shape)}."
252
+ )
253
+
254
+ value_losses = value_losses.to(dtype=torch.float32)
255
+
256
+ # ROI is a sparse data-side reweighting over already-supervised points. We
257
+ # normalize within each target before averaging target spans so `alpha`
258
+ # means "add one ROI objective" rather than "scale with the number of ROI
259
+ # points or the forecast horizon".
260
+ roi_mask_float = (roi_records * keep_mask).to(dtype=torch.float32)
261
+ roi_den_by_target = torch.zeros(
262
+ (target_count,), device=value_losses.device, dtype=torch.float32
263
+ )
264
+ roi_num_by_target = torch.zeros(
265
+ (target_count,), device=value_losses.device, dtype=torch.float32
266
+ )
267
+ roi_den_by_target.scatter_add_(0, record_target_idx, roi_mask_float.sum(dim=1))
268
+ if not bool(torch.any(roi_den_by_target > 0.0)):
269
+ zero = value_losses.new_zeros(())
270
+ return zero, value_losses.new_zeros((0,))
271
+
272
+ roi_num_by_target.scatter_add_(
273
+ 0,
274
+ record_target_idx,
275
+ (value_losses.to(dtype=torch.float32) * roi_mask_float).sum(dim=1),
276
+ )
277
+ active_roi_targets = roi_den_by_target > 0.0
278
+ roi_per_target = (
279
+ roi_num_by_target[active_roi_targets] / roi_den_by_target[active_roi_targets]
280
+ )
281
+ reduced_roi_loss = _reduce_per_record_loss(
282
+ roi_per_target,
283
+ global_count=global_target_count,
284
+ label="ROI target",
285
+ )
286
+ return reduced_roi_loss, roi_per_target
287
+
288
+
289
+ def _roi_supervision_counts(
290
+ *,
291
+ roi_records: torch.Tensor,
292
+ keep_mask: torch.Tensor,
293
+ record_target_idx: torch.Tensor,
294
+ target_count: int,
295
+ ) -> tuple[torch.Tensor, torch.Tensor]:
296
+ """Count ROI supervision after intersecting data masks with active loss positions."""
297
+ if roi_records.ndim != 2:
298
+ raise RuntimeError(
299
+ f"ROI supervision count expects [record,horizon] ROI masks, got {tuple(roi_records.shape)}."
300
+ )
301
+ if keep_mask.shape != roi_records.shape:
302
+ raise RuntimeError(
303
+ "ROI supervision count expects keep/ROI shapes to match: "
304
+ f"roi={tuple(roi_records.shape)}, keep={tuple(keep_mask.shape)}."
305
+ )
306
+ if record_target_idx.ndim != 1 or int(record_target_idx.shape[0]) != int(
307
+ roi_records.shape[0]
308
+ ):
309
+ raise RuntimeError(
310
+ "ROI supervision count expects one target index per record, got "
311
+ f"targets={tuple(record_target_idx.shape)}, roi_records={tuple(roi_records.shape)}."
312
+ )
313
+ if target_count < 0:
314
+ raise RuntimeError(
315
+ f"ROI supervision count requires non-negative target_count, got {target_count}."
316
+ )
317
+
318
+ roi_keep = torch.logical_and(roi_records > 0.0, keep_mask.to(dtype=torch.bool))
319
+ roi_points = roi_keep.to(dtype=torch.float32).sum()
320
+ if target_count == 0:
321
+ return roi_points, roi_records.new_zeros((), dtype=torch.float32)
322
+ roi_den_by_target = torch.zeros(
323
+ (target_count,), device=roi_records.device, dtype=torch.float32
324
+ )
325
+ roi_den_by_target.scatter_add_(
326
+ 0, record_target_idx, roi_keep.to(dtype=torch.float32).sum(dim=1)
327
+ )
328
+ roi_targets = (roi_den_by_target > 0.0).to(dtype=torch.float32).sum()
329
+ return roi_points, roi_targets
330
+
331
+
332
+ def _roi_value_losses_from_predictions(
333
+ *,
334
+ pred_records: torch.Tensor,
335
+ gt_records: torch.Tensor,
336
+ decode_index: int,
337
+ ) -> torch.Tensor:
338
+ """Build per-value ROI MSE on the TimesFM decode channel."""
339
+ pred_records = pred_records.to(dtype=torch.float32)
340
+ gt_records = gt_records.to(device=pred_records.device, dtype=torch.float32)
341
+ decode_index = int(decode_index)
342
+ if decode_index < 0 or decode_index >= int(pred_records.shape[-1]):
343
+ raise RuntimeError(
344
+ "ROI point loss decode index is outside the forecast channel axis: "
345
+ f"decode_index={decode_index}, q_channels={int(pred_records.shape[-1])}."
346
+ )
347
+ return _point_forecast_loss_values(
348
+ pred_records[:, :, decode_index],
349
+ gt_records,
350
+ )
351
+
352
+
353
+ def _require_known_output_space(output_space: str) -> str:
354
+ output_space = str(output_space).strip().lower()
355
+ if output_space not in {"real", "normalized"}:
356
+ raise RuntimeError(
357
+ f"Forecast output space must be 'real' or 'normalized', got {output_space!r}."
358
+ )
359
+ return output_space
360
+
361
+
362
+ def _predict_mot_span_quantiles_batched(
363
+ self,
364
+ *,
365
+ mot_runtime: "_MoTRuntime",
366
+ targets: List["_MoTForecastTarget"],
367
+ output_space: str = "real",
368
+ ) -> tuple[torch.Tensor, List[int]]:
369
+ """Run one numeric-head projection per non-empty TS-route sub-batch.
370
+
371
+ `output_space="real"` applies the full inverse transform (sinh, then RevIN
372
+ reverse) for generation/eval; `"normalized"` returns the tower-space head
373
+ output for normalized-space training losses.
374
+ """
375
+ output_space = _require_known_output_space(output_space)
376
+ if self.generation_tsfm is None or self._timesfm_util is None:
377
+ raise RuntimeError("TimesFM model/util is not initialized.")
378
+
379
+ output_patch_len = int(self.tsfm_output_patch_len)
380
+ q_channels = int(self.tsfm_q_channels)
381
+ if not targets:
382
+ return mot_runtime.ts_hidden.new_empty((0, 0, output_patch_len, q_channels)), []
383
+
384
+ # Group targets by explicit TS route so each uses its matching registered
385
+ # numeric head, then restore caller order for downstream loss or decoding.
386
+ predictions_by_target: list[Optional[torch.Tensor]] = [None] * len(targets)
387
+ mu_by_target: list[Optional[torch.Tensor]] = [None] * len(targets)
388
+ sigma_by_target: list[Optional[torch.Tensor]] = [None] * len(targets)
389
+ patch_counts = [0] * len(targets)
390
+ grouped_indices: dict[int, list[int]] = {
391
+ int(TS_ROUTE_GENERATION): [],
392
+ int(TS_ROUTE_UNDERSTANDING): [],
393
+ }
394
+ for target_idx, target in enumerate(targets):
395
+ route_id = int(target.ts_route_id)
396
+ if route_id not in grouped_indices:
397
+ raise RuntimeError(
398
+ f"Packed forecast head cannot read from TS route id {route_id}."
399
+ )
400
+ grouped_indices[route_id].append(target_idx)
401
+
402
+ for route_id, target_indices in grouped_indices.items():
403
+ if not target_indices:
404
+ continue
405
+ route_targets = [targets[index] for index in target_indices]
406
+ batched_hidden, context_mu, context_sigma, route_patch_counts = (
407
+ _collect_batched_forecast_head_inputs(
408
+ self,
409
+ mot_runtime=mot_runtime,
410
+ targets=route_targets,
411
+ )
412
+ )
413
+ route_timesfm = (
414
+ self.generation_tsfm
415
+ if route_id == int(TS_ROUTE_GENERATION)
416
+ else self.understanding_tsfm
417
+ )
418
+ if route_timesfm is None:
419
+ route_name = (
420
+ "generation"
421
+ if route_id == int(TS_ROUTE_GENERATION)
422
+ else "understanding"
423
+ )
424
+ raise RuntimeError(
425
+ f"{route_name.capitalize()} routed forecast target requires its TimesFM expert."
426
+ )
427
+ out_proj = route_timesfm.output_projection_point
428
+ out_dtype = next(out_proj.parameters()).dtype
429
+ route_outputs = out_proj(batched_hidden.to(dtype=out_dtype)).reshape(
430
+ len(route_targets),
431
+ -1,
432
+ output_patch_len,
433
+ q_channels,
434
+ )
435
+ if int(route_outputs.shape[1]) != max(route_patch_counts):
436
+ raise RuntimeError(
437
+ "Batched TimesFM forecast head produced an unexpected patch axis: "
438
+ f"route={route_id}, output_patches={route_outputs.shape[1]}, "
439
+ f"expected={max(route_patch_counts)}."
440
+ )
441
+ for route_row, target_idx in enumerate(target_indices):
442
+ patch_count = int(route_patch_counts[route_row])
443
+ predictions_by_target[target_idx] = route_outputs[route_row, :patch_count]
444
+ mu_by_target[target_idx] = context_mu[route_row, :patch_count]
445
+ sigma_by_target[target_idx] = context_sigma[route_row, :patch_count]
446
+ patch_counts[target_idx] = patch_count
447
+
448
+ if any(prediction is None for prediction in predictions_by_target):
449
+ raise RuntimeError("Batched TimesFM forecast head left an unprojected target.")
450
+ normed_batched = torch.nn.utils.rnn.pad_sequence(
451
+ [prediction for prediction in predictions_by_target if prediction is not None],
452
+ batch_first=True,
453
+ )
454
+ if output_space == "normalized":
455
+ return normed_batched, patch_counts
456
+ else:
457
+ # The TimesFM tower consumes asinh(RevIN(x)), so sinh must run before
458
+ # the RevIN reverse transform.
459
+ normed_outputs = normed_batched.reshape(
460
+ len(targets), -1, output_patch_len * q_channels
461
+ )
462
+ real_outputs = _inverse_timesfm_value_transform(
463
+ normed_outputs,
464
+ targets=targets,
465
+ backend_label="TimesFM",
466
+ )
467
+ _require_finite_tensor(
468
+ real_outputs,
469
+ label="inverse_timesfm_value_transform(timesfm_head)",
470
+ targets=targets,
471
+ )
472
+ context_mu = torch.nn.utils.rnn.pad_sequence(
473
+ [value for value in mu_by_target if value is not None],
474
+ batch_first=True,
475
+ )
476
+ context_sigma = torch.nn.utils.rnn.pad_sequence(
477
+ [value for value in sigma_by_target if value is not None],
478
+ batch_first=True,
479
+ padding_value=1.0,
480
+ )
481
+ head_outputs = self._timesfm_util.revin(
482
+ real_outputs,
483
+ context_mu.to(device=real_outputs.device, dtype=real_outputs.dtype),
484
+ context_sigma.to(device=real_outputs.device, dtype=real_outputs.dtype),
485
+ reverse=True,
486
+ )
487
+ forecast_batched = head_outputs.reshape(
488
+ len(targets),
489
+ -1,
490
+ output_patch_len,
491
+ q_channels,
492
+ )
493
+ if int(forecast_batched.shape[1]) != max(patch_counts):
494
+ raise RuntimeError(
495
+ "Batched TimesFM forecast head produced an unexpected patch axis: "
496
+ f"output_patches={forecast_batched.shape[1]}, expected={max(patch_counts)}"
497
+ )
498
+ return forecast_batched, patch_counts
499
+
500
+
501
+ def _collect_batched_forecast_head_inputs(
502
+ self,
503
+ *,
504
+ mot_runtime: "_MoTRuntime",
505
+ targets: List["_MoTForecastTarget"],
506
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, List[int]]:
507
+ """Pad TS hidden states and RevIN stats for a batched forecast head call."""
508
+ hidden_slices: List[torch.Tensor] = []
509
+ context_mu_parts: List[torch.Tensor] = []
510
+ context_sigma_parts: List[torch.Tensor] = []
511
+ patch_counts: List[int] = []
512
+ for target_idx, target in enumerate(targets):
513
+ if target.ts_end < target.ts_start:
514
+ raise RuntimeError(
515
+ "Packed forecast target has negative TS slice: "
516
+ f"target_idx={target_idx}, start={target.ts_start}, end={target.ts_end}"
517
+ )
518
+
519
+ patch_count = int(target.ts_end) - int(target.ts_start)
520
+ if patch_count <= 0:
521
+ raise RuntimeError(
522
+ "Batched packed forecast head requires every supervised target to have at least "
523
+ f"one TS patch, got target_idx={target_idx}, patches={patch_count}."
524
+ )
525
+ if patch_count != int(target.patch_valid_lengths.shape[0]):
526
+ raise RuntimeError(
527
+ "Packed TimesFM patch count mismatch before batched forecast head: "
528
+ f"target_idx={target_idx}, hidden_patches={patch_count}, "
529
+ f"valid_len_patches={target.patch_valid_lengths.shape[0]}"
530
+ )
531
+
532
+ context_mu = target.context_mu.reshape(-1)
533
+ context_sigma = target.context_sigma.reshape(-1)
534
+ if (
535
+ int(context_mu.shape[0]) != patch_count
536
+ or int(context_sigma.shape[0]) != patch_count
537
+ ):
538
+ raise RuntimeError(
539
+ "Packed TimesFM RevIN stats must align one value per target patch: "
540
+ f"target_idx={target_idx}, patches={patch_count}, "
541
+ f"mu={tuple(target.context_mu.shape)}, sigma={tuple(target.context_sigma.shape)}"
542
+ )
543
+
544
+ route_id = int(target.ts_route_id)
545
+ if route_id == int(TS_ROUTE_UNDERSTANDING):
546
+ source_hidden = mot_runtime.understanding_hidden
547
+ if source_hidden is None:
548
+ raise RuntimeError(
549
+ "Understanding routed forecast target requires `mot_runtime.understanding_hidden`."
550
+ )
551
+ elif route_id == int(TS_ROUTE_GENERATION):
552
+ source_hidden = mot_runtime.ts_hidden
553
+ else:
554
+ raise RuntimeError(
555
+ f"Packed forecast head cannot read from TS route id {route_id}."
556
+ )
557
+
558
+ hidden_slices.append(source_hidden[target.ts_start : target.ts_end])
559
+ context_mu_parts.append(context_mu.to(device=source_hidden.device))
560
+ context_sigma_parts.append(context_sigma.to(device=source_hidden.device))
561
+ patch_counts.append(patch_count)
562
+
563
+ batched_hidden = torch.nn.utils.rnn.pad_sequence(hidden_slices, batch_first=True)
564
+ context_mu = torch.nn.utils.rnn.pad_sequence(context_mu_parts, batch_first=True)
565
+ context_sigma = torch.nn.utils.rnn.pad_sequence(
566
+ context_sigma_parts,
567
+ batch_first=True,
568
+ padding_value=1.0,
569
+ )
570
+ return batched_hidden, context_mu, context_sigma, patch_counts
571
+
572
+
573
+ def _compute_flat_forecast_losses_from_predictions(
574
+ self,
575
+ *,
576
+ pred_quantiles_batched: torch.Tensor,
577
+ targets: List[Any],
578
+ patch_counts: List[int],
579
+ mot_ts_num_records_in_batch: Optional[object] = None,
580
+ mot_ts_num_roi_targets_in_batch: Optional[object] = None,
581
+ loss_space: str = "real",
582
+ ) -> Optional[_FlatForecastLossParts]:
583
+ """
584
+ Reduce all patch forecasts in one masked tensor while preserving patch-level weighting.
585
+
586
+ The previous path launched point/quantile loss kernels once per patch. This
587
+ helper builds one flat `[num_patch_forecasts, horizon]` mask and performs the
588
+ loss reductions in batched tensor ops. Each record is owned by patch i and
589
+ aligns it to the raw suffix immediately after that patch; detached per-record
590
+ caps may rescale its final objective contribution.
591
+
592
+ Predictions must be in the tower-normalized space (producer called with
593
+ `output_space="normalized"`); targets are scored against
594
+ `asinh((gt - mu) / sigma)` built from each record's owner-patch causal stats.
595
+ `loss_space` accepts only `"normalized"`; the `"real"` default exists so a
596
+ caller that forgets to state the space fails loudly instead of silently
597
+ picking a value frame.
598
+ """
599
+ loss_space = _require_known_output_space(loss_space)
600
+ if loss_space != "normalized":
601
+ raise RuntimeError("Routed TS losses must use loss_space='normalized'.")
602
+ if pred_quantiles_batched.ndim != 4:
603
+ raise RuntimeError(
604
+ "Flat forecast loss expects predictions shaped [target, patch, horizon, channel], got "
605
+ f"{tuple(pred_quantiles_batched.shape)}"
606
+ )
607
+ if int(pred_quantiles_batched.shape[0]) != len(targets):
608
+ raise RuntimeError(
609
+ "Flat forecast loss target count mismatch: "
610
+ f"pred_targets={pred_quantiles_batched.shape[0]}, targets={len(targets)}"
611
+ )
612
+ if len(patch_counts) != len(targets):
613
+ raise RuntimeError(
614
+ "Flat forecast loss patch-count metadata mismatch: "
615
+ f"patch_counts={len(patch_counts)}, targets={len(targets)}"
616
+ )
617
+
618
+ device = pred_quantiles_batched.device
619
+ dtype = torch.float32
620
+ max_pred_patches = int(pred_quantiles_batched.shape[1])
621
+ forecast_horizon = int(pred_quantiles_batched.shape[2])
622
+ q_channels = int(pred_quantiles_batched.shape[3])
623
+
624
+ values_by_target: List[torch.Tensor] = []
625
+ roi_masks_by_target: List[torch.Tensor] = []
626
+ patch_length_parts: List[torch.Tensor] = []
627
+ context_mu_parts: List[torch.Tensor] = []
628
+ context_sigma_parts: List[torch.Tensor] = []
629
+ value_lengths: List[int] = []
630
+ loss_start_idxs: List[int] = []
631
+
632
+ for target_idx, target in enumerate(targets):
633
+ pred_patch_count = int(patch_counts[target_idx])
634
+ if pred_patch_count < 0 or pred_patch_count > max_pred_patches:
635
+ raise RuntimeError(
636
+ "Flat forecast loss received invalid per-target patch count: "
637
+ f"target_idx={target_idx}, patches={pred_patch_count}, max_patches={max_pred_patches}"
638
+ )
639
+ context_mu = target.context_mu.reshape(-1).to(
640
+ device=device, dtype=torch.float32
641
+ )
642
+ context_sigma = target.context_sigma.reshape(-1).to(
643
+ device=device, dtype=torch.float32
644
+ )
645
+ if (
646
+ int(context_mu.shape[0]) != pred_patch_count
647
+ or int(context_sigma.shape[0]) != pred_patch_count
648
+ ):
649
+ raise RuntimeError(
650
+ "Normalized-space forecast loss stats must align one value per owner patch: "
651
+ f"target_idx={target_idx}, patches={pred_patch_count}, "
652
+ f"mu={tuple(target.context_mu.shape)}, sigma={tuple(target.context_sigma.shape)}."
653
+ )
654
+ context_mu_parts.append(context_mu)
655
+ context_sigma_parts.append(context_sigma)
656
+
657
+ if target.values.ndim != 1:
658
+ raise RuntimeError(
659
+ "Flat forecast loss expects raw TS values to be one-dimensional, got "
660
+ f"target_idx={target_idx}, values_shape={tuple(target.values.shape)}"
661
+ )
662
+ values_len = int(target.values.shape[0])
663
+ if pred_patch_count != int(target.patch_valid_lengths.shape[0]):
664
+ raise RuntimeError(
665
+ "Flat forecast loss patch count mismatch with valid lengths: "
666
+ f"target_idx={target_idx}, pred_patches={pred_patch_count}, "
667
+ f"valid_len_patches={target.patch_valid_lengths.shape[0]}"
668
+ )
669
+ if pred_patch_count == 0:
670
+ raise RuntimeError(
671
+ "Routed TS supervision requires at least one real TS patch per span."
672
+ )
673
+
674
+ # Raw targets must stay FP32 until after causal normalization. Casting
675
+ # large-offset series to BF16 here can erase the complete future delta.
676
+ values_by_target.append(target.values.to(device=device, dtype=torch.float32))
677
+ target_roi_mask = target.loss_roi_mask
678
+ if target_roi_mask is None:
679
+ target_roi_mask = torch.zeros_like(target.values, dtype=torch.float32)
680
+ target_roi_mask = target_roi_mask.to(device=device, dtype=torch.float32)
681
+ if target_roi_mask.ndim != 1 or int(target_roi_mask.shape[0]) != values_len:
682
+ raise RuntimeError(
683
+ "Flat forecast loss expects loss_roi_mask to align with raw TS values, got "
684
+ f"target_idx={target_idx}, mask_shape={tuple(target_roi_mask.shape)}, values_len={values_len}."
685
+ )
686
+ if bool(
687
+ torch.any(torch.logical_or(target_roi_mask < 0.0, target_roi_mask > 1.0))
688
+ ):
689
+ raise RuntimeError(
690
+ "Flat forecast loss expects loss_roi_mask values in [0, 1], got "
691
+ f"target_idx={target_idx}."
692
+ )
693
+ roi_masks_by_target.append(target_roi_mask)
694
+ patch_length_parts.append(
695
+ target.patch_valid_lengths.to(device=device, dtype=torch.long)
696
+ )
697
+ value_lengths.append(values_len)
698
+ loss_start_idx = int(target.loss_start_idx)
699
+ loss_start_idxs.append(loss_start_idx)
700
+
701
+ if not values_by_target:
702
+ return None
703
+
704
+ values_padded = torch.nn.utils.rnn.pad_sequence(values_by_target, batch_first=True)
705
+ roi_masks_padded = torch.nn.utils.rnn.pad_sequence(
706
+ roi_masks_by_target, batch_first=True
707
+ )
708
+ patch_lengths = torch.nn.utils.rnn.pad_sequence(
709
+ patch_length_parts, batch_first=True
710
+ )
711
+ context_mu_padded = torch.nn.utils.rnn.pad_sequence(
712
+ context_mu_parts, batch_first=True
713
+ )
714
+ context_sigma_padded = torch.nn.utils.rnn.pad_sequence(
715
+ context_sigma_parts, batch_first=True, padding_value=1.0
716
+ )
717
+ max_values_len = int(values_padded.shape[1])
718
+ if max_values_len <= 0:
719
+ raise RuntimeError(
720
+ "Flat forecast loss built supervision records without any raw target values."
721
+ )
722
+ if int(patch_lengths.shape[1]) != max_pred_patches:
723
+ raise RuntimeError(
724
+ "Flat forecast loss patch-length padding axis does not match predictions: "
725
+ f"patch_lengths={patch_lengths.shape[1]}, pred_patches={max_pred_patches}"
726
+ )
727
+
728
+ patch_counts_tensor = torch.tensor(patch_counts, device=device, dtype=torch.long)
729
+ values_lengths_tensor = torch.tensor(value_lengths, device=device, dtype=torch.long)
730
+ loss_start_idxs_tensor = torch.tensor(
731
+ loss_start_idxs, device=device, dtype=torch.long
732
+ )
733
+ prefix_ends = torch.cumsum(patch_lengths, dim=1)
734
+ last_patch_indices = torch.clamp(patch_counts_tensor - 1, min=0)
735
+ covered_lengths = prefix_ends.gather(1, last_patch_indices[:, None]).squeeze(1)
736
+ patch_axis = torch.arange(
737
+ max_pred_patches, device=device, dtype=torch.long
738
+ ).unsqueeze(0)
739
+ successor_patch_mask = patch_axis < (patch_counts_tensor - 1).unsqueeze(1)
740
+ bad_contract = torch.logical_or(patch_counts_tensor <= 0, patch_lengths[:, 0] <= 0)
741
+ bad_contract = torch.logical_or(
742
+ bad_contract, covered_lengths != values_lengths_tensor
743
+ )
744
+ if max_pred_patches > 1:
745
+ bad_successors = torch.any(
746
+ torch.logical_and(patch_lengths[:, 1:] <= 0, successor_patch_mask[:, :-1]),
747
+ dim=1,
748
+ )
749
+ bad_contract = torch.logical_or(bad_contract, bad_successors)
750
+ if bool(torch.any(bad_contract)):
751
+ bad_idx = int(torch.nonzero(bad_contract, as_tuple=False)[0].item())
752
+ raise RuntimeError(
753
+ "Packed routed TS supervision received an invalid patch/value contract: "
754
+ f"target_idx={bad_idx}, patches={int(patch_counts[bad_idx])}, "
755
+ f"covered_raw_points={int(covered_lengths[bad_idx].item())}, "
756
+ f"raw_values={int(values_lengths_tensor[bad_idx].item())}."
757
+ )
758
+
759
+ decode_index = int(self.tsfm_decode_index)
760
+ if decode_index < 0 or decode_index >= q_channels:
761
+ raise RuntimeError(
762
+ "TS forecast decode index is outside the forecast channel axis: "
763
+ f"decode_index={decode_index}, q_channels={q_channels}"
764
+ )
765
+ accounting = build_mot_forecast_loss_accounting(
766
+ patch_lengths=patch_lengths,
767
+ patch_counts=patch_counts_tensor,
768
+ value_lengths=values_lengths_tensor,
769
+ loss_start_idxs=loss_start_idxs_tensor,
770
+ forecast_horizon=forecast_horizon,
771
+ )
772
+ if accounting is None:
773
+ return None
774
+
775
+ keep_mask = accounting.keep_mask
776
+ record_target_idx = accounting.record_target_idx
777
+ record_patch_idx = accounting.record_patch_idx
778
+ raw_indices = accounting.raw_indices
779
+ valid_counts = accounting.valid_counts.to(dtype=dtype)
780
+ if bool(torch.any(valid_counts <= 0)):
781
+ raise RuntimeError(
782
+ "Flat forecast loss built a record with no supervised points."
783
+ )
784
+ if bool(torch.any(torch.logical_and(keep_mask, raw_indices >= max_values_len))):
785
+ raise RuntimeError(
786
+ "Flat forecast loss built raw target indices outside the padded target width."
787
+ )
788
+
789
+ # Head projections may run in BF16, but every target transform and loss
790
+ # reduction is FP32. The cast remains differentiable back to the head.
791
+ pred_records = pred_quantiles_batched[record_target_idx, record_patch_idx].to(
792
+ dtype=torch.float32
793
+ )
794
+ safe_raw_indices = torch.clamp(raw_indices, max=max_values_len - 1)
795
+ gt_records = values_padded[record_target_idx.unsqueeze(1), safe_raw_indices]
796
+ roi_records = roi_masks_padded[record_target_idx.unsqueeze(1), safe_raw_indices]
797
+ # Each record was predicted in its owner patch's causal-stats frame, so
798
+ # the targets move into the same configured post-scale value frame.
799
+ owner_mu = context_mu_padded[record_target_idx, record_patch_idx]
800
+ owner_sigma = context_sigma_padded[record_target_idx, record_patch_idx].clamp_min(
801
+ 1e-6
802
+ )
803
+ gt_records = _transform_forecast_targets_for_loss(
804
+ gt_records,
805
+ owner_mu=owner_mu,
806
+ owner_sigma=owner_sigma,
807
+ )
808
+ mask_float = keep_mask.to(dtype=dtype)
809
+
810
+ zero = pred_records.new_zeros(())
811
+ roi_supervised_points, roi_supervised_targets = _roi_supervision_counts(
812
+ roi_records=roi_records,
813
+ keep_mask=keep_mask,
814
+ record_target_idx=record_target_idx,
815
+ target_count=len(targets),
816
+ )
817
+ global_point = zero
818
+ total_point = zero
819
+ roi_point_loss = global_point.new_zeros(())
820
+ roi_weighted_point_loss = global_point.new_zeros(())
821
+ global_quantile = zero
822
+ total_quantile = zero
823
+ global_uncapped = zero
824
+ global_cap_scale = zero.new_ones(())
825
+ roi_uncapped = zero
826
+ roi_weighted_uncapped = zero
827
+ roi_cap_scale = zero.new_ones(())
828
+ record_count = int(record_target_idx.numel())
829
+ point_per_record = pred_records.new_zeros((record_count,))
830
+ quantile_per_record = pred_records.new_zeros((record_count,))
831
+
832
+ taus = torch.tensor(self.tsfm_quantile_taus, device=device, dtype=dtype)
833
+ if int(taus.numel()) != q_channels:
834
+ raise RuntimeError(
835
+ "TS expert quantile tau count must match the forecast channel axis: "
836
+ f"taus={int(taus.numel())}, q_channels={q_channels}"
837
+ )
838
+ quantile_mask = taus >= 0
839
+ if torch.any(quantile_mask):
840
+ active_taus = taus[quantile_mask]
841
+ pred_q = pred_records[:, :, quantile_mask]
842
+ err = gt_records[:, :, None] - pred_q
843
+ quantile_by_value = torch.maximum(
844
+ active_taus.view(1, 1, -1) * err,
845
+ (active_taus.view(1, 1, -1) - 1.0) * err,
846
+ )
847
+ quantile_denominator = valid_counts * int(active_taus.shape[0])
848
+ quantile_per_record = (quantile_by_value * mask_float[:, :, None]).sum(
849
+ dim=(1, 2)
850
+ ) / quantile_denominator
851
+
852
+ roi_alpha = float(self.ts_roi_mse_alpha)
853
+ point_by_value = _point_forecast_loss_values(
854
+ pred_records[:, :, decode_index],
855
+ gt_records,
856
+ )
857
+ point_per_record = (point_by_value * mask_float).sum(dim=1) / valid_counts
858
+ global_point = _reduce_per_record_loss(
859
+ point_per_record,
860
+ global_count=mot_ts_num_records_in_batch,
861
+ label="TS point record",
862
+ )
863
+
864
+ # Point and quantile are the same TS objective family. Keep their internal
865
+ # contribution unweighted here; the public knob is the outer
866
+ # `mot_ts_loss_weight` applied to the complete `ts_aux_loss`.
867
+ global_per_record_uncapped = point_per_record + quantile_per_record
868
+ global_uncapped = _reduce_per_record_loss(
869
+ global_per_record_uncapped,
870
+ global_count=mot_ts_num_records_in_batch,
871
+ label="TS global record",
872
+ )
873
+ global_record_cap_scales = _detached_per_item_loss_cap_scale(
874
+ global_per_record_uncapped,
875
+ _MOT_TS_GLOBAL_LOSS_DETACHED_CAP,
876
+ )
877
+ global_cap_scale = global_record_cap_scales.mean()
878
+ global_point = _reduce_per_record_loss(
879
+ point_per_record * global_record_cap_scales,
880
+ global_count=mot_ts_num_records_in_batch,
881
+ label="TS capped point record",
882
+ )
883
+ global_quantile = _reduce_per_record_loss(
884
+ quantile_per_record * global_record_cap_scales,
885
+ global_count=mot_ts_num_records_in_batch,
886
+ label="TS capped quantile record",
887
+ )
888
+ total_point = global_point
889
+ total_quantile = global_quantile
890
+
891
+ if roi_alpha > 0.0:
892
+ roi_value_losses = _roi_value_losses_from_predictions(
893
+ pred_records=pred_records,
894
+ gt_records=gt_records,
895
+ decode_index=decode_index,
896
+ )
897
+ reduced_roi_loss, roi_per_target = _roi_loss_from_value_losses(
898
+ value_losses=roi_value_losses,
899
+ roi_records=roi_records,
900
+ keep_mask=mask_float,
901
+ record_target_idx=record_target_idx,
902
+ target_count=len(targets),
903
+ global_target_count=mot_ts_num_roi_targets_in_batch,
904
+ )
905
+ roi_uncapped = reduced_roi_loss
906
+ if int(roi_per_target.numel()) > 0:
907
+ roi_target_cap_scales = _detached_per_item_loss_cap_scale(
908
+ roi_per_target,
909
+ _MOT_TS_ROI_LOSS_DETACHED_CAP,
910
+ )
911
+ roi_loss_for_objective = _reduce_per_record_loss(
912
+ roi_per_target * roi_target_cap_scales,
913
+ global_count=mot_ts_num_roi_targets_in_batch,
914
+ label="capped ROI target",
915
+ )
916
+ roi_cap_scale = roi_target_cap_scales.mean()
917
+ else:
918
+ roi_loss_for_objective = roi_uncapped
919
+ roi_weighted_uncapped = total_quantile.new_tensor(roi_alpha) * roi_uncapped
920
+ roi_point_loss = roi_loss_for_objective
921
+ roi_weighted_point_loss = total_point.new_tensor(roi_alpha) * roi_point_loss
922
+ total_point = total_point + roi_weighted_point_loss
923
+
924
+ return _FlatForecastLossParts(
925
+ point=total_point,
926
+ quantile=total_quantile,
927
+ global_point=global_point,
928
+ global_quantile=global_quantile,
929
+ roi_point=roi_point_loss,
930
+ roi_weighted_point=roi_weighted_point_loss,
931
+ global_uncapped=global_uncapped,
932
+ global_cap_scale=global_cap_scale,
933
+ roi_uncapped=roi_uncapped,
934
+ roi_weighted_uncapped=roi_weighted_uncapped,
935
+ roi_cap_scale=roi_cap_scale,
936
+ roi_supervised_points=roi_supervised_points,
937
+ roi_supervised_targets=roi_supervised_targets,
938
+ )
939
+
940
+
941
+ def _predict_mot_span_quantiles(
942
+ self,
943
+ *,
944
+ mot_runtime: "_MoTRuntime",
945
+ target: "_MoTForecastTarget",
946
+ ) -> torch.Tensor:
947
+ """Run the native numeric head on one packed TS target slice (real space)."""
948
+ pred_batched, patch_counts = _predict_mot_span_quantiles_batched(
949
+ self,
950
+ mot_runtime=mot_runtime,
951
+ targets=[target],
952
+ output_space="real",
953
+ )
954
+ if len(patch_counts) != 1:
955
+ raise RuntimeError(
956
+ f"Single-target forecast expected one patch count, got {patch_counts}."
957
+ )
958
+ return pred_batched[0, : int(patch_counts[0])]
959
+
960
+
961
+ def _finalize_forecast_routed_losses(
962
+ self,
963
+ *,
964
+ main_losses: _FlatForecastLossParts,
965
+ extra_losses: Optional[Dict[str, torch.Tensor]] = None,
966
+ ) -> Dict[str, torch.Tensor]:
967
+ """Assemble the routed TS loss dictionary."""
968
+ total_point = main_losses.point
969
+ total_quantile = main_losses.quantile
970
+ total_ts_aux = total_point + total_quantile
971
+ total_roi_loss = main_losses.roi_weighted_point
972
+ routed_losses: Dict[str, torch.Tensor] = {
973
+ "ts_aux_loss": total_ts_aux,
974
+ "ts_aux_point_loss": total_point,
975
+ "ts_aux_quantile_loss": total_quantile,
976
+ "ts_aux_global_point_loss": main_losses.global_point,
977
+ "ts_aux_global_quantile_loss": main_losses.global_quantile,
978
+ "ts_aux_global_uncapped_loss": main_losses.global_uncapped,
979
+ "ts_aux_global_cap_scale": main_losses.global_cap_scale,
980
+ "ts_aux_roi_loss": total_roi_loss,
981
+ "ts_aux_roi_point_loss": main_losses.roi_point,
982
+ "ts_aux_roi_weighted_point_loss": main_losses.roi_weighted_point,
983
+ "ts_aux_roi_uncapped_loss": main_losses.roi_uncapped,
984
+ "ts_aux_roi_weighted_uncapped_loss": main_losses.roi_weighted_uncapped,
985
+ "ts_aux_roi_cap_scale": main_losses.roi_cap_scale,
986
+ "ts_aux_roi_supervised_points": main_losses.roi_supervised_points,
987
+ "ts_aux_roi_supervised_targets": main_losses.roi_supervised_targets,
988
+ "ts_aux_roi_alpha": total_point.new_tensor(float(self.ts_roi_mse_alpha)),
989
+ }
990
+
991
+ if extra_losses:
992
+ weighted_loss = extra_losses.get("ts_aux_understanding_weighted_loss")
993
+ if isinstance(weighted_loss, torch.Tensor):
994
+ total_ts_aux = total_ts_aux + weighted_loss
995
+ routed_losses["ts_aux_loss"] = total_ts_aux
996
+ routed_losses.update(extra_losses)
997
+
998
+ return routed_losses
999
+
1000
+
1001
+ def _zero_flat_forecast_loss_parts(reference: torch.Tensor) -> _FlatForecastLossParts:
1002
+ zero = reference.new_zeros((), dtype=torch.float32)
1003
+ one = reference.new_ones((), dtype=torch.float32)
1004
+ return _FlatForecastLossParts(
1005
+ point=zero,
1006
+ quantile=zero,
1007
+ global_point=zero,
1008
+ global_quantile=zero,
1009
+ roi_point=zero,
1010
+ roi_weighted_point=zero,
1011
+ global_uncapped=zero,
1012
+ global_cap_scale=one,
1013
+ roi_uncapped=zero,
1014
+ roi_weighted_uncapped=zero,
1015
+ roi_cap_scale=one,
1016
+ roi_supervised_points=zero,
1017
+ roi_supervised_targets=zero,
1018
+ )
1019
+
1020
+
1021
+ def _compute_understanding_head_losses(
1022
+ self,
1023
+ *,
1024
+ mot_runtime: "_MoTRuntime",
1025
+ targets: List[Any],
1026
+ mot_ts_understanding_num_points: Optional[object] = None,
1027
+ mot_ts_understanding_num_fft_elements: Optional[object] = None,
1028
+ ) -> Dict[str, torch.Tensor]:
1029
+ """Reconstruct observed/context TS patches from understanding hidden states."""
1030
+ weight = float(self.ts_understanding_loss_weight)
1031
+ understanding_head = self.understanding_head
1032
+ understanding_hidden = mot_runtime.understanding_hidden
1033
+ reference = understanding_hidden
1034
+ if reference is None and targets:
1035
+ reference = targets[0].values
1036
+ if reference is None:
1037
+ reference = torch.zeros((), dtype=torch.float32)
1038
+ zero = reference.new_zeros((), dtype=torch.float32)
1039
+ count_zero = reference.new_zeros((), dtype=torch.float32)
1040
+ disabled = weight <= 0.0
1041
+ if (
1042
+ disabled
1043
+ or understanding_hidden is None
1044
+ or int(understanding_hidden.shape[0]) == 0
1045
+ ):
1046
+ return {
1047
+ "ts_aux_understanding_loss": zero,
1048
+ "ts_aux_understanding_weighted_loss": zero,
1049
+ "ts_aux_understanding_time_loss": zero,
1050
+ "ts_aux_understanding_fft_loss": zero,
1051
+ "ts_aux_understanding_patches": count_zero,
1052
+ "ts_aux_understanding_points": count_zero,
1053
+ "ts_aux_understanding_weight": count_zero,
1054
+ }
1055
+ if understanding_head is None:
1056
+ raise RuntimeError(
1057
+ "ts_understanding_loss_weight > 0 requires understanding_head."
1058
+ )
1059
+
1060
+ hidden_parts: List[torch.Tensor] = []
1061
+ value_parts: List[torch.Tensor] = []
1062
+ mask_parts: List[torch.Tensor] = []
1063
+ for target in targets:
1064
+ if int(target.role_id) == int(ROLE_TARGET):
1065
+ continue
1066
+ if int(target.ts_route_id) != int(TS_ROUTE_UNDERSTANDING):
1067
+ continue
1068
+ ts_start = int(target.ts_start)
1069
+ ts_end = int(target.ts_end)
1070
+ if ts_end <= ts_start:
1071
+ continue
1072
+ if not (0 <= ts_start < ts_end <= int(understanding_hidden.shape[0])):
1073
+ raise RuntimeError(
1074
+ "TS understanding reconstruction target points outside packed understanding hidden states: "
1075
+ f"sample={int(target.sample_idx)}, "
1076
+ f"slot={int(target.slot_idx)}, "
1077
+ f"ts_range=[{ts_start},{ts_end}), hidden_count={int(understanding_hidden.shape[0])}."
1078
+ )
1079
+ reconstruction_values = target.reconstruction_values
1080
+ reconstruction_masks = target.reconstruction_masks
1081
+ if reconstruction_values is None or reconstruction_masks is None:
1082
+ raise RuntimeError(
1083
+ "TS understanding reconstruction requires patch-aligned values and masks."
1084
+ )
1085
+ if reconstruction_values.ndim != 3 or int(reconstruction_values.shape[0]) != 1:
1086
+ raise RuntimeError(
1087
+ "TS understanding reconstruction values must be shaped [1,N_patch,patch], got "
1088
+ f"{tuple(reconstruction_values.shape)}."
1089
+ )
1090
+ if reconstruction_masks.shape != reconstruction_values.shape:
1091
+ raise RuntimeError(
1092
+ "TS understanding reconstruction masks must match reconstruction values, got "
1093
+ f"values={tuple(reconstruction_values.shape)}, masks={tuple(reconstruction_masks.shape)}."
1094
+ )
1095
+ patch_count = ts_end - ts_start
1096
+ if int(reconstruction_values.shape[1]) != patch_count:
1097
+ raise RuntimeError(
1098
+ "TS understanding reconstruction patch count must match hidden span length, got "
1099
+ f"values={int(reconstruction_values.shape[1])}, hidden={patch_count}."
1100
+ )
1101
+ valid_mask = torch.logical_not(
1102
+ reconstruction_masks[0].to(
1103
+ device=understanding_hidden.device, dtype=torch.bool
1104
+ )
1105
+ )
1106
+ if not bool(torch.any(valid_mask)):
1107
+ continue
1108
+ hidden_parts.append(understanding_hidden[ts_start:ts_end])
1109
+ value_parts.append(
1110
+ reconstruction_values[0].to(
1111
+ device=understanding_hidden.device, dtype=torch.float32
1112
+ )
1113
+ )
1114
+ mask_parts.append(valid_mask)
1115
+
1116
+ if not hidden_parts:
1117
+ return {
1118
+ "ts_aux_understanding_loss": zero,
1119
+ "ts_aux_understanding_weighted_loss": zero,
1120
+ "ts_aux_understanding_time_loss": zero,
1121
+ "ts_aux_understanding_fft_loss": zero,
1122
+ "ts_aux_understanding_patches": count_zero,
1123
+ "ts_aux_understanding_points": count_zero,
1124
+ "ts_aux_understanding_weight": reference.new_tensor(
1125
+ float(weight), dtype=torch.float32
1126
+ ),
1127
+ }
1128
+
1129
+ head_inputs = torch.cat(hidden_parts, dim=0)
1130
+ target_values = torch.cat(value_parts, dim=0)
1131
+ valid_mask = torch.cat(mask_parts, dim=0)
1132
+ pred_values = understanding_head(head_inputs)
1133
+ if pred_values.ndim != 2 or tuple(pred_values.shape) != tuple(target_values.shape):
1134
+ raise RuntimeError(
1135
+ "TS understanding head output must match patch reconstruction targets, got "
1136
+ f"pred={tuple(pred_values.shape)}, target={tuple(target_values.shape)}."
1137
+ )
1138
+
1139
+ pred_f32 = pred_values.to(dtype=torch.float32)
1140
+ target_f32 = target_values.to(dtype=torch.float32)
1141
+ valid_mask_f32 = valid_mask.to(dtype=torch.float32)
1142
+ local_valid_points = valid_mask_f32.sum()
1143
+ global_valid_points = _global_count_tensor(
1144
+ mot_ts_understanding_num_points,
1145
+ reference=pred_f32,
1146
+ label="TS understanding valid-point",
1147
+ )
1148
+ time_denominator = (
1149
+ local_valid_points if global_valid_points is None else global_valid_points
1150
+ )
1151
+ time_loss = (
1152
+ (pred_f32 - target_f32).square() * valid_mask_f32
1153
+ ).sum() / time_denominator
1154
+
1155
+ pred_fft = torch.fft.rfft(pred_f32 * valid_mask_f32, dim=-1, norm="ortho")
1156
+ target_fft = torch.fft.rfft(target_f32 * valid_mask_f32, dim=-1, norm="ortho")
1157
+ fft_squared_error = (pred_fft - target_fft).abs().square()
1158
+ global_fft_elements = _global_count_tensor(
1159
+ mot_ts_understanding_num_fft_elements,
1160
+ reference=pred_f32,
1161
+ label="TS understanding FFT-element",
1162
+ )
1163
+ fft_denominator = (
1164
+ fft_squared_error.new_tensor(float(fft_squared_error.numel()))
1165
+ if global_fft_elements is None
1166
+ else global_fft_elements
1167
+ )
1168
+ fft_loss = fft_squared_error.sum() / fft_denominator
1169
+ loss = 0.2 * time_loss + 0.8 * fft_loss
1170
+ weighted_loss = loss.new_tensor(weight) * loss
1171
+ return {
1172
+ "ts_aux_understanding_loss": loss,
1173
+ "ts_aux_understanding_weighted_loss": weighted_loss,
1174
+ "ts_aux_understanding_time_loss": time_loss,
1175
+ "ts_aux_understanding_fft_loss": fft_loss,
1176
+ "ts_aux_understanding_patches": pred_values.new_tensor(
1177
+ float(int(pred_values.shape[0])), dtype=torch.float32
1178
+ ),
1179
+ "ts_aux_understanding_points": pred_values.new_tensor(
1180
+ float(valid_mask.sum().item()), dtype=torch.float32
1181
+ ),
1182
+ "ts_aux_understanding_weight": pred_values.new_tensor(
1183
+ float(weight), dtype=torch.float32
1184
+ ),
1185
+ }
1186
+
1187
+
1188
+ def _sum_flat_loss_parts(parts: List[_FlatForecastLossParts]) -> _FlatForecastLossParts:
1189
+ if not parts:
1190
+ raise RuntimeError("Cannot sum empty TS loss parts.")
1191
+ if len(parts) == 1:
1192
+ return parts[0]
1193
+ values = []
1194
+ for field_idx in range(len(_FlatForecastLossParts._fields)):
1195
+ values.append(sum((part[field_idx] for part in parts[1:]), parts[0][field_idx]))
1196
+ return _FlatForecastLossParts(*values)
1197
+
1198
+
1199
+ def compute_routed_mot_losses(
1200
+ self,
1201
+ mot_runtime: "_MoTRuntime",
1202
+ *,
1203
+ mot_ts_num_records_by_horizon: Optional[object] = None,
1204
+ mot_ts_num_roi_targets_by_horizon: Optional[object] = None,
1205
+ mot_ts_understanding_num_points: Optional[object] = None,
1206
+ mot_ts_understanding_num_fft_elements: Optional[object] = None,
1207
+ ) -> Optional[Dict[str, torch.Tensor]]:
1208
+ """Route TS losses by target type, directly from the packed runtime.
1209
+
1210
+ Contract:
1211
+ - next-patch generation records share one TS record/ROI reducer and the
1212
+ exact GA/DDP denominator bundle
1213
+ - `<ts> -> first patch` and `last patch -> </ts>` boundary routes are removed
1214
+ """
1215
+ if self.generation_tsfm is None:
1216
+ return None
1217
+
1218
+ understanding_head_losses = _compute_understanding_head_losses(
1219
+ self,
1220
+ mot_runtime=mot_runtime,
1221
+ targets=list(mot_runtime.forecast_targets),
1222
+ mot_ts_understanding_num_points=mot_ts_understanding_num_points,
1223
+ mot_ts_understanding_num_fft_elements=mot_ts_understanding_num_fft_elements,
1224
+ )
1225
+ supervised_targets_all = [
1226
+ target for target in mot_runtime.forecast_targets if target.supervise
1227
+ ]
1228
+ if not supervised_targets_all:
1229
+ understanding_weighted = understanding_head_losses.get(
1230
+ "ts_aux_understanding_weighted_loss"
1231
+ )
1232
+ if isinstance(understanding_weighted, torch.Tensor) and bool(
1233
+ torch.any(understanding_weighted != 0)
1234
+ ):
1235
+ return _finalize_forecast_routed_losses(
1236
+ self,
1237
+ main_losses=_zero_flat_forecast_loss_parts(understanding_weighted),
1238
+ extra_losses=understanding_head_losses,
1239
+ )
1240
+ return None
1241
+ # Keep conditioning spans out of next-patch loss even for hand-built
1242
+ # runtimes; target spans must use the generation route checked below.
1243
+ generation_next_patch_targets = [
1244
+ target
1245
+ for target in supervised_targets_all
1246
+ if int(target.role_id) == int(ROLE_TARGET)
1247
+ and int(target.ts_route_id) == int(TS_ROUTE_GENERATION)
1248
+ ]
1249
+ bad_next_patch_target_routes = [
1250
+ (
1251
+ int(target.sample_idx),
1252
+ int(target.slot_idx),
1253
+ int(target.ts_route_id),
1254
+ )
1255
+ for target in supervised_targets_all
1256
+ if int(target.role_id) == int(ROLE_TARGET)
1257
+ and int(target.ts_route_id) != int(TS_ROUTE_GENERATION)
1258
+ ]
1259
+ if bad_next_patch_target_routes:
1260
+ raise RuntimeError(
1261
+ "assistant forecasting target spans must route through the generation TS expert; "
1262
+ f"bad_sample_slot_route={bad_next_patch_target_routes}."
1263
+ )
1264
+
1265
+ loss_parts: List[_FlatForecastLossParts] = []
1266
+ extra_losses: Dict[str, torch.Tensor] = dict(understanding_head_losses)
1267
+ routed_targets = generation_next_patch_targets
1268
+ if routed_targets:
1269
+ pred_quantiles_batched, patch_counts = _predict_mot_span_quantiles_batched(
1270
+ self,
1271
+ mot_runtime=mot_runtime,
1272
+ targets=routed_targets,
1273
+ output_space="normalized",
1274
+ )
1275
+ routed_losses = _compute_flat_forecast_losses_from_predictions(
1276
+ self,
1277
+ pred_quantiles_batched=pred_quantiles_batched,
1278
+ targets=routed_targets,
1279
+ patch_counts=patch_counts,
1280
+ mot_ts_num_records_in_batch=_global_count_for_horizon(
1281
+ mot_ts_num_records_by_horizon,
1282
+ int(self.tsfm_output_patch_len),
1283
+ ),
1284
+ mot_ts_num_roi_targets_in_batch=_global_count_for_horizon(
1285
+ mot_ts_num_roi_targets_by_horizon,
1286
+ int(self.tsfm_output_patch_len),
1287
+ ),
1288
+ loss_space="normalized",
1289
+ )
1290
+ if routed_losses is not None:
1291
+ loss_parts.append(routed_losses)
1292
+
1293
+ if not loss_parts:
1294
+ understanding_weighted = extra_losses.get("ts_aux_understanding_weighted_loss")
1295
+ if isinstance(understanding_weighted, torch.Tensor) and bool(
1296
+ torch.any(understanding_weighted != 0)
1297
+ ):
1298
+ return _finalize_forecast_routed_losses(
1299
+ self,
1300
+ main_losses=_zero_flat_forecast_loss_parts(understanding_weighted),
1301
+ extra_losses=extra_losses,
1302
+ )
1303
+ return None
1304
+ flat_losses = _sum_flat_loss_parts(loss_parts)
1305
+
1306
+ return _finalize_forecast_routed_losses(
1307
+ self,
1308
+ main_losses=flat_losses,
1309
+ extra_losses=extra_losses,
1310
+ )
1311
+
1312
+
1313
+ __all__ = [
1314
+ "compute_routed_mot_losses",
1315
+ "_compute_flat_forecast_losses_from_predictions",
1316
+ "_compute_understanding_head_losses",
1317
+ ]
timebraid/model/mot/bridge_runtime_mot.py ADDED
The diff for this file is too large to render. See raw diff
 
timebraid/model/mot/config_contract.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TimeBraid Hugging Face config restore contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ class _Required:
11
+ """Sentinel for an option the restore contract always supplies."""
12
+
13
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
14
+ return "REQUIRED"
15
+
16
+
17
+ REQUIRED = _Required()
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class MoTOption:
22
+ """One MoT construction knob, declared once for every boundary it crosses.
23
+
24
+ The same `name` is used in the checkpoint's `config.json`, in the runtime
25
+ option mapping, and in the error messages — so grepping one spelling finds
26
+ every place the knob exists.
27
+ """
28
+
29
+ name: str
30
+ kind: str # boolean | integer | number | string | torch_dtype
31
+ default: Any # REQUIRED when the restore contract always supplies it
32
+ persisted: bool # does it live in the checkpoint's config.json
33
+
34
+ @property
35
+ def is_required(self) -> bool:
36
+ return self.default is REQUIRED
37
+
38
+
39
+ # The single declaration of the MoT option surface.
40
+ #
41
+ # Persisted options are REQUIRED rather than defaulted on purpose. The restore
42
+ # contract always supplies them, so a default could only ever apply to a
43
+ # hand-built option map — and for something like the position lattice that
44
+ # means plausible-but-wrong forecasts instead of an error. Defaults belong
45
+ # only to options no checkpoint carries.
46
+ MOT_OPTIONS: tuple[MoTOption, ...] = (
47
+ # --- persisted: module topology and tokenizer ABI ---
48
+ MoTOption("mot_pairing_mode", "string", REQUIRED, persisted=True),
49
+ MoTOption("mot_t_layers", "integer", REQUIRED, persisted=True),
50
+ MoTOption("mot_understanding_pair_depth", "integer", REQUIRED, persisted=True),
51
+ # The one contract field without the `mot_` prefix. Renaming it would break
52
+ # every checkpoint the training tree has written, so it is left alone until
53
+ # both trees move together.
54
+ MoTOption("has_understanding_head", "boolean", REQUIRED, persisted=True),
55
+ MoTOption("mot_mixed_position_mode", "string", REQUIRED, persisted=True),
56
+ MoTOption("mot_tsfm_num_heads", "integer", REQUIRED, persisted=True),
57
+ MoTOption("mot_tsfm_head_dim", "integer", REQUIRED, persisted=True),
58
+ MoTOption("mot_tsfm_hidden_size", "integer", REQUIRED, persisted=True),
59
+ MoTOption("mot_patch_size", "integer", REQUIRED, persisted=True),
60
+ MoTOption("mot_ts_open_token_id", "integer", REQUIRED, persisted=True),
61
+ MoTOption("mot_ts_close_token_id", "integer", REQUIRED, persisted=True),
62
+ # --- not persisted: supplied per load, or not at all ---
63
+ MoTOption("mot_compute_dtype", "torch_dtype", "float32", persisted=False),
64
+ MoTOption("mot_max_spans_per_sample", "integer", 64, persisted=False),
65
+ MoTOption("mot_ts_gradient_checkpointing", "boolean", False, persisted=False),
66
+ MoTOption(
67
+ "mot_ts_gradient_checkpointing_use_reentrant",
68
+ "boolean",
69
+ False,
70
+ persisted=False,
71
+ ),
72
+ # No loader supplies these four; they exist because the loss path is still
73
+ # reachable from the public `runtime_options` argument. See the release
74
+ # audit's "training machinery on the inference path".
75
+ MoTOption("mot_ts_loss_weight", "number", 1.0, persisted=False),
76
+ MoTOption("mot_lm_loss_weight", "number", 1.0, persisted=False),
77
+ MoTOption("mot_ts_roi_mse_alpha", "number", 0.0, persisted=False),
78
+ MoTOption("mot_ts_understanding_loss_weight", "number", 0.0, persisted=False),
79
+ )
80
+
81
+ MOT_OPTION_BY_NAME: dict[str, MoTOption] = {
82
+ option.name: option for option in MOT_OPTIONS
83
+ }
84
+
85
+ # Derived, not restated: the persisted subset is a property of the registry.
86
+ MOT_HF_CONFIG_CONTRACT_FIELDS = tuple(
87
+ option.name for option in MOT_OPTIONS if option.persisted
88
+ )
89
+
90
+
91
+ def collect_mot_hf_config_contract(config: Any) -> dict[str, object]:
92
+ """Collect the complete MoT HF restore contract from a config object."""
93
+ is_mapping = isinstance(config, Mapping)
94
+
95
+ def _has(field: str) -> bool:
96
+ return field in config if is_mapping else hasattr(config, field)
97
+
98
+ def _get(field: str) -> object:
99
+ return config.get(field) if is_mapping else getattr(config, field, None)
100
+
101
+ missing = [field for field in MOT_HF_CONFIG_CONTRACT_FIELDS if not _has(field)]
102
+ if missing:
103
+ raise RuntimeError(f"MoT HF config restore metadata is missing {missing}.")
104
+
105
+ contract: dict[str, object] = {}
106
+ for field in MOT_HF_CONFIG_CONTRACT_FIELDS:
107
+ contract[field] = _get(field)
108
+ return validate_mot_hf_config_contract(contract)
109
+
110
+
111
+ def validate_mot_hf_config_contract(
112
+ contract: dict[str, object],
113
+ ) -> dict[str, object]:
114
+ """Validate and canonicalize every persisted MoT restore field."""
115
+ missing = [
116
+ field for field in MOT_HF_CONFIG_CONTRACT_FIELDS if field not in contract
117
+ ]
118
+ if missing:
119
+ raise RuntimeError(f"MoT HF config restore metadata is missing {missing}.")
120
+
121
+ validated = dict(contract)
122
+ allowed_strings = {
123
+ "mot_pairing_mode": {"interleaved", "dsfp"},
124
+ "mot_mixed_position_mode": {"span_slot", "patch_slot"},
125
+ }
126
+ for field, allowed in allowed_strings.items():
127
+ value = validated[field]
128
+ if not isinstance(value, str) or value not in allowed:
129
+ raise RuntimeError(
130
+ f"MoT HF config `{field}` must be one of {sorted(allowed)!r}, got {value!r}."
131
+ )
132
+
133
+ positive_integer_fields = (
134
+ "mot_t_layers",
135
+ "mot_tsfm_num_heads",
136
+ "mot_tsfm_head_dim",
137
+ "mot_tsfm_hidden_size",
138
+ "mot_patch_size",
139
+ )
140
+ for field in positive_integer_fields:
141
+ value = validated[field]
142
+ if type(value) is not int or value <= 0:
143
+ raise RuntimeError(
144
+ f"MoT HF config `{field}` must be a positive integer, got {value!r}."
145
+ )
146
+ pair_depth = validated["mot_understanding_pair_depth"]
147
+ if type(pair_depth) is not int or not 0 <= pair_depth <= validated["mot_t_layers"]:
148
+ raise RuntimeError(
149
+ "MoT HF config `mot_understanding_pair_depth` must be an integer in "
150
+ f"[0, mot_t_layers], got {pair_depth!r}."
151
+ )
152
+ has_understanding_head = validated["has_understanding_head"]
153
+ if type(has_understanding_head) is not bool:
154
+ raise RuntimeError(
155
+ "MoT HF config `has_understanding_head` must be a bool, got "
156
+ f"{has_understanding_head!r}."
157
+ )
158
+ if has_understanding_head and pair_depth == 0:
159
+ raise RuntimeError(
160
+ "MoT HF config cannot enable `has_understanding_head` without an "
161
+ "understanding TimesFM tower."
162
+ )
163
+ expected_hidden_size = (
164
+ validated["mot_tsfm_num_heads"] * validated["mot_tsfm_head_dim"]
165
+ )
166
+ if validated["mot_tsfm_hidden_size"] != expected_hidden_size:
167
+ raise RuntimeError(
168
+ "MoT HF config TSFM geometry is inconsistent: "
169
+ f"hidden_size={validated['mot_tsfm_hidden_size']}, "
170
+ f"num_heads*head_dim={expected_hidden_size}."
171
+ )
172
+
173
+ delimiter_ids = []
174
+ for field in ("mot_ts_open_token_id", "mot_ts_close_token_id"):
175
+ value = validated[field]
176
+ if type(value) is not int or value < 0:
177
+ raise RuntimeError(
178
+ f"MoT HF config `{field}` must be a non-negative integer, got {value!r}."
179
+ )
180
+ delimiter_ids.append(value)
181
+ if delimiter_ids[0] == delimiter_ids[1]:
182
+ raise RuntimeError(
183
+ "MoT HF config TS delimiter token IDs must be distinct, got "
184
+ f"{delimiter_ids[0]}."
185
+ )
186
+ return validated
timebraid/model/mot/generation_route.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit routing decision for TimeBraid generation.
2
+
3
+ `TimeBraid.generate` accepts ordinary Hugging Face keyword arguments and has to
4
+ decide, from the inputs alone, whether a request wants plain text from the host
5
+ language model or the mixed text/time-series scheduler. That decision used to
6
+ live inline in a five-condition lattice with no name and no way to report
7
+ itself, so a request that fell through to plain text produced text with no
8
+ diagnostic. Everything needed to make that decision is gathered here instead:
9
+ the route is a named value, the reason is a sentence, and both are reachable
10
+ from a test without constructing a model.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ from typing import Optional
19
+
20
+ import torch
21
+
22
+ from .structures import TIMEBRAID_REQUIRED_TS_PAYLOAD_FIELDS, TimeBraidPayload
23
+
24
+
25
+ class FinishReason(str, Enum):
26
+ """Why the mixed scheduler stopped producing tokens.
27
+
28
+ These two spellings used to be bare string literals at five construction
29
+ sites, with the processor raising on anything it did not recognise — so a
30
+ third reason would have broken decoding at a distance rather than at the
31
+ call site that introduced it.
32
+ """
33
+
34
+ TEXT_BUDGET = "text_budget"
35
+ EOS_OR_PROTOCOL_STOP = "eos_or_protocol_stop"
36
+
37
+ # Enum overrides str.__str__, so without this `str(member)` and any
38
+ # f-string yield 'ClassName.MEMBER' instead of the value — which is
39
+ # exactly what a JSON payload or a log line would then carry.
40
+ __str__ = str.__str__
41
+
42
+
43
+ class GenerationRoute(str, Enum):
44
+ """Which generation implementation a request resolves to.
45
+
46
+ Inherits from `str` so the value survives JSON serialization and compares
47
+ equal to its own name in rollout records and logs.
48
+ """
49
+
50
+ PLAIN_TEXT = "plain_text"
51
+ MIXED_TIMESERIES = "mixed_timeseries"
52
+
53
+ # Enum overrides str.__str__, so without this `str(member)` and any
54
+ # f-string yield 'ClassName.MEMBER' instead of the value — which is
55
+ # exactly what a JSON payload or a log line would then carry.
56
+ __str__ = str.__str__
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class RouteDecision:
61
+ """A resolved route together with the reason it was chosen."""
62
+
63
+ route: GenerationRoute
64
+ reason: str
65
+
66
+ @property
67
+ def is_mixed(self) -> bool:
68
+ return self.route is GenerationRoute.MIXED_TIMESERIES
69
+
70
+
71
+ def _contains_ts_delimiter(
72
+ route_input: object, *, ts_open_token_id: int, ts_close_token_id: int
73
+ ) -> bool:
74
+ """Scan a token tensor for either TS delimiter id."""
75
+ if not isinstance(route_input, torch.Tensor):
76
+ return False
77
+ return bool(
78
+ torch.logical_or(
79
+ route_input.eq(int(ts_open_token_id)),
80
+ route_input.eq(int(ts_close_token_id)),
81
+ )
82
+ .any()
83
+ .item()
84
+ )
85
+
86
+
87
+ def resolve_generation_route(
88
+ *,
89
+ payload: TimeBraidPayload,
90
+ route_input: object,
91
+ target_horizons: Optional[Sequence[int]],
92
+ target_total_lengths: Optional[Sequence[int]],
93
+ ts_open_token_id: int,
94
+ ts_close_token_id: int,
95
+ ) -> RouteDecision:
96
+ """Decide which generation route a request takes.
97
+
98
+ Three independent signals request the mixed scheduler: a tensor-valued TS
99
+ payload, a `<ts>`/`</ts>` delimiter in the prompt, or explicit
100
+ `target_horizons`. Absent all three, the request is plain text.
101
+
102
+ One tolerance is preserved from the original lattice: an explicit all-zero
103
+ `target_horizons` on a text-only batch still produces text, because
104
+ external evaluation harnesses pass `target_horizons=0` to mean "forbid TS
105
+ rollout" rather than "run the TS scheduler with nothing to forecast".
106
+
107
+ `target_total_lengths` is deliberately asymmetric against that tolerance:
108
+ all-zero lengths are an explicit request for the scheduler's empty-target
109
+ validation, so they defeat it. An absent field is *not* all-zero.
110
+
111
+ This is only consulted by the `generate` compatibility shim, which is the
112
+ only entry point that has to infer intent from loose keyword arguments.
113
+ `generate_mixed` states its intent in its signature and validates its
114
+ inputs directly.
115
+ """
116
+ has_payload = payload.has_runtime_inputs()
117
+
118
+ horizons_given = target_horizons is not None
119
+ horizons_all_zero = horizons_given and all(
120
+ horizon == 0 for horizon in target_horizons
121
+ )
122
+ lengths_all_zero = target_total_lengths is not None and all(
123
+ total == 0 for total in target_total_lengths
124
+ )
125
+
126
+ # A positive horizon, or an explicit empty-target request, goes straight to
127
+ # the scheduler; the delimiter scan below is then pure overhead.
128
+ horizons_force_mixed = horizons_given and (
129
+ not horizons_all_zero or lengths_all_zero
130
+ )
131
+
132
+ has_delimiter = False
133
+ if not has_payload and not horizons_force_mixed:
134
+ has_delimiter = _contains_ts_delimiter(
135
+ route_input,
136
+ ts_open_token_id=ts_open_token_id,
137
+ ts_close_token_id=ts_close_token_id,
138
+ )
139
+
140
+ horizons_request_mixed = horizons_given
141
+ if (
142
+ horizons_request_mixed
143
+ and horizons_all_zero
144
+ and not lengths_all_zero
145
+ and not has_payload
146
+ and isinstance(route_input, torch.Tensor)
147
+ and not has_delimiter
148
+ ):
149
+ horizons_request_mixed = False
150
+
151
+ if has_payload:
152
+ return RouteDecision(
153
+ GenerationRoute.MIXED_TIMESERIES,
154
+ "a tensor-valued TS payload was supplied",
155
+ )
156
+ if has_delimiter:
157
+ return RouteDecision(
158
+ GenerationRoute.MIXED_TIMESERIES,
159
+ "the prompt contains a TS delimiter token",
160
+ )
161
+ if horizons_request_mixed:
162
+ return RouteDecision(
163
+ GenerationRoute.MIXED_TIMESERIES,
164
+ "explicit target_horizons were supplied",
165
+ )
166
+
167
+ absent = []
168
+ if not has_payload:
169
+ absent.append("no TS payload")
170
+ if not has_delimiter:
171
+ absent.append("no TS delimiter token in the prompt")
172
+ if not horizons_given:
173
+ absent.append("no target_horizons")
174
+ elif horizons_all_zero:
175
+ absent.append("target_horizons are all zero")
176
+ return RouteDecision(
177
+ GenerationRoute.PLAIN_TEXT,
178
+ "plain text because " + ", ".join(absent),
179
+ )
180
+
181
+
182
+ def require_mixed_generation_inputs(inputs: Mapping[str, object]) -> None:
183
+ """Reject a mapping that cannot drive mixed generation.
184
+
185
+ Validated structurally rather than by type so a test can pass a plain dict
186
+ and so a caller that assembles its own batch is not forced through the
187
+ processor. The error names the missing fields and where they come from,
188
+ because their absence used to route silently to plain text.
189
+ """
190
+ if not isinstance(inputs, Mapping):
191
+ raise TypeError(
192
+ "generate_mixed requires the mapping returned by "
193
+ "TimeBraidProcessor.apply_chat_template (or an equivalent mapping), "
194
+ f"got {type(inputs).__name__}."
195
+ )
196
+
197
+ if "input_ids" not in inputs or inputs["input_ids"] is None:
198
+ raise ValueError(
199
+ "generate_mixed requires `input_ids`; "
200
+ "TimeBraidProcessor.apply_chat_template supplies it."
201
+ )
202
+
203
+ missing = [
204
+ name
205
+ for name in TIMEBRAID_REQUIRED_TS_PAYLOAD_FIELDS
206
+ if inputs.get(name) is None
207
+ ]
208
+ if missing:
209
+ raise ValueError(
210
+ "generate_mixed requires a complete TS payload; missing "
211
+ f"{missing}. Pass `timeseries=` to "
212
+ "TimeBraidProcessor.apply_chat_template, which emits every TS "
213
+ "payload field, or call `generate` for plain text."
214
+ )
215
+
216
+
217
+ __all__ = [
218
+ "FinishReason",
219
+ "GenerationRoute",
220
+ "RouteDecision",
221
+ "require_mixed_generation_inputs",
222
+ "resolve_generation_route",
223
+ ]
timebraid/model/mot/kv_cache.py ADDED
@@ -0,0 +1,1614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache state and validation for TimeBraid generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass, fields, is_dataclass
7
+ from typing import Mapping, Sequence
8
+
9
+ import torch
10
+ from transformers.cache_utils import DynamicCache, DynamicLayer, StaticCache
11
+
12
+ _RequestId = str | int
13
+ _SUPPORTED_CACHE_IMPLEMENTATIONS = {None, "dynamic", "dynamic_full"}
14
+
15
+ # One incremental KV stream registry key: (kind, row, layer, span).
16
+ # kind="llm"/"residual" streams live for the whole request row (span=None,
17
+ # layer is the Qwen layer index). kind="ts" streams live for one generated
18
+ # span (span is the target slot, layer is the TimesFM layer index).
19
+ StreamKey = tuple[str, int, int, int | None]
20
+ _STREAM_KINDS = ("llm", "residual", "ts")
21
+ _STREAM_LIFECYCLE = {"llm": "row", "residual": "row", "ts": "span"}
22
+
23
+
24
+ def _validate_stream_key(key: object) -> StreamKey:
25
+ if not isinstance(key, tuple) or len(key) != 4:
26
+ raise TypeError(
27
+ f"Stream key must be a (kind, row, layer, span) tuple, got {key!r}."
28
+ )
29
+ kind, row, layer, span = key
30
+ if kind not in _STREAM_KINDS:
31
+ raise ValueError(f"Stream kind must be one of {_STREAM_KINDS}, got {kind!r}.")
32
+ if isinstance(row, bool) or not isinstance(row, int) or row < 0:
33
+ raise ValueError(f"Stream row must be a non-negative int, got {row!r}.")
34
+ if isinstance(layer, bool) or not isinstance(layer, int) or layer < 0:
35
+ raise ValueError(f"Stream layer must be a non-negative int, got {layer!r}.")
36
+ if _STREAM_LIFECYCLE[kind] == "row":
37
+ if span is not None:
38
+ raise ValueError(
39
+ f"Row-lifetime {kind!r} streams must use span=None, got {span!r}."
40
+ )
41
+ elif isinstance(span, bool) or not isinstance(span, int) or span < 0:
42
+ raise ValueError(
43
+ f"Span-lifetime {kind!r} streams need a non-negative span slot, got {span!r}."
44
+ )
45
+ return key
46
+
47
+
48
+ def _validate_request_id(request_id: object) -> _RequestId:
49
+ if isinstance(request_id, bool) or not isinstance(request_id, (str, int)):
50
+ raise TypeError(
51
+ f"request_id must be str or int, got {type(request_id).__name__}."
52
+ )
53
+ if isinstance(request_id, str) and not request_id:
54
+ raise ValueError("request_id must not be empty.")
55
+ return request_id
56
+
57
+
58
+ def _normalize_request_ids(request_ids: Sequence[_RequestId]) -> tuple[_RequestId, ...]:
59
+ normalized = tuple(_validate_request_id(request_id) for request_id in request_ids)
60
+ if not normalized:
61
+ raise ValueError("MoTDynamicCache requires at least one request_id.")
62
+ if len(set(normalized)) != len(normalized):
63
+ raise ValueError(f"Initial request_ids must be unique, got {normalized}.")
64
+ return normalized
65
+
66
+
67
+ def _update_payload_hash(digest: "hashlib._Hash", value: object) -> None:
68
+ if isinstance(value, torch.Tensor):
69
+ try:
70
+ version = int(value._version)
71
+ except RuntimeError as exc:
72
+ raise RuntimeError(
73
+ "Payload tensors must be created outside torch.inference_mode so in-place mutation is observable."
74
+ ) from exc
75
+ tensor_state = (
76
+ "tensor",
77
+ str(value.dtype),
78
+ str(value.device),
79
+ tuple(value.shape),
80
+ tuple(value.stride()),
81
+ int(value.storage_offset()),
82
+ int(value.untyped_storage().data_ptr()) if value.numel() > 0 else 0,
83
+ version,
84
+ )
85
+ digest.update(repr(tensor_state).encode("utf-8"))
86
+ return
87
+
88
+ if value is None or isinstance(value, (bool, int, float, str)):
89
+ digest.update(repr((type(value).__name__, value)).encode("utf-8"))
90
+ return
91
+
92
+ if isinstance(value, Mapping):
93
+ digest.update(b"mapping{")
94
+ for key in sorted(value):
95
+ if not isinstance(key, str):
96
+ raise TypeError(
97
+ f"Payload mapping keys must be strings, got {type(key).__name__}."
98
+ )
99
+ digest.update(key.encode("utf-8"))
100
+ _update_payload_hash(digest, value[key])
101
+ digest.update(b"}")
102
+ return
103
+
104
+ if isinstance(value, (list, tuple)):
105
+ digest.update(f"{type(value).__name__}[".encode("utf-8"))
106
+ for item in value:
107
+ _update_payload_hash(digest, item)
108
+ digest.update(b"]")
109
+ return
110
+
111
+ if is_dataclass(value) and not isinstance(value, type):
112
+ digest.update(f"dataclass:{type(value).__qualname__}{{".encode("utf-8"))
113
+ for field in fields(value):
114
+ digest.update(field.name.encode("utf-8"))
115
+ try:
116
+ _update_payload_hash(digest, getattr(value, field.name))
117
+ except RuntimeError as exc:
118
+ raise RuntimeError(
119
+ f"Payload field `{field.name}` is not fingerprintable: {exc}"
120
+ ) from exc
121
+ digest.update(b"}")
122
+ return
123
+
124
+ raise TypeError(f"Unsupported payload fingerprint value: {type(value).__name__}.")
125
+
126
+
127
+ def fingerprint_payload_state(payload: object) -> str:
128
+ """Fingerprint tensor identity/version metadata without copying payload values."""
129
+ digest = hashlib.sha256()
130
+ _update_payload_hash(digest, payload)
131
+ return digest.hexdigest()
132
+
133
+
134
+ def validate_mot_cache_inputs(
135
+ *,
136
+ use_cache: bool | None,
137
+ past_key_values: object | None,
138
+ cache_implementation: str | None = None,
139
+ ) -> None:
140
+ """Validate the supported MoT cache surface before any cache can mutate."""
141
+ if cache_implementation not in _SUPPORTED_CACHE_IMPLEMENTATIONS:
142
+ if cache_implementation == "static" or isinstance(past_key_values, StaticCache):
143
+ raise RuntimeError(
144
+ "MoT KV cache does not support StaticCache: HF static generation replaces the required 2D "
145
+ "layout mask with a compiled 4D mask. Use the dynamic cache implementation."
146
+ )
147
+ raise RuntimeError(
148
+ "MoT KV cache supports cache_implementation in {None, 'dynamic', 'dynamic_full'}, got "
149
+ f"{cache_implementation!r}."
150
+ )
151
+
152
+ if isinstance(past_key_values, StaticCache):
153
+ raise RuntimeError(
154
+ "MoT KV cache does not support StaticCache; use MoTDynamicCache."
155
+ )
156
+ if past_key_values is not None and use_cache is not True:
157
+ raise RuntimeError(
158
+ "past_key_values requires use_cache=True because Qwen mutates a supplied cache even when use_cache=False."
159
+ )
160
+ if use_cache is False and cache_implementation is not None:
161
+ raise RuntimeError("cache_implementation must be unset when use_cache=False.")
162
+ if past_key_values is not None and not isinstance(past_key_values, MoTDynamicCache):
163
+ raise TypeError(
164
+ "MoT continuation requires MoTDynamicCache with runtime sidecars, got "
165
+ f"{type(past_key_values).__name__}."
166
+ )
167
+
168
+
169
+ def _require_bool_mask(
170
+ mask: torch.Tensor, *, name: str, shape: tuple[int, ...]
171
+ ) -> torch.Tensor:
172
+ if not isinstance(mask, torch.Tensor):
173
+ raise TypeError(f"{name} must be a tensor, got {type(mask).__name__}.")
174
+ if mask.dtype is not torch.bool:
175
+ raise TypeError(f"{name} must have dtype torch.bool, got {mask.dtype}.")
176
+ if tuple(mask.shape) != shape:
177
+ raise ValueError(f"{name} must have shape {shape}, got {tuple(mask.shape)}.")
178
+ return mask
179
+
180
+
181
+ def _require_positions(
182
+ positions: torch.Tensor,
183
+ *,
184
+ name: str,
185
+ shape: tuple[int, ...],
186
+ device: torch.device,
187
+ ) -> torch.Tensor:
188
+ if not isinstance(positions, torch.Tensor):
189
+ raise TypeError(f"{name} must be a tensor, got {type(positions).__name__}.")
190
+ if positions.dtype is not torch.long:
191
+ raise TypeError(f"{name} must have dtype torch.long, got {positions.dtype}.")
192
+ if tuple(positions.shape) != shape:
193
+ raise ValueError(
194
+ f"{name} must have shape {shape}, got {tuple(positions.shape)}."
195
+ )
196
+ if positions.device != device:
197
+ raise ValueError(f"{name} must be on {device}, got {positions.device}.")
198
+ return positions
199
+
200
+
201
+ def _expanded_positions(
202
+ positions: torch.Tensor,
203
+ *,
204
+ batch_size: int,
205
+ token_count: int,
206
+ name: str,
207
+ device: torch.device,
208
+ ) -> torch.Tensor:
209
+ if not isinstance(positions, torch.Tensor):
210
+ raise TypeError(f"{name} must be a tensor, got {type(positions).__name__}.")
211
+ if positions.dtype is not torch.long:
212
+ raise TypeError(f"{name} must have dtype torch.long, got {positions.dtype}.")
213
+ if positions.device != device:
214
+ raise ValueError(f"{name} must be on {device}, got {positions.device}.")
215
+ if tuple(positions.shape) == (token_count,):
216
+ return positions.view(1, token_count).expand(batch_size, token_count)
217
+ if tuple(positions.shape) == (batch_size, token_count):
218
+ return positions
219
+ raise ValueError(
220
+ f"{name} must have shape {(token_count,)} or {(batch_size, token_count)}, got {tuple(positions.shape)}."
221
+ )
222
+
223
+
224
+ def _geometric_capacity(current: int, required: int, initial: int) -> int:
225
+ if required <= current:
226
+ return current
227
+ capacity = max(1, current, initial)
228
+ while capacity < required:
229
+ capacity *= 2
230
+ return capacity
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class MoTSidecarView:
235
+ """Logical view over one named per-layer sidecar stream."""
236
+
237
+ keys: torch.Tensor
238
+ values: torch.Tensor
239
+ valid_mask: torch.Tensor
240
+ physical_positions: torch.Tensor
241
+ logical_positions: torch.Tensor
242
+ rope_positions: torch.Tensor
243
+ capacity: int
244
+
245
+
246
+ class _PositionBuffer:
247
+ def __init__(self, *, batch_size: int, initial_capacity: int) -> None:
248
+ self.batch_size = batch_size
249
+ self.initial_capacity = initial_capacity
250
+ self.length = 0
251
+ self.capacity = 0
252
+ self.valid_mask: torch.Tensor | None = None
253
+ self.logical_positions: torch.Tensor | None = None
254
+ self.rope_positions: torch.Tensor | None = None
255
+
256
+ @property
257
+ def device(self) -> torch.device | None:
258
+ return None if self.valid_mask is None else self.valid_mask.device
259
+
260
+ def _ensure_capacity(self, required: int, *, device: torch.device) -> None:
261
+ target = _geometric_capacity(self.capacity, required, self.initial_capacity)
262
+ if target == self.capacity:
263
+ if self.device != device:
264
+ raise ValueError(
265
+ f"Position metadata is on {self.device}, got append on {device}."
266
+ )
267
+ return
268
+
269
+ valid = torch.zeros((self.batch_size, target), dtype=torch.bool, device=device)
270
+ logical = torch.full(
271
+ (self.batch_size, target), -1, dtype=torch.long, device=device
272
+ )
273
+ rope = torch.full(
274
+ (self.batch_size, target), -1, dtype=torch.long, device=device
275
+ )
276
+ if self.length > 0:
277
+ if self.device != device:
278
+ raise ValueError(
279
+ f"Position metadata is on {self.device}, got append on {device}."
280
+ )
281
+ valid[:, : self.length].copy_(self.valid_mask[:, : self.length])
282
+ logical[:, : self.length].copy_(self.logical_positions[:, : self.length])
283
+ rope[:, : self.length].copy_(self.rope_positions[:, : self.length])
284
+ self.valid_mask = valid
285
+ self.logical_positions = logical
286
+ self.rope_positions = rope
287
+ self.capacity = target
288
+
289
+ def append(
290
+ self,
291
+ *,
292
+ valid_mask: torch.Tensor,
293
+ logical_positions: torch.Tensor,
294
+ rope_positions: torch.Tensor,
295
+ ) -> None:
296
+ token_count = int(valid_mask.shape[1])
297
+ required = self.length + token_count
298
+ self._ensure_capacity(required, device=valid_mask.device)
299
+ start, end = self.length, required
300
+ self.valid_mask[:, start:end].copy_(valid_mask)
301
+ self.logical_positions[:, start:end].copy_(logical_positions)
302
+ self.rope_positions[:, start:end].copy_(rope_positions)
303
+ invalid = ~valid_mask
304
+ self.logical_positions[:, start:end].masked_fill_(invalid, -1)
305
+ self.rope_positions[:, start:end].masked_fill_(invalid, -1)
306
+ self.length = required
307
+
308
+ def crop(self, target_length: int) -> None:
309
+ self.length = min(self.length, target_length)
310
+ if self.capacity == 0:
311
+ return
312
+ self.valid_mask[:, self.length :].zero_()
313
+ self.logical_positions[:, self.length :].fill_(-1)
314
+ self.rope_positions[:, self.length :].fill_(-1)
315
+
316
+ def reset(self) -> None:
317
+ self.length = 0
318
+ if self.capacity == 0:
319
+ return
320
+ self.valid_mask.zero_()
321
+ self.logical_positions.fill_(-1)
322
+ self.rope_positions.fill_(-1)
323
+
324
+ def index_select(self, indices: torch.LongTensor) -> None:
325
+ if self.capacity == 0:
326
+ self.batch_size = int(indices.numel())
327
+ return
328
+ local_indices = indices.to(device=self.valid_mask.device)
329
+ self.valid_mask = self.valid_mask.index_select(0, local_indices)
330
+ self.logical_positions = self.logical_positions.index_select(0, local_indices)
331
+ self.rope_positions = self.rope_positions.index_select(0, local_indices)
332
+ self.batch_size = int(indices.numel())
333
+
334
+ def repeat_interleave(self, repeats: int) -> None:
335
+ if self.capacity > 0:
336
+ self.valid_mask = self.valid_mask.repeat_interleave(repeats, dim=0)
337
+ self.logical_positions = self.logical_positions.repeat_interleave(
338
+ repeats, dim=0
339
+ )
340
+ self.rope_positions = self.rope_positions.repeat_interleave(repeats, dim=0)
341
+ self.batch_size *= repeats
342
+
343
+
344
+ class _SidecarBuffer:
345
+ # Sidecar K/V uses packed-runtime order [B, capacity, H, D]. The native HF
346
+ # cache remains compact Qwen order [B, H_kv, T, D].
347
+ def __init__(self, *, batch_size: int, initial_capacity: int) -> None:
348
+ self.batch_size = batch_size
349
+ self.initial_capacity = initial_capacity
350
+ self.length = 0
351
+ self.capacity = 0
352
+ self.keys: torch.Tensor | None = None
353
+ self.values: torch.Tensor | None = None
354
+ self.valid_mask: torch.Tensor | None = None
355
+ self.physical_positions: torch.Tensor | None = None
356
+ self.logical_positions: torch.Tensor | None = None
357
+ self.rope_positions: torch.Tensor | None = None
358
+
359
+ def _ensure_capacity(self, required: int, *, reference: torch.Tensor) -> None:
360
+ target = _geometric_capacity(self.capacity, required, self.initial_capacity)
361
+ if target == self.capacity:
362
+ if (
363
+ self.keys.device != reference.device
364
+ or self.keys.dtype != reference.dtype
365
+ ):
366
+ raise ValueError(
367
+ "Sidecar append dtype/device changed: "
368
+ f"expected {self.keys.dtype} on {self.keys.device}, got {reference.dtype} on {reference.device}."
369
+ )
370
+ return
371
+
372
+ _, _, num_heads, head_dim = reference.shape
373
+ keys = torch.zeros(
374
+ (self.batch_size, target, num_heads, head_dim),
375
+ dtype=reference.dtype,
376
+ device=reference.device,
377
+ )
378
+ values = torch.zeros_like(keys)
379
+ valid = torch.zeros(
380
+ (self.batch_size, target), dtype=torch.bool, device=reference.device
381
+ )
382
+ physical = torch.full(
383
+ (self.batch_size, target), -1, dtype=torch.long, device=reference.device
384
+ )
385
+ logical = torch.full_like(physical, -1)
386
+ rope = torch.full_like(physical, -1)
387
+ if self.length > 0:
388
+ if (
389
+ self.keys.device != reference.device
390
+ or self.keys.dtype != reference.dtype
391
+ ):
392
+ raise ValueError(
393
+ "Sidecar append dtype/device changed: "
394
+ f"expected {self.keys.dtype} on {self.keys.device}, got {reference.dtype} on {reference.device}."
395
+ )
396
+ if tuple(self.keys.shape[2:]) != (num_heads, head_dim):
397
+ raise ValueError(
398
+ "Sidecar head shape changed: "
399
+ f"expected {tuple(self.keys.shape[2:])}, got {(num_heads, head_dim)}."
400
+ )
401
+ keys[:, : self.length].copy_(self.keys[:, : self.length])
402
+ values[:, : self.length].copy_(self.values[:, : self.length])
403
+ valid[:, : self.length].copy_(self.valid_mask[:, : self.length])
404
+ physical[:, : self.length].copy_(self.physical_positions[:, : self.length])
405
+ logical[:, : self.length].copy_(self.logical_positions[:, : self.length])
406
+ rope[:, : self.length].copy_(self.rope_positions[:, : self.length])
407
+ self.keys = keys
408
+ self.values = values
409
+ self.valid_mask = valid
410
+ self.physical_positions = physical
411
+ self.logical_positions = logical
412
+ self.rope_positions = rope
413
+ self.capacity = target
414
+
415
+ def append(
416
+ self,
417
+ *,
418
+ keys: torch.Tensor,
419
+ values: torch.Tensor,
420
+ valid_mask: torch.Tensor,
421
+ physical_positions: torch.Tensor,
422
+ logical_positions: torch.Tensor,
423
+ rope_positions: torch.Tensor,
424
+ ) -> None:
425
+ required = self.length + int(keys.shape[1])
426
+ self._ensure_capacity(required, reference=keys)
427
+ if tuple(self.keys.shape[2:]) != tuple(keys.shape[2:]):
428
+ raise ValueError(
429
+ f"Sidecar head shape changed: expected {tuple(self.keys.shape[2:])}, got {tuple(keys.shape[2:])}."
430
+ )
431
+ start, end = self.length, required
432
+ self.keys[:, start:end].copy_(keys)
433
+ self.values[:, start:end].copy_(values)
434
+ self.valid_mask[:, start:end].copy_(valid_mask)
435
+ self.physical_positions[:, start:end].copy_(physical_positions)
436
+ self.logical_positions[:, start:end].copy_(logical_positions)
437
+ self.rope_positions[:, start:end].copy_(rope_positions)
438
+ invalid = ~valid_mask
439
+ expanded_invalid = invalid[:, :, None, None]
440
+ self.keys[:, start:end].masked_fill_(expanded_invalid, 0)
441
+ self.values[:, start:end].masked_fill_(expanded_invalid, 0)
442
+ self.physical_positions[:, start:end].masked_fill_(invalid, -1)
443
+ self.logical_positions[:, start:end].masked_fill_(invalid, -1)
444
+ self.rope_positions[:, start:end].masked_fill_(invalid, -1)
445
+ self.length = required
446
+
447
+ def view(self) -> MoTSidecarView:
448
+ if self.capacity == 0:
449
+ raise RuntimeError("Sidecar buffer is not initialized.")
450
+ return MoTSidecarView(
451
+ keys=self.keys[:, : self.length],
452
+ values=self.values[:, : self.length],
453
+ valid_mask=self.valid_mask[:, : self.length],
454
+ physical_positions=self.physical_positions[:, : self.length],
455
+ logical_positions=self.logical_positions[:, : self.length],
456
+ rope_positions=self.rope_positions[:, : self.length],
457
+ capacity=self.capacity,
458
+ )
459
+
460
+ def crop_physical(self, target_length: int) -> None:
461
+ if self.capacity == 0:
462
+ return
463
+ remove = self.valid_mask[:, : self.length] & self.physical_positions[
464
+ :, : self.length
465
+ ].ge(target_length)
466
+ if torch.any(remove):
467
+ expanded_remove = remove[:, :, None, None]
468
+ self.keys[:, : self.length].masked_fill_(expanded_remove, 0)
469
+ self.values[:, : self.length].masked_fill_(expanded_remove, 0)
470
+ self.valid_mask[:, : self.length].masked_fill_(remove, False)
471
+ self.physical_positions[:, : self.length].masked_fill_(remove, -1)
472
+ self.logical_positions[:, : self.length].masked_fill_(remove, -1)
473
+ self.rope_positions[:, : self.length].masked_fill_(remove, -1)
474
+ while self.length > 0 and not bool(
475
+ torch.any(self.valid_mask[:, self.length - 1]).item()
476
+ ):
477
+ self.length -= 1
478
+
479
+ def truncate(self, target_length: int) -> None:
480
+ if target_length < 0 or target_length > self.length:
481
+ raise ValueError(
482
+ f"Sidecar target_length must be in [0, {self.length}], got {target_length}."
483
+ )
484
+ if target_length == self.length:
485
+ return
486
+ self.keys[:, target_length : self.length].zero_()
487
+ self.values[:, target_length : self.length].zero_()
488
+ self.valid_mask[:, target_length : self.length].zero_()
489
+ self.physical_positions[:, target_length : self.length].fill_(-1)
490
+ self.logical_positions[:, target_length : self.length].fill_(-1)
491
+ self.rope_positions[:, target_length : self.length].fill_(-1)
492
+ self.length = target_length
493
+
494
+ def reset(self) -> None:
495
+ self.length = 0
496
+ if self.capacity == 0:
497
+ return
498
+ self.keys.zero_()
499
+ self.values.zero_()
500
+ self.valid_mask.zero_()
501
+ self.physical_positions.fill_(-1)
502
+ self.logical_positions.fill_(-1)
503
+ self.rope_positions.fill_(-1)
504
+
505
+ def index_select(self, indices: torch.LongTensor) -> None:
506
+ if self.capacity == 0:
507
+ self.batch_size = int(indices.numel())
508
+ return
509
+ local_indices = indices.to(device=self.keys.device)
510
+ self.keys = self.keys.index_select(0, local_indices)
511
+ self.values = self.values.index_select(0, local_indices)
512
+ self.valid_mask = self.valid_mask.index_select(0, local_indices)
513
+ self.physical_positions = self.physical_positions.index_select(0, local_indices)
514
+ self.logical_positions = self.logical_positions.index_select(0, local_indices)
515
+ self.rope_positions = self.rope_positions.index_select(0, local_indices)
516
+ self.batch_size = int(indices.numel())
517
+
518
+ def repeat_interleave(self, repeats: int) -> None:
519
+ if self.capacity > 0:
520
+ self.keys = self.keys.repeat_interleave(repeats, dim=0)
521
+ self.values = self.values.repeat_interleave(repeats, dim=0)
522
+ self.valid_mask = self.valid_mask.repeat_interleave(repeats, dim=0)
523
+ self.physical_positions = self.physical_positions.repeat_interleave(
524
+ repeats, dim=0
525
+ )
526
+ self.logical_positions = self.logical_positions.repeat_interleave(
527
+ repeats, dim=0
528
+ )
529
+ self.rope_positions = self.rope_positions.repeat_interleave(repeats, dim=0)
530
+ self.batch_size *= repeats
531
+
532
+
533
+ class _GeometricDynamicLayer(DynamicLayer):
534
+ """HF-compatible dynamic layer whose appends reuse geometrically grown storage."""
535
+
536
+ def __init__(self, *, initial_capacity: int) -> None:
537
+ super().__init__()
538
+ self._initial_capacity = int(initial_capacity)
539
+ self._length = 0
540
+ self._capacity = 0
541
+ self._key_storage: torch.Tensor | None = None
542
+ self._value_storage: torch.Tensor | None = None
543
+
544
+ @property
545
+ def capacity(self) -> int:
546
+ return self._capacity
547
+
548
+ def lazy_initialization(self, key_states: torch.Tensor) -> None:
549
+ self.dtype = key_states.dtype
550
+ self.device = key_states.device
551
+ self.keys = key_states.new_empty(
552
+ (*key_states.shape[:-2], 0, key_states.shape[-1])
553
+ )
554
+ self.values = self.keys.clone()
555
+ self.is_initialized = True
556
+
557
+ def _refresh_views(self) -> None:
558
+ if self._key_storage is None or self._value_storage is None:
559
+ return
560
+ self.keys = self._key_storage[..., : self._length, :]
561
+ self.values = self._value_storage[..., : self._length, :]
562
+
563
+ def _ensure_capacity(self, key_states: torch.Tensor, required: int) -> None:
564
+ if required <= self._capacity:
565
+ return
566
+ if self._capacity == 0:
567
+ capacity = max(
568
+ self._initial_capacity,
569
+ required + max(64, required // 8),
570
+ )
571
+ else:
572
+ capacity = max(required, self._capacity * 2)
573
+ storage_shape = (*key_states.shape[:-2], capacity, key_states.shape[-1])
574
+ key_storage = key_states.new_empty(storage_shape)
575
+ value_storage = key_states.new_empty(storage_shape)
576
+ if self._length > 0:
577
+ key_storage[..., : self._length, :].copy_(
578
+ self._key_storage[..., : self._length, :]
579
+ )
580
+ value_storage[..., : self._length, :].copy_(
581
+ self._value_storage[..., : self._length, :]
582
+ )
583
+ self._key_storage = key_storage
584
+ self._value_storage = value_storage
585
+ self._capacity = capacity
586
+
587
+ def update(
588
+ self,
589
+ key_states: torch.Tensor,
590
+ value_states: torch.Tensor,
591
+ cache_kwargs: Mapping[str, object] | None = None,
592
+ ) -> tuple[torch.Tensor, torch.Tensor]:
593
+ del cache_kwargs
594
+ if not self.is_initialized:
595
+ self.lazy_initialization(key_states)
596
+ if tuple(key_states.shape) != tuple(value_states.shape):
597
+ raise ValueError(
598
+ f"Native layer K/V shape mismatch: keys={tuple(key_states.shape)}, values={tuple(value_states.shape)}."
599
+ )
600
+ if self._length > 0 and (
601
+ key_states.dtype != self._key_storage.dtype
602
+ or key_states.device != self._key_storage.device
603
+ or tuple(key_states.shape[:-2]) != tuple(self._key_storage.shape[:-2])
604
+ or int(key_states.shape[-1]) != int(self._key_storage.shape[-1])
605
+ ):
606
+ raise ValueError(
607
+ "Native layer append dtype, device, batch, head, and head_dim must stay fixed."
608
+ )
609
+ token_count = int(key_states.shape[-2])
610
+ required = self._length + token_count
611
+ self._ensure_capacity(key_states, required)
612
+ self._key_storage[..., self._length : required, :].copy_(key_states)
613
+ self._value_storage[..., self._length : required, :].copy_(value_states)
614
+ self._length = required
615
+ self._refresh_views()
616
+ return self.keys, self.values
617
+
618
+ def get_seq_length(self) -> int:
619
+ return self._length
620
+
621
+ def crop(self, max_length: int) -> None:
622
+ if max_length < 0:
623
+ max_length = self._length - abs(max_length)
624
+ self._length = max(0, min(self._length, int(max_length)))
625
+ self._refresh_views()
626
+
627
+ def reset(self) -> None:
628
+ if self._length > 0:
629
+ self._key_storage[..., : self._length, :].zero_()
630
+ self._value_storage[..., : self._length, :].zero_()
631
+ self._length = 0
632
+ self._refresh_views()
633
+
634
+ def _select_batch(self, indices: torch.LongTensor) -> None:
635
+ if self._capacity == 0:
636
+ return
637
+ local_indices = indices.to(device=self._key_storage.device)
638
+ self._key_storage = self._key_storage.index_select(0, local_indices)
639
+ self._value_storage = self._value_storage.index_select(0, local_indices)
640
+ self._refresh_views()
641
+
642
+ def reorder_cache(self, beam_idx: torch.LongTensor) -> None:
643
+ self._select_batch(beam_idx)
644
+
645
+ def batch_select_indices(self, indices: torch.Tensor) -> None:
646
+ self._select_batch(indices.to(dtype=torch.long))
647
+
648
+ def batch_repeat_interleave(self, repeats: int) -> None:
649
+ if self._capacity > 0:
650
+ self._key_storage = self._key_storage.repeat_interleave(repeats, dim=0)
651
+ self._value_storage = self._value_storage.repeat_interleave(repeats, dim=0)
652
+ self._refresh_views()
653
+
654
+ def offload(self) -> None:
655
+ if self._capacity > 0:
656
+ self._key_storage = self._key_storage.to("cpu", non_blocking=True)
657
+ self._value_storage = self._value_storage.to("cpu", non_blocking=True)
658
+ self._refresh_views()
659
+
660
+ def prefetch(self) -> None:
661
+ if self._capacity > 0 and self._key_storage.device != self.device:
662
+ self._key_storage = self._key_storage.to(self.device, non_blocking=True)
663
+ self._value_storage = self._value_storage.to(self.device, non_blocking=True)
664
+ self._refresh_views()
665
+
666
+
667
+ class MoTDynamicCache(DynamicCache):
668
+ """HF dynamic cache plus batch-stable MoT runtime sidecars."""
669
+
670
+ is_mot_generation_cache = True
671
+
672
+ def __init__(
673
+ self,
674
+ *,
675
+ request_ids: Sequence[_RequestId],
676
+ config=None,
677
+ initial_sidecar_capacity: int = 16,
678
+ offloading: bool = False,
679
+ offload_only_non_sliding: bool = False,
680
+ ) -> None:
681
+ if initial_sidecar_capacity <= 0:
682
+ raise ValueError(
683
+ f"initial_sidecar_capacity must be positive, got {initial_sidecar_capacity}."
684
+ )
685
+ if offloading or offload_only_non_sliding:
686
+ raise RuntimeError(
687
+ "MoTDynamicCache does not support offloading because runtime sidecars must remain colocated."
688
+ )
689
+ super().__init__(
690
+ config=config,
691
+ offloading=False,
692
+ offload_only_non_sliding=False,
693
+ )
694
+ if any(bool(getattr(layer, "is_sliding", False)) for layer in self.layers):
695
+ raise RuntimeError(
696
+ "MoTDynamicCache does not support sliding-window decoder layers."
697
+ )
698
+ self.layers = [
699
+ _GeometricDynamicLayer(initial_capacity=initial_sidecar_capacity)
700
+ for _layer in self.layers
701
+ ]
702
+ # Config-less caches are still supported: `update` appends the same
703
+ # geometric layer type instead of DynamicCache's concatenating default.
704
+ self.layer_class_to_replicate = None
705
+ self._request_ids = _normalize_request_ids(request_ids)
706
+ self._batch_size = len(self._request_ids)
707
+ self._initial_sidecar_capacity = int(initial_sidecar_capacity)
708
+ self._position_buffer = _PositionBuffer(
709
+ batch_size=self._batch_size,
710
+ initial_capacity=self._initial_sidecar_capacity,
711
+ )
712
+ self._sidecars: dict[str, dict[int, _SidecarBuffer]] = {}
713
+ self._physical_length = 0
714
+ self._row_logical_next_positions = torch.zeros(
715
+ self._batch_size, dtype=torch.long
716
+ )
717
+ self._row_rope_next_positions = torch.zeros(self._batch_size, dtype=torch.long)
718
+ self._payload_fingerprints: tuple[str, ...] | None = None
719
+ self._payload_epochs = torch.zeros(self._batch_size, dtype=torch.long)
720
+ self._config = (
721
+ config.get_text_config(decoder=True) if config is not None else None
722
+ )
723
+ # Cached mixed generation owns these request-local values. Declaring the
724
+ # complete schema here keeps cache lifetime and reset behavior explicit.
725
+ # `_streams` is the single registry for every incremental KV stream,
726
+ # keyed by (kind, row, layer, span):
727
+ # kind="llm"/"residual": layer is the Qwen layer index, span is None,
728
+ # and the stream lives for the whole request row.
729
+ # kind="ts": layer is the TimesFM layer index, span is the target
730
+ # slot, and the stream lives for one generated span.
731
+ # `None` means no incremental runtime streams are currently bound.
732
+ self._streams: dict[StreamKey, dict[str, object]] | None = None
733
+ self._mot_active_segment_ids: tuple[int, ...] = ()
734
+ self._mot_identity_runtime: bool = False
735
+ # Non-KV per-span decode state (hidden, patch_count, target, ...).
736
+ self._span_states: dict[tuple[int, int], dict[str, object]] = {}
737
+ self._mot_pending_text_logits: torch.Tensor | None = None
738
+ self._mot_stats: list[dict[str, int]] = []
739
+ self._mot_pad_token_id: int = 0
740
+
741
+ @classmethod
742
+ def from_legacy_cache(
743
+ cls,
744
+ past_key_values: tuple[tuple[torch.Tensor, torch.Tensor], ...],
745
+ ) -> "MoTDynamicCache":
746
+ del past_key_values
747
+ raise RuntimeError(
748
+ "MoT cannot recover runtime, position, and payload sidecars from a legacy tuple cache."
749
+ )
750
+
751
+ def to_legacy_cache(self) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]:
752
+ raise RuntimeError(
753
+ "MoT cache cannot be converted to a legacy tuple without losing required sidecars."
754
+ )
755
+
756
+ def offload(self, layer_idx: int, only_non_sliding: bool = True) -> None:
757
+ del layer_idx, only_non_sliding
758
+ raise RuntimeError(
759
+ "MoTDynamicCache does not support offloading required MoT sidecars."
760
+ )
761
+
762
+ def prefetch(self, layer_idx: int, only_non_sliding: bool = True) -> None:
763
+ del layer_idx, only_non_sliding
764
+ raise RuntimeError(
765
+ "MoTDynamicCache does not support offloaded sidecar prefetch."
766
+ )
767
+
768
+ @property
769
+ def request_ids(self) -> tuple[_RequestId, ...]:
770
+ return self._request_ids
771
+
772
+ @property
773
+ def batch_size(self) -> int:
774
+ return self._batch_size
775
+
776
+ @property
777
+ def physical_length(self) -> int:
778
+ return self._physical_length
779
+
780
+ @property
781
+ def row_logical_next_positions(self) -> torch.LongTensor:
782
+ return self._row_logical_next_positions
783
+
784
+ @property
785
+ def row_rope_next_positions(self) -> torch.LongTensor:
786
+ return self._row_rope_next_positions
787
+
788
+ @property
789
+ def payload_fingerprints(self) -> tuple[str, ...] | None:
790
+ return self._payload_fingerprints
791
+
792
+ @property
793
+ def payload_epochs(self) -> tuple[int, ...]:
794
+ return tuple(int(epoch) for epoch in self._payload_epochs.tolist())
795
+
796
+ @property
797
+ def has_streams(self) -> bool:
798
+ return self._streams is not None
799
+
800
+ def bind_streams(self, streams: Mapping[StreamKey, dict]) -> None:
801
+ """Bind the complete prefill stream registry exactly once."""
802
+ if self._streams is not None:
803
+ raise RuntimeError(
804
+ "MoT stream registry is already bound; reset the cache first."
805
+ )
806
+ validated: dict[StreamKey, dict[str, object]] = {}
807
+ for key, entry in streams.items():
808
+ _validate_stream_key(key)
809
+ if not isinstance(entry, dict):
810
+ raise TypeError(
811
+ f"Stream entry for {key!r} must be a dict, got {type(entry).__name__}."
812
+ )
813
+ if int(key[1]) >= self._batch_size:
814
+ raise ValueError(
815
+ f"Stream key {key!r} row exceeds cache batch size {self._batch_size}."
816
+ )
817
+ validated[key] = entry
818
+ self._streams = validated
819
+
820
+ def add_stream(self, key: StreamKey, entry: dict) -> None:
821
+ """Register one lazily created stream (e.g. a new TS target layer)."""
822
+ if self._streams is None:
823
+ raise RuntimeError("Cannot add a stream before the registry is bound.")
824
+ _validate_stream_key(key)
825
+ if key in self._streams:
826
+ raise RuntimeError(f"Stream {key!r} is already registered.")
827
+ if not isinstance(entry, dict):
828
+ raise TypeError(
829
+ f"Stream entry for {key!r} must be a dict, got {type(entry).__name__}."
830
+ )
831
+ self._streams[key] = entry
832
+
833
+ def get_stream(self, key: StreamKey) -> dict | None:
834
+ """Fetch one stream; None means the key is legitimately absent (e.g. unpaired layer)."""
835
+ if self._streams is None:
836
+ raise RuntimeError("MoT stream registry is not bound.")
837
+ return self._streams.get(_validate_stream_key(key))
838
+
839
+ def require_stream(self, key: StreamKey) -> dict:
840
+ stream = self.get_stream(key)
841
+ if stream is None:
842
+ raise RuntimeError(f"MoT stream registry has no entry for {key!r}.")
843
+ return stream
844
+
845
+ def iter_streams(
846
+ self,
847
+ *,
848
+ kind: str | None = None,
849
+ row: int | None = None,
850
+ span: int | None = None,
851
+ ):
852
+ """Yield (key, entry) pairs; the single enumeration point for reset/rollback/stats."""
853
+ if self._streams is None:
854
+ return
855
+ for key, entry in self._streams.items():
856
+ if kind is not None and key[0] != kind:
857
+ continue
858
+ if row is not None and key[1] != row:
859
+ continue
860
+ if span is not None and key[3] != span:
861
+ continue
862
+ yield key, entry
863
+
864
+ def _validate_native_kv(
865
+ self,
866
+ key_states: torch.Tensor,
867
+ value_states: torch.Tensor,
868
+ layer_idx: int,
869
+ cache_kwargs: Mapping[str, object] | None,
870
+ ) -> torch.LongTensor:
871
+ if not isinstance(key_states, torch.Tensor) or not isinstance(
872
+ value_states, torch.Tensor
873
+ ):
874
+ raise TypeError("Native cache key_states and value_states must be tensors.")
875
+ if key_states.ndim != 4 or value_states.ndim != 4:
876
+ raise ValueError(
877
+ "Native Qwen K/V must have shape [B,H_kv,T,D], got "
878
+ f"keys={tuple(key_states.shape)}, values={tuple(value_states.shape)}."
879
+ )
880
+ if tuple(key_states.shape) != tuple(value_states.shape):
881
+ raise ValueError(
882
+ f"Native Qwen K/V shape mismatch: keys={tuple(key_states.shape)}, values={tuple(value_states.shape)}."
883
+ )
884
+ if (
885
+ key_states.dtype != value_states.dtype
886
+ or key_states.device != value_states.device
887
+ ):
888
+ raise ValueError(
889
+ "Native Qwen K/V dtype/device mismatch: "
890
+ f"keys={key_states.dtype}/{key_states.device}, values={value_states.dtype}/{value_states.device}."
891
+ )
892
+ if not torch.is_floating_point(key_states):
893
+ raise TypeError(
894
+ f"Native Qwen K/V must be floating point, got {key_states.dtype}."
895
+ )
896
+ if int(key_states.shape[0]) != self._batch_size:
897
+ raise ValueError(
898
+ f"Native Qwen K/V batch must match request_ids: expected {self._batch_size}, got {key_states.shape[0]}."
899
+ )
900
+ if int(key_states.shape[2]) <= 0:
901
+ raise ValueError("Native Qwen K/V update must contain at least one token.")
902
+ if layer_idx < 0 or (
903
+ self._config is not None
904
+ and layer_idx >= int(self._config.num_hidden_layers)
905
+ ):
906
+ raise ValueError(f"Native cache layer_idx out of range: {layer_idx}.")
907
+ if self._config is not None:
908
+ expected_heads = int(self._config.num_key_value_heads)
909
+ expected_head_dim = int(
910
+ getattr(
911
+ self._config,
912
+ "head_dim",
913
+ self._config.hidden_size // self._config.num_attention_heads,
914
+ )
915
+ )
916
+ if tuple(key_states.shape[1::2]) != (expected_heads, expected_head_dim):
917
+ raise ValueError(
918
+ "Native cache must keep compact Qwen KV heads: "
919
+ f"expected H_kv/D={(expected_heads, expected_head_dim)}, "
920
+ f"got {(int(key_states.shape[1]), int(key_states.shape[3]))}."
921
+ )
922
+
923
+ previous_length = int(self.get_seq_length(layer_idx))
924
+ token_count = int(key_states.shape[2])
925
+ raw_cache_position = (
926
+ None if cache_kwargs is None else cache_kwargs.get("cache_position")
927
+ )
928
+ if raw_cache_position is None:
929
+ cache_position = torch.arange(
930
+ previous_length,
931
+ previous_length + token_count,
932
+ device=key_states.device,
933
+ dtype=torch.long,
934
+ )
935
+ else:
936
+ if not isinstance(raw_cache_position, torch.Tensor):
937
+ raise TypeError(
938
+ "cache_position must be a tensor for native cache update, got "
939
+ f"{type(raw_cache_position).__name__}."
940
+ )
941
+ cache_position = _require_positions(
942
+ raw_cache_position,
943
+ name="cache_position",
944
+ shape=(token_count,),
945
+ device=key_states.device,
946
+ )
947
+ expected = torch.arange(
948
+ previous_length,
949
+ previous_length + token_count,
950
+ device=key_states.device,
951
+ dtype=torch.long,
952
+ )
953
+ if not torch.equal(cache_position, expected):
954
+ raise ValueError(
955
+ "Dynamic cache_position must be a contiguous append: "
956
+ f"expected {expected.tolist()}, got {cache_position.tolist()}."
957
+ )
958
+ return cache_position
959
+
960
+ def update(
961
+ self,
962
+ key_states: torch.Tensor,
963
+ value_states: torch.Tensor,
964
+ layer_idx: int,
965
+ cache_kwargs: Mapping[str, object] | None = None,
966
+ ) -> tuple[torch.Tensor, torch.Tensor]:
967
+ cache_position = self._validate_native_kv(
968
+ key_states, value_states, layer_idx, cache_kwargs
969
+ )
970
+ while len(self.layers) <= layer_idx:
971
+ self.layers.append(
972
+ _GeometricDynamicLayer(initial_capacity=self._initial_sidecar_capacity)
973
+ )
974
+ keys, values = super().update(
975
+ key_states, value_states, layer_idx, dict(cache_kwargs or {})
976
+ )
977
+ resulting_length = int(self.get_seq_length(layer_idx))
978
+ expected_length = int(cache_position[-1].item()) + 1
979
+ if resulting_length != expected_length:
980
+ raise RuntimeError(
981
+ f"Native cache length drifted at layer {layer_idx}: expected {expected_length}, got {resulting_length}."
982
+ )
983
+ if layer_idx == 0:
984
+ if self._physical_length not in (
985
+ int(cache_position[0].item()),
986
+ expected_length,
987
+ ):
988
+ raise RuntimeError(
989
+ "Native layer 0 update disagrees with physical_length: "
990
+ f"physical_length={self._physical_length}, cache_position={cache_position.tolist()}."
991
+ )
992
+ self._physical_length = expected_length
993
+ elif self._physical_length != expected_length:
994
+ raise RuntimeError(
995
+ "Native layers must update with the same physical timeline: "
996
+ f"layer={layer_idx}, layer_length={expected_length}, physical_length={self._physical_length}."
997
+ )
998
+ return keys, values
999
+
1000
+ def record_position_metadata(
1001
+ self,
1002
+ *,
1003
+ cache_position: torch.LongTensor,
1004
+ valid_mask: torch.Tensor,
1005
+ logical_positions: torch.LongTensor,
1006
+ rope_positions: torch.LongTensor,
1007
+ active_rows: torch.Tensor | None = None,
1008
+ ) -> None:
1009
+ if not isinstance(cache_position, torch.Tensor) or cache_position.ndim != 1:
1010
+ raise ValueError(
1011
+ "cache_position metadata must be a 1D tensor, got "
1012
+ f"{None if not isinstance(cache_position, torch.Tensor) else tuple(cache_position.shape)}."
1013
+ )
1014
+ token_count = int(cache_position.numel())
1015
+ if token_count <= 0:
1016
+ raise ValueError(
1017
+ "Position metadata must contain at least one physical token."
1018
+ )
1019
+ valid_mask = _require_bool_mask(
1020
+ valid_mask,
1021
+ name="valid_mask",
1022
+ shape=(self._batch_size, token_count),
1023
+ )
1024
+ if (
1025
+ cache_position.device != valid_mask.device
1026
+ or cache_position.dtype is not torch.long
1027
+ ):
1028
+ raise ValueError(
1029
+ "cache_position must be torch.long on the metadata device, got "
1030
+ f"{cache_position.dtype} on {cache_position.device}, metadata on {valid_mask.device}."
1031
+ )
1032
+ logical_positions = _require_positions(
1033
+ logical_positions,
1034
+ name="logical_positions",
1035
+ shape=(self._batch_size, token_count),
1036
+ device=valid_mask.device,
1037
+ )
1038
+ rope_positions = _require_positions(
1039
+ rope_positions,
1040
+ name="rope_positions",
1041
+ shape=(self._batch_size, token_count),
1042
+ device=valid_mask.device,
1043
+ )
1044
+ expected_start = self._position_buffer.length
1045
+ expected = torch.arange(
1046
+ expected_start,
1047
+ expected_start + token_count,
1048
+ device=cache_position.device,
1049
+ dtype=torch.long,
1050
+ )
1051
+ if not torch.equal(cache_position, expected):
1052
+ raise ValueError(
1053
+ "Position metadata must append contiguously: "
1054
+ f"expected {expected.tolist()}, got {cache_position.tolist()}."
1055
+ )
1056
+ if int(cache_position[-1].item()) >= self._physical_length:
1057
+ raise RuntimeError(
1058
+ "Position metadata cannot run ahead of native K/V: "
1059
+ f"last_position={int(cache_position[-1].item())}, physical_length={self._physical_length}."
1060
+ )
1061
+ if active_rows is not None:
1062
+ active_rows = _require_bool_mask(
1063
+ active_rows,
1064
+ name="active_rows",
1065
+ shape=(self._batch_size,),
1066
+ )
1067
+ if active_rows.device != valid_mask.device:
1068
+ raise ValueError(
1069
+ f"active_rows must be on {valid_mask.device}, got {active_rows.device}."
1070
+ )
1071
+ valid_mask = valid_mask & active_rows[:, None]
1072
+ if torch.any(logical_positions[valid_mask] < 0):
1073
+ raise ValueError("Valid logical_positions must be non-negative.")
1074
+ if torch.any(rope_positions[valid_mask] < 0):
1075
+ raise ValueError("Valid rope_positions must be non-negative.")
1076
+
1077
+ self._position_buffer.append(
1078
+ valid_mask=valid_mask,
1079
+ logical_positions=logical_positions,
1080
+ rope_positions=rope_positions,
1081
+ )
1082
+ if self._row_logical_next_positions.device != valid_mask.device:
1083
+ if expected_start != 0:
1084
+ raise RuntimeError(
1085
+ "Row logical position metadata changed device after prefill."
1086
+ )
1087
+ self._row_logical_next_positions = self._row_logical_next_positions.to(
1088
+ valid_mask.device
1089
+ )
1090
+ self._row_rope_next_positions = self._row_rope_next_positions.to(
1091
+ valid_mask.device
1092
+ )
1093
+ for row_idx in range(self._batch_size):
1094
+ row_valid = valid_mask[row_idx]
1095
+ if bool(torch.any(row_valid).item()):
1096
+ last_idx = int(torch.nonzero(row_valid, as_tuple=False)[-1].item())
1097
+ self._row_logical_next_positions[row_idx] = (
1098
+ logical_positions[row_idx, last_idx] + 1
1099
+ )
1100
+ self._row_rope_next_positions[row_idx] = (
1101
+ rope_positions[row_idx, last_idx] + 1
1102
+ )
1103
+
1104
+ def _write_sidecar(
1105
+ self,
1106
+ *,
1107
+ name: str,
1108
+ layer_idx: int,
1109
+ keys: torch.Tensor,
1110
+ values: torch.Tensor,
1111
+ valid_mask: torch.Tensor,
1112
+ physical_positions: torch.LongTensor,
1113
+ logical_positions: torch.LongTensor,
1114
+ rope_positions: torch.LongTensor,
1115
+ active_rows: torch.Tensor | None = None,
1116
+ replace_start: int | None = None,
1117
+ ) -> MoTSidecarView:
1118
+ if not isinstance(name, str) or not name:
1119
+ raise ValueError(f"Sidecar name must be a non-empty string, got {name!r}.")
1120
+ if layer_idx < 0:
1121
+ raise ValueError(
1122
+ f"Sidecar layer_idx must be non-negative, got {layer_idx}."
1123
+ )
1124
+ if not isinstance(keys, torch.Tensor) or not isinstance(values, torch.Tensor):
1125
+ raise TypeError("Sidecar keys and values must be tensors.")
1126
+ if keys.ndim != 4 or values.ndim != 4:
1127
+ raise ValueError(
1128
+ "Sidecar K/V must have shape [B,T,H,D], got "
1129
+ f"keys={tuple(keys.shape)}, values={tuple(values.shape)}."
1130
+ )
1131
+ if tuple(keys.shape) != tuple(values.shape):
1132
+ raise ValueError(
1133
+ f"Sidecar K/V shape mismatch: keys={tuple(keys.shape)}, values={tuple(values.shape)}."
1134
+ )
1135
+ if keys.dtype != values.dtype or keys.device != values.device:
1136
+ raise ValueError(
1137
+ "Sidecar K/V dtype/device mismatch: "
1138
+ f"keys={keys.dtype}/{keys.device}, values={values.dtype}/{values.device}."
1139
+ )
1140
+ if not torch.is_floating_point(keys):
1141
+ raise TypeError(f"Sidecar K/V must be floating point, got {keys.dtype}.")
1142
+ batch_size, token_count = int(keys.shape[0]), int(keys.shape[1])
1143
+ if batch_size != self._batch_size:
1144
+ raise ValueError(
1145
+ f"Sidecar batch must be {self._batch_size}, got {batch_size}."
1146
+ )
1147
+ if token_count <= 0 or int(keys.shape[2]) <= 0 or int(keys.shape[3]) <= 0:
1148
+ raise ValueError(
1149
+ f"Sidecar K/V dimensions must be positive, got {tuple(keys.shape)}."
1150
+ )
1151
+ valid_mask = _require_bool_mask(
1152
+ valid_mask,
1153
+ name="valid_mask",
1154
+ shape=(self._batch_size, token_count),
1155
+ )
1156
+ if valid_mask.device != keys.device:
1157
+ raise ValueError(
1158
+ f"valid_mask must be on {keys.device}, got {valid_mask.device}."
1159
+ )
1160
+ if active_rows is not None:
1161
+ active_rows = _require_bool_mask(
1162
+ active_rows,
1163
+ name="active_rows",
1164
+ shape=(self._batch_size,),
1165
+ )
1166
+ if active_rows.device != keys.device:
1167
+ raise ValueError(
1168
+ f"active_rows must be on {keys.device}, got {active_rows.device}."
1169
+ )
1170
+ valid_mask = valid_mask & active_rows[:, None]
1171
+ physical_positions = _expanded_positions(
1172
+ physical_positions,
1173
+ batch_size=self._batch_size,
1174
+ token_count=token_count,
1175
+ name="physical_positions",
1176
+ device=keys.device,
1177
+ )
1178
+ logical_positions = _require_positions(
1179
+ logical_positions,
1180
+ name="logical_positions",
1181
+ shape=(self._batch_size, token_count),
1182
+ device=keys.device,
1183
+ )
1184
+ rope_positions = _require_positions(
1185
+ rope_positions,
1186
+ name="rope_positions",
1187
+ shape=(self._batch_size, token_count),
1188
+ device=keys.device,
1189
+ )
1190
+ for positions, position_name in (
1191
+ (physical_positions, "physical_positions"),
1192
+ (logical_positions, "logical_positions"),
1193
+ (rope_positions, "rope_positions"),
1194
+ ):
1195
+ if torch.any(positions[valid_mask] < 0):
1196
+ raise ValueError(f"Valid {position_name} must be non-negative.")
1197
+ if torch.any(physical_positions[valid_mask] >= self._physical_length):
1198
+ raise ValueError(
1199
+ "Sidecar physical_positions must refer to existing native cache columns: "
1200
+ f"physical_length={self._physical_length}."
1201
+ )
1202
+
1203
+ layer_buffers = self._sidecars.setdefault(name, {})
1204
+ buffer = layer_buffers.get(layer_idx)
1205
+ if buffer is None:
1206
+ if replace_start is not None:
1207
+ raise KeyError(
1208
+ f"Cannot replace absent MoT sidecar {name!r} at layer {layer_idx}."
1209
+ )
1210
+ buffer = _SidecarBuffer(
1211
+ batch_size=self._batch_size,
1212
+ initial_capacity=self._initial_sidecar_capacity,
1213
+ )
1214
+ layer_buffers[layer_idx] = buffer
1215
+ if replace_start is not None:
1216
+ if isinstance(replace_start, bool) or not isinstance(replace_start, int):
1217
+ raise TypeError(
1218
+ f"replace_start must be an integer, got {type(replace_start).__name__}."
1219
+ )
1220
+ if buffer.capacity > 0 and (
1221
+ buffer.keys.dtype != keys.dtype
1222
+ or buffer.keys.device != keys.device
1223
+ or tuple(buffer.keys.shape[2:]) != tuple(keys.shape[2:])
1224
+ ):
1225
+ raise ValueError(
1226
+ "Replacement sidecar dtype/device/head shape must match the existing stream: "
1227
+ f"existing={buffer.keys.dtype}/{buffer.keys.device}/{tuple(buffer.keys.shape[2:])}, "
1228
+ f"replacement={keys.dtype}/{keys.device}/{tuple(keys.shape[2:])}."
1229
+ )
1230
+ buffer.truncate(replace_start)
1231
+ buffer.append(
1232
+ keys=keys,
1233
+ values=values,
1234
+ valid_mask=valid_mask,
1235
+ physical_positions=physical_positions,
1236
+ logical_positions=logical_positions,
1237
+ rope_positions=rope_positions,
1238
+ )
1239
+ return buffer.view()
1240
+
1241
+ def append_sidecar(
1242
+ self,
1243
+ *,
1244
+ name: str,
1245
+ layer_idx: int,
1246
+ keys: torch.Tensor,
1247
+ values: torch.Tensor,
1248
+ valid_mask: torch.Tensor,
1249
+ physical_positions: torch.LongTensor,
1250
+ logical_positions: torch.LongTensor,
1251
+ rope_positions: torch.LongTensor,
1252
+ active_rows: torch.Tensor | None = None,
1253
+ ) -> MoTSidecarView:
1254
+ return self._write_sidecar(
1255
+ name=name,
1256
+ layer_idx=layer_idx,
1257
+ keys=keys,
1258
+ values=values,
1259
+ valid_mask=valid_mask,
1260
+ physical_positions=physical_positions,
1261
+ logical_positions=logical_positions,
1262
+ rope_positions=rope_positions,
1263
+ active_rows=active_rows,
1264
+ )
1265
+
1266
+ def rollback_sidecar(
1267
+ self, name: str, layer_idx: int, *, target_length: int
1268
+ ) -> MoTSidecarView:
1269
+ """Drop one mutable sidecar tail without touching native or sibling streams."""
1270
+ try:
1271
+ buffer = self._sidecars[name][layer_idx]
1272
+ except KeyError as exc:
1273
+ raise KeyError(
1274
+ f"No MoT sidecar named {name!r} at layer {layer_idx}."
1275
+ ) from exc
1276
+ if isinstance(target_length, bool) or not isinstance(target_length, int):
1277
+ raise TypeError(
1278
+ f"target_length must be an integer, got {type(target_length).__name__}."
1279
+ )
1280
+ buffer.truncate(target_length)
1281
+ return buffer.view()
1282
+
1283
+ def replace_sidecar_tail(
1284
+ self,
1285
+ *,
1286
+ name: str,
1287
+ layer_idx: int,
1288
+ start: int,
1289
+ keys: torch.Tensor,
1290
+ values: torch.Tensor,
1291
+ valid_mask: torch.Tensor,
1292
+ physical_positions: torch.LongTensor,
1293
+ logical_positions: torch.LongTensor,
1294
+ rope_positions: torch.LongTensor,
1295
+ active_rows: torch.Tensor | None = None,
1296
+ ) -> MoTSidecarView:
1297
+ """Validate a replacement, then atomically discard and rewrite one stream tail."""
1298
+ return self._write_sidecar(
1299
+ name=name,
1300
+ layer_idx=layer_idx,
1301
+ keys=keys,
1302
+ values=values,
1303
+ valid_mask=valid_mask,
1304
+ physical_positions=physical_positions,
1305
+ logical_positions=logical_positions,
1306
+ rope_positions=rope_positions,
1307
+ active_rows=active_rows,
1308
+ replace_start=start,
1309
+ )
1310
+
1311
+ def get_sidecar(self, name: str, layer_idx: int) -> MoTSidecarView:
1312
+ try:
1313
+ return self._sidecars[name][layer_idx].view()
1314
+ except KeyError as exc:
1315
+ raise KeyError(
1316
+ f"No MoT sidecar named {name!r} at layer {layer_idx}."
1317
+ ) from exc
1318
+
1319
+ def bind_payload_fingerprints(self, fingerprints: Sequence[str]) -> None:
1320
+ normalized = tuple(fingerprints)
1321
+ if len(normalized) != self._batch_size or any(
1322
+ not isinstance(value, str) or not value for value in normalized
1323
+ ):
1324
+ raise ValueError(
1325
+ f"Payload fingerprints must contain {self._batch_size} non-empty strings, got {normalized}."
1326
+ )
1327
+ if self._payload_fingerprints is None:
1328
+ self._payload_fingerprints = normalized
1329
+ return
1330
+ self.validate_payload_fingerprints(normalized)
1331
+
1332
+ def validate_payload_fingerprints(self, fingerprints: Sequence[str]) -> None:
1333
+ if self._payload_fingerprints is None:
1334
+ raise RuntimeError("Payload fingerprints are not bound to this cache.")
1335
+ candidate = tuple(fingerprints)
1336
+ if len(candidate) != self._batch_size:
1337
+ raise ValueError(
1338
+ f"Expected {self._batch_size} payload fingerprints, got {len(candidate)}."
1339
+ )
1340
+ changed = [
1341
+ self._request_ids[row_idx]
1342
+ for row_idx, (cached, current) in enumerate(
1343
+ zip(self._payload_fingerprints, candidate, strict=True)
1344
+ )
1345
+ if cached != current
1346
+ ]
1347
+ if changed:
1348
+ raise RuntimeError(
1349
+ "MoT payload changed while KV cache was live; start a new payload epoch for request_ids="
1350
+ f"{changed}."
1351
+ )
1352
+
1353
+ def start_payload_epoch(self, fingerprints: Sequence[str]) -> None:
1354
+ if self._streams is not None:
1355
+ raise RuntimeError(
1356
+ "External payload epochs cannot preserve bound MoT runtime streams; start a fresh prefill cache."
1357
+ )
1358
+ if self._payload_fingerprints is None:
1359
+ raise RuntimeError(
1360
+ "Cannot advance payload epoch before binding initial fingerprints."
1361
+ )
1362
+ candidate = tuple(fingerprints)
1363
+ if len(candidate) != self._batch_size or any(
1364
+ not isinstance(value, str) or not value for value in candidate
1365
+ ):
1366
+ raise ValueError(
1367
+ f"Expected {self._batch_size} non-empty payload fingerprints, got {candidate}."
1368
+ )
1369
+ changed = torch.tensor(
1370
+ [
1371
+ cached != current
1372
+ for cached, current in zip(
1373
+ self._payload_fingerprints, candidate, strict=True
1374
+ )
1375
+ ],
1376
+ dtype=torch.bool,
1377
+ )
1378
+ if not bool(torch.any(changed).item()):
1379
+ raise ValueError(
1380
+ "start_payload_epoch requires at least one changed payload fingerprint."
1381
+ )
1382
+ next_epochs = self._payload_epochs + changed.to(dtype=torch.long)
1383
+ self._clear_content(clear_payload=False)
1384
+ self._payload_fingerprints = candidate
1385
+ self._payload_epochs = next_epochs
1386
+
1387
+ def commit_payload_mutation(
1388
+ self,
1389
+ fingerprints: Sequence[str],
1390
+ *,
1391
+ changed_rows: Sequence[int],
1392
+ ) -> None:
1393
+ """Record a payload mutation only after its TS and fusion streams were synchronized."""
1394
+ if self._payload_fingerprints is None:
1395
+ raise RuntimeError(
1396
+ "Cannot commit payload mutation before binding initial fingerprints."
1397
+ )
1398
+ candidate = tuple(fingerprints)
1399
+ if len(candidate) != self._batch_size or any(
1400
+ not isinstance(value, str) or not value for value in candidate
1401
+ ):
1402
+ raise ValueError(
1403
+ f"Expected {self._batch_size} non-empty payload fingerprints, got {candidate}."
1404
+ )
1405
+ normalized_rows = tuple(int(row_idx) for row_idx in changed_rows)
1406
+ if len(set(normalized_rows)) != len(normalized_rows):
1407
+ raise ValueError(f"changed_rows must be unique, got {normalized_rows}.")
1408
+ if any(
1409
+ row_idx < 0 or row_idx >= self._batch_size for row_idx in normalized_rows
1410
+ ):
1411
+ raise IndexError(
1412
+ f"changed_rows must stay in [0,{self._batch_size}), got {normalized_rows}."
1413
+ )
1414
+ observed_changed = {
1415
+ row_idx
1416
+ for row_idx, (cached, current) in enumerate(
1417
+ zip(self._payload_fingerprints, candidate, strict=True)
1418
+ )
1419
+ if cached != current
1420
+ }
1421
+ expected_changed = set(normalized_rows)
1422
+ if observed_changed != expected_changed:
1423
+ raise RuntimeError(
1424
+ "Controlled payload mutation rows do not match fingerprint changes: "
1425
+ f"expected={sorted(expected_changed)}, observed={sorted(observed_changed)}."
1426
+ )
1427
+ if not observed_changed:
1428
+ raise ValueError(
1429
+ "commit_payload_mutation requires at least one changed payload fingerprint."
1430
+ )
1431
+ changed_mask = torch.tensor(
1432
+ [row_idx in observed_changed for row_idx in range(self._batch_size)],
1433
+ dtype=torch.long,
1434
+ )
1435
+ self._payload_epochs = self._payload_epochs + changed_mask
1436
+ self._payload_fingerprints = candidate
1437
+
1438
+ def _reject_bound_batch_transform(self, operation: str) -> None:
1439
+ if self._streams is not None:
1440
+ raise RuntimeError(
1441
+ f"MoT cache {operation} is unavailable while request-local runtime streams are bound."
1442
+ )
1443
+
1444
+ def _validated_batch_indices(
1445
+ self, indices: torch.LongTensor, *, name: str
1446
+ ) -> torch.LongTensor:
1447
+ if (
1448
+ not isinstance(indices, torch.Tensor)
1449
+ or indices.dtype is not torch.long
1450
+ or indices.ndim != 1
1451
+ ):
1452
+ raise TypeError(f"{name} must be a 1D torch.long tensor.")
1453
+ if indices.numel() <= 0:
1454
+ raise ValueError(f"{name} must select at least one row.")
1455
+ indices_cpu = indices.detach().to(device="cpu")
1456
+ if (
1457
+ int(indices_cpu.min().item()) < 0
1458
+ or int(indices_cpu.max().item()) >= self._batch_size
1459
+ ):
1460
+ raise IndexError(
1461
+ f"{name} values must be in [0, {self._batch_size}), got {indices_cpu.tolist()}."
1462
+ )
1463
+ return indices_cpu
1464
+
1465
+ def _select_metadata(self, indices: torch.LongTensor) -> None:
1466
+ selected = indices.tolist()
1467
+ self._request_ids = tuple(self._request_ids[index] for index in selected)
1468
+ self._payload_epochs = self._payload_epochs.index_select(0, indices)
1469
+ if self._payload_fingerprints is not None:
1470
+ self._payload_fingerprints = tuple(
1471
+ self._payload_fingerprints[index] for index in selected
1472
+ )
1473
+ row_indices = indices.to(device=self._row_logical_next_positions.device)
1474
+ self._row_logical_next_positions = (
1475
+ self._row_logical_next_positions.index_select(0, row_indices)
1476
+ )
1477
+ self._row_rope_next_positions = self._row_rope_next_positions.index_select(
1478
+ 0, row_indices
1479
+ )
1480
+ self._position_buffer.index_select(indices)
1481
+ for layer_buffers in self._sidecars.values():
1482
+ for buffer in layer_buffers.values():
1483
+ buffer.index_select(indices)
1484
+ self._batch_size = int(indices.numel())
1485
+
1486
+ def reorder_cache(self, beam_idx: torch.LongTensor) -> None:
1487
+ self._reject_bound_batch_transform("reorder")
1488
+ indices = self._validated_batch_indices(beam_idx, name="beam_idx")
1489
+ super().reorder_cache(indices)
1490
+ self._select_metadata(indices)
1491
+
1492
+ def batch_select_indices(self, indices: torch.Tensor) -> None:
1493
+ self._reject_bound_batch_transform("batch selection")
1494
+ indices_cpu = self._validated_batch_indices(indices, name="indices")
1495
+ super().batch_select_indices(indices_cpu)
1496
+ self._select_metadata(indices_cpu)
1497
+
1498
+ def batch_repeat_interleave(self, repeats: int) -> None:
1499
+ self._reject_bound_batch_transform("batch repeat")
1500
+ if isinstance(repeats, bool) or not isinstance(repeats, int) or repeats <= 0:
1501
+ raise ValueError(f"repeats must be a positive integer, got {repeats!r}.")
1502
+ super().batch_repeat_interleave(repeats)
1503
+ self._request_ids = tuple(
1504
+ request_id for request_id in self._request_ids for _ in range(repeats)
1505
+ )
1506
+ self._payload_epochs = self._payload_epochs.repeat_interleave(repeats)
1507
+ if self._payload_fingerprints is not None:
1508
+ self._payload_fingerprints = tuple(
1509
+ fingerprint
1510
+ for fingerprint in self._payload_fingerprints
1511
+ for _ in range(repeats)
1512
+ )
1513
+ self._row_logical_next_positions = (
1514
+ self._row_logical_next_positions.repeat_interleave(repeats)
1515
+ )
1516
+ self._row_rope_next_positions = self._row_rope_next_positions.repeat_interleave(
1517
+ repeats
1518
+ )
1519
+ self._position_buffer.repeat_interleave(repeats)
1520
+ for layer_buffers in self._sidecars.values():
1521
+ for buffer in layer_buffers.values():
1522
+ buffer.repeat_interleave(repeats)
1523
+ self._batch_size *= repeats
1524
+
1525
+ def _target_crop_length(self, max_length: int) -> int:
1526
+ if isinstance(max_length, bool) or not isinstance(max_length, int):
1527
+ raise TypeError(
1528
+ f"max_length must be an integer, got {type(max_length).__name__}."
1529
+ )
1530
+ target = (
1531
+ self._physical_length - abs(max_length) if max_length < 0 else max_length
1532
+ )
1533
+ return max(0, min(self._physical_length, target))
1534
+
1535
+ def crop(self, max_length: int) -> None:
1536
+ self._reject_bound_batch_transform("crop")
1537
+ target = self._target_crop_length(max_length)
1538
+ super().crop(target)
1539
+ self._physical_length = target
1540
+ self._position_buffer.crop(target)
1541
+ for layer_buffers in self._sidecars.values():
1542
+ for buffer in layer_buffers.values():
1543
+ buffer.crop_physical(target)
1544
+ self._recompute_row_next_positions()
1545
+
1546
+ def _recompute_row_next_positions(self) -> None:
1547
+ self._row_logical_next_positions.zero_()
1548
+ self._row_rope_next_positions.zero_()
1549
+ if self._position_buffer.length == 0:
1550
+ return
1551
+ valid = self._position_buffer.valid_mask[:, : self._position_buffer.length]
1552
+ logical = self._position_buffer.logical_positions[
1553
+ :, : self._position_buffer.length
1554
+ ]
1555
+ rope = self._position_buffer.rope_positions[:, : self._position_buffer.length]
1556
+ for row_idx in range(self._batch_size):
1557
+ row_valid = valid[row_idx]
1558
+ if bool(torch.any(row_valid).item()):
1559
+ last_idx = int(torch.nonzero(row_valid, as_tuple=False)[-1].item())
1560
+ self._row_logical_next_positions[row_idx] = (
1561
+ logical[row_idx, last_idx] + 1
1562
+ )
1563
+ self._row_rope_next_positions[row_idx] = rope[row_idx, last_idx] + 1
1564
+
1565
+ def _clear_content(self, *, clear_payload: bool) -> None:
1566
+ super().crop(0)
1567
+ self._physical_length = 0
1568
+ self._position_buffer.reset()
1569
+ self._row_logical_next_positions.zero_()
1570
+ self._row_rope_next_positions.zero_()
1571
+ for layer_buffers in self._sidecars.values():
1572
+ for buffer in layer_buffers.values():
1573
+ buffer.reset()
1574
+ if clear_payload:
1575
+ self._payload_fingerprints = None
1576
+ self._payload_epochs.zero_()
1577
+
1578
+ def reset(self) -> None:
1579
+ self._clear_content(clear_payload=True)
1580
+ self._streams = None
1581
+ self._mot_active_segment_ids = ()
1582
+ self._mot_identity_runtime = False
1583
+ self._span_states = {}
1584
+ self._mot_pending_text_logits = None
1585
+ self._mot_stats = []
1586
+ self._mot_pad_token_id = 0
1587
+
1588
+ def validate_consistency(self, *, require_position_metadata: bool = True) -> None:
1589
+ for layer_idx, layer in enumerate(self.layers):
1590
+ if (
1591
+ layer.is_initialized
1592
+ and int(layer.get_seq_length()) != self._physical_length
1593
+ ):
1594
+ raise RuntimeError(
1595
+ "Native cache layer length mismatch: "
1596
+ f"layer={layer_idx}, layer_length={int(layer.get_seq_length())}, "
1597
+ f"physical_length={self._physical_length}."
1598
+ )
1599
+ if (
1600
+ require_position_metadata
1601
+ and self._position_buffer.length != self._physical_length
1602
+ ):
1603
+ raise RuntimeError(
1604
+ "Position metadata length mismatch: "
1605
+ f"metadata={self._position_buffer.length}, physical_length={self._physical_length}."
1606
+ )
1607
+
1608
+
1609
+ __all__ = [
1610
+ "MoTDynamicCache",
1611
+ "MoTSidecarView",
1612
+ "fingerprint_payload_state",
1613
+ "validate_mot_cache_inputs",
1614
+ ]
timebraid/model/mot/model.py ADDED
The diff for this file is too large to render. See raw diff
 
timebraid/model/mot/pairing.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer-pairing helpers for the TimeBraid model core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ _QWEN_PAIRABLE_LAYER_TYPE = "full_attention"
8
+
9
+
10
+ def _build_pair_map(
11
+ num_q_layers: int,
12
+ num_t_layers: int,
13
+ pairing_mode: str,
14
+ q_layer_indices: Optional[List[int]] = None,
15
+ ) -> Dict[int, int]:
16
+ """Build the Q-layer -> TimesFM-layer mapping for one pairing mode."""
17
+ if num_t_layers <= 0:
18
+ raise ValueError(f"num_t_layers must be positive, got {num_t_layers}")
19
+ if q_layer_indices is None:
20
+ available_q_layers = list(range(num_q_layers))
21
+ else:
22
+ available_q_layers = [int(idx) for idx in q_layer_indices]
23
+ if not available_q_layers:
24
+ raise ValueError("No pairable Q layers are available for MoT bridge mapping.")
25
+ if available_q_layers != sorted(available_q_layers):
26
+ raise ValueError(
27
+ f"q_layer_indices must be sorted ascending, got {available_q_layers}"
28
+ )
29
+ if available_q_layers[0] < 0 or available_q_layers[-1] >= num_q_layers:
30
+ raise ValueError(
31
+ "q_layer_indices must stay inside [0, num_q_layers). "
32
+ f"got indices={available_q_layers}, q_layers={num_q_layers}"
33
+ )
34
+
35
+ if len(available_q_layers) > len(set(available_q_layers)):
36
+ raise ValueError(f"q_layer_indices must be unique, got {available_q_layers}")
37
+ if num_t_layers > num_q_layers and q_layer_indices is None:
38
+ raise ValueError(
39
+ f"TimesFM layer count exceeds Qwen layers: t_layers={num_t_layers}, q_layers={num_q_layers}"
40
+ )
41
+
42
+ if pairing_mode == "interleaved":
43
+ if len(available_q_layers) < num_t_layers:
44
+ raise ValueError(
45
+ "`interleaved` requires at least as many pairable Qwen layers as active TimesFM layers, "
46
+ f"got pairable_q_layers={len(available_q_layers)}, t_layers={num_t_layers}."
47
+ )
48
+ if num_t_layers == 1:
49
+ return {available_q_layers[0]: 0}
50
+ q_last = len(available_q_layers) - 1
51
+ t_last = num_t_layers - 1
52
+ selected_q_layers = [
53
+ available_q_layers[round(t_idx * q_last / t_last)]
54
+ for t_idx in range(num_t_layers)
55
+ ]
56
+ return {q_idx: t_idx for t_idx, q_idx in enumerate(selected_q_layers)}
57
+
58
+ if pairing_mode == "dsfp":
59
+ if len(available_q_layers) != num_q_layers:
60
+ raise ValueError(
61
+ "`dsfp` requires every Qwen layer to be pairable, got "
62
+ f"pairable_q_layers={len(available_q_layers)}, q_layers={num_q_layers}."
63
+ )
64
+ if num_t_layers != num_q_layers:
65
+ raise ValueError(
66
+ "`dsfp` maps the expanded TimesFM stack one-to-one onto Qwen depth, got "
67
+ f"t_layers={num_t_layers}, q_layers={num_q_layers}."
68
+ )
69
+ return {q_idx: t_idx for t_idx, q_idx in enumerate(available_q_layers)}
70
+
71
+ raise ValueError(
72
+ f"Unsupported MoT pairing mode: {pairing_mode!r}. Expected 'interleaved' or 'dsfp'."
73
+ )
74
+
75
+
76
+ def _resolve_pairable_q_layer_indices(config: Any, num_q_layers: int) -> List[int]:
77
+ """Resolve which Qwen decoder layers can participate in the TimeBraid layer plan."""
78
+ layer_types = getattr(config, "layer_types", None)
79
+ if layer_types is None:
80
+ return list(range(num_q_layers))
81
+ if len(layer_types) != num_q_layers:
82
+ raise ValueError(
83
+ "config.layer_types length mismatch with num_hidden_layers: "
84
+ f"len(layer_types)={len(layer_types)}, num_hidden_layers={num_q_layers}"
85
+ )
86
+
87
+ pairable = [
88
+ idx
89
+ for idx, layer_type in enumerate(layer_types)
90
+ if layer_type == _QWEN_PAIRABLE_LAYER_TYPE
91
+ ]
92
+ if not pairable:
93
+ raise ValueError(
94
+ f"MoT requires at least one `{_QWEN_PAIRABLE_LAYER_TYPE}` layer, but none were found in "
95
+ "config.layer_types."
96
+ )
97
+ return pairable
timebraid/model/mot/runtime_state.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Immutable per-model MoT construction options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from types import MappingProxyType
7
+ from typing import Any, Mapping
8
+
9
+ import torch
10
+
11
+ from .config_contract import MOT_OPTION_BY_NAME
12
+
13
+
14
+ class MoTRuntimeOptions:
15
+ """Read-only option view used while initializing one TimeBraid model."""
16
+
17
+ __slots__ = ("_values",)
18
+
19
+ def __init__(self, values: Mapping[str, Any] | None = None) -> None:
20
+ if values is None:
21
+ values = {}
22
+ if not isinstance(values, Mapping):
23
+ raise TypeError(
24
+ f"MoT runtime options must be a mapping, got {type(values).__name__}."
25
+ )
26
+ # A read-only copy prevents both caller mutation and cross-model state
27
+ # leakage. Model construction can therefore overlap safely in threads;
28
+ # there is no process-global configuration transaction to race on.
29
+ self._values = MappingProxyType(dict(values))
30
+
31
+ def _value(self, name: str, default: Any) -> Any:
32
+ value = self._values.get(name, default)
33
+ return default if value is None else value
34
+
35
+ def value(self, name: str) -> Any:
36
+ """Read one option, taking its type and default from the registry.
37
+
38
+ Call sites used to restate both at every read, which is how the same
39
+ knob ended up with disagreeing defaults in different files. Here the
40
+ registry answers once.
41
+ """
42
+ option = MOT_OPTION_BY_NAME.get(name)
43
+ if option is None:
44
+ raise KeyError(
45
+ f"Unknown MoT runtime option {name!r}; declare it in MOT_OPTIONS."
46
+ )
47
+
48
+ present = self._values.get(name)
49
+ if option.is_required and present is None:
50
+ raise RuntimeError(
51
+ f"MoT runtime option {name!r} is supplied by the checkpoint restore "
52
+ "contract and has no default. A hand-built option map must set it "
53
+ "explicitly; substituting a value here would silently change the "
54
+ "trained topology."
55
+ )
56
+
57
+ # For a required option the value is present, so passing it as its own
58
+ # default simply routes it through the type check below.
59
+ default = present if option.is_required else option.default
60
+ if option.kind == "boolean":
61
+ return self.boolean(name, default)
62
+ if option.kind == "integer":
63
+ return self.integer(name, default)
64
+ if option.kind == "number":
65
+ return self.number(name, default)
66
+ if option.kind == "string":
67
+ return self.string(name, default)
68
+ if option.kind == "torch_dtype":
69
+ return self.torch_dtype(name, default)
70
+ raise KeyError(f"MoT option {name!r} has an unknown kind {option.kind!r}.")
71
+
72
+ def boolean(self, name: str, default: bool = False) -> bool:
73
+ value = self._values.get(name, default)
74
+ if type(value) is not bool:
75
+ raise TypeError(
76
+ f"MoT runtime option {name!r} must be a boolean, got {value!r}."
77
+ )
78
+ return value
79
+
80
+ def integer(self, name: str, default: int) -> int:
81
+ value = self._value(name, default)
82
+ if type(value) is not int:
83
+ raise TypeError(
84
+ f"MoT runtime option {name!r} must be an integer, got {value!r}."
85
+ )
86
+ return value
87
+
88
+ def string(self, name: str, default: str) -> str:
89
+ value = self._value(name, default)
90
+ if not isinstance(value, str):
91
+ raise TypeError(
92
+ f"MoT runtime option {name!r} must be a string, got {value!r}."
93
+ )
94
+ return value
95
+
96
+ def number(self, name: str, default: float) -> float:
97
+ value = self._value(name, default)
98
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
99
+ raise TypeError(
100
+ f"MoT runtime option {name!r} must be numeric, got {value!r}."
101
+ )
102
+ result = float(value)
103
+ if not math.isfinite(result):
104
+ raise ValueError(
105
+ f"MoT runtime option {name!r} must be finite, got {value!r}."
106
+ )
107
+ return result
108
+
109
+ def torch_dtype(self, name: str, default: torch.dtype) -> torch.dtype:
110
+ value = self._value(name, default)
111
+
112
+ if isinstance(value, torch.dtype):
113
+ return value
114
+
115
+ if not isinstance(value, str):
116
+ raise TypeError(
117
+ f"MoT runtime option {name!r} must resolve to a torch dtype, "
118
+ f"got {value!r}."
119
+ )
120
+ text = value.strip().lower()
121
+ if text in {"torch.bfloat16", "bfloat16", "bf16"}:
122
+ return torch.bfloat16
123
+ if text in {"torch.float16", "float16", "fp16", "half"}:
124
+ return torch.float16
125
+ if text in {"torch.float32", "float32", "fp32", "float"}:
126
+ return torch.float32
127
+
128
+ raise ValueError(
129
+ f"MoT runtime option {name!r} must resolve to a torch dtype, got {value!r}."
130
+ )
timebraid/model/mot/structures.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared structural types and tag helpers for TimeBraid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import NamedTuple, Optional
8
+
9
+ import torch
10
+
11
+ IGNORE_INDEX = -100
12
+
13
+
14
+ def _extract_text_position_ids(
15
+ position_ids: Optional[torch.LongTensor],
16
+ ) -> Optional[torch.LongTensor]:
17
+ """Normalize position ids to MoT's 2D text timeline."""
18
+ if position_ids is None:
19
+ return None
20
+ if position_ids.ndim == 2:
21
+ return position_ids
22
+ if position_ids.ndim == 3 and position_ids.shape[0] in {3, 4}:
23
+ return position_ids[0]
24
+ raise RuntimeError(f"Unsupported position_ids shape: {tuple(position_ids.shape)}")
25
+
26
+
27
+ ROLE_OBSERVED = 0
28
+ ROLE_CONTEXT = 1
29
+ ROLE_TARGET = 2
30
+ TS_ROUTE_GENERATION = 0
31
+ TS_ROUTE_UNDERSTANDING = 2
32
+
33
+
34
+ TIMEBRAID_TS_PAYLOAD_FIELDS = (
35
+ "ts_values",
36
+ "ts_lengths",
37
+ "ts_loss_start_idxs",
38
+ "ts_loss_roi_masks",
39
+ "ts_roles",
40
+ "ts_segment_ids",
41
+ "ts_span_mask",
42
+ "ts_text_start_token_idxs",
43
+ "ts_text_end_token_idxs",
44
+ )
45
+ TIMEBRAID_REQUIRED_TS_PAYLOAD_FIELDS = tuple(
46
+ name for name in TIMEBRAID_TS_PAYLOAD_FIELDS if name != "ts_loss_roi_masks"
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class TimeBraidPayload:
52
+ """Curve tensors carried together through TimeBraid runtime compilation and rollout."""
53
+
54
+ ts_values: Optional[torch.Tensor] = None
55
+ ts_lengths: Optional[torch.Tensor] = None
56
+ ts_loss_start_idxs: Optional[torch.Tensor] = None
57
+ ts_loss_roi_masks: Optional[torch.Tensor] = None
58
+ ts_roles: Optional[torch.Tensor] = None
59
+ ts_segment_ids: Optional[torch.Tensor] = None
60
+ ts_span_mask: Optional[torch.Tensor] = None
61
+ ts_text_start_token_idxs: Optional[torch.Tensor] = None
62
+ ts_text_end_token_idxs: Optional[torch.Tensor] = None
63
+
64
+ @classmethod
65
+ def pop_from_kwargs(cls, kwargs: dict[str, object]) -> "TimeBraidPayload":
66
+ return cls(
67
+ **{name: kwargs.pop(name, None) for name in TIMEBRAID_TS_PAYLOAD_FIELDS}
68
+ )
69
+
70
+ def inject_generation_inputs(self, model_inputs: dict[str, object]) -> None:
71
+ """Flatten the payload only at the public Hugging Face model-kwargs boundary."""
72
+ for name in TIMEBRAID_TS_PAYLOAD_FIELDS:
73
+ value = getattr(self, name)
74
+ if value is not None:
75
+ model_inputs[name] = value
76
+
77
+ def has_runtime_inputs(self) -> bool:
78
+ return any(
79
+ getattr(self, name) is not None for name in TIMEBRAID_TS_PAYLOAD_FIELDS
80
+ )
81
+
82
+ def runtime_kwargs(self) -> dict[str, object]:
83
+ """Flatten the payload for public model calls that retain the HF tensor ABI."""
84
+ return {name: getattr(self, name) for name in TIMEBRAID_TS_PAYLOAD_FIELDS}
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class TimeBraidLayerStep:
89
+ """One LLM layer and its optional generation/understanding TimesFM layers."""
90
+
91
+ llm_layer: int
92
+ generation_tsfm_layer: Optional[int] = None
93
+ understanding_tsfm_layer: Optional[int] = None
94
+
95
+
96
+ def build_timebraid_layer_plan(
97
+ *,
98
+ num_llm_layers: int,
99
+ generation_tsfm_by_llm_layer: Mapping[int, int],
100
+ understanding_tsfm_by_llm_layer: Mapping[int, int],
101
+ ) -> tuple[TimeBraidLayerStep, ...]:
102
+ """Materialize validated per-layer execution steps from paired-layer maps."""
103
+ if type(num_llm_layers) is not int or num_llm_layers <= 0:
104
+ raise ValueError(
105
+ f"num_llm_layers must be a positive integer, got {num_llm_layers!r}."
106
+ )
107
+
108
+ def _validate_tsfm_map(mapping: Mapping[int, int], *, role: str) -> dict[int, int]:
109
+ if not isinstance(mapping, Mapping):
110
+ raise TypeError(
111
+ f"{role}_tsfm_by_llm_layer must be a mapping, got "
112
+ f"{type(mapping).__name__}."
113
+ )
114
+ normalized: dict[int, int] = {}
115
+ for llm_layer, tsfm_layer in mapping.items():
116
+ if type(llm_layer) is not int or not 0 <= llm_layer < num_llm_layers:
117
+ raise ValueError(
118
+ f"{role} LLM layer must be an integer in [0, {num_llm_layers}), "
119
+ f"got {llm_layer!r}."
120
+ )
121
+ if type(tsfm_layer) is not int or tsfm_layer < 0:
122
+ raise ValueError(
123
+ f"{role} TimesFM layer must be a non-negative integer, got "
124
+ f"{tsfm_layer!r}."
125
+ )
126
+ normalized[llm_layer] = tsfm_layer
127
+
128
+ observed_tsfm_layers = [
129
+ normalized[llm_layer] for llm_layer in sorted(normalized)
130
+ ]
131
+ expected_tsfm_layers = list(range(len(normalized)))
132
+ if observed_tsfm_layers != expected_tsfm_layers:
133
+ raise ValueError(
134
+ f"{role} TimesFM layers must advance in contiguous 0..N-1 order, "
135
+ f"got {observed_tsfm_layers}."
136
+ )
137
+ return normalized
138
+
139
+ generation = _validate_tsfm_map(generation_tsfm_by_llm_layer, role="generation")
140
+ if not generation:
141
+ raise ValueError("generation_tsfm_by_llm_layer must not be empty.")
142
+ understanding = _validate_tsfm_map(
143
+ understanding_tsfm_by_llm_layer, role="understanding"
144
+ )
145
+ return tuple(
146
+ TimeBraidLayerStep(
147
+ llm_layer=llm_layer,
148
+ generation_tsfm_layer=generation.get(llm_layer),
149
+ understanding_tsfm_layer=understanding.get(llm_layer),
150
+ )
151
+ for llm_layer in range(num_llm_layers)
152
+ )
153
+
154
+
155
+ class MoTForecastLossAccounting(NamedTuple):
156
+ record_target_idx: torch.Tensor
157
+ record_patch_idx: torch.Tensor
158
+ target_starts: torch.Tensor
159
+ target_lens: torch.Tensor
160
+ raw_indices: torch.Tensor
161
+ keep_mask: torch.Tensor
162
+ valid_counts: torch.Tensor
163
+
164
+
165
+ def build_mot_forecast_loss_accounting(
166
+ *,
167
+ patch_lengths: torch.Tensor,
168
+ patch_counts: torch.Tensor,
169
+ value_lengths: torch.Tensor,
170
+ loss_start_idxs: torch.Tensor,
171
+ forecast_horizon: int,
172
+ ) -> Optional[MoTForecastLossAccounting]:
173
+ """
174
+ Build the canonical TS forecast record mask.
175
+
176
+ `loss_start` is both a point-level supervision boundary and an owner-patch
177
+ eligibility boundary. A historical owner patch whose raw end is before
178
+ `loss_start` must not receive loss just because a long horizon overlaps the
179
+ future suffix.
180
+ """
181
+ if patch_lengths.ndim != 2:
182
+ raise RuntimeError(
183
+ f"TS loss accounting expects patch_lengths [target,patch], got {tuple(patch_lengths.shape)}."
184
+ )
185
+ target_count, max_patches = int(patch_lengths.shape[0]), int(patch_lengths.shape[1])
186
+ for name, tensor in (
187
+ ("patch_counts", patch_counts),
188
+ ("value_lengths", value_lengths),
189
+ ("loss_start_idxs", loss_start_idxs),
190
+ ):
191
+ if tensor.ndim != 1 or int(tensor.shape[0]) != target_count:
192
+ raise RuntimeError(
193
+ f"TS loss accounting expects {name} [{target_count}], got {tuple(tensor.shape)}."
194
+ )
195
+ forecast_horizon = int(forecast_horizon)
196
+ if forecast_horizon <= 0:
197
+ raise RuntimeError(
198
+ f"TS loss accounting requires positive forecast_horizon, got {forecast_horizon}."
199
+ )
200
+
201
+ device = patch_lengths.device
202
+ prefix_ends = torch.cumsum(patch_lengths, dim=1)
203
+ patch_axis = torch.arange(max_patches, device=device, dtype=torch.long).unsqueeze(0)
204
+ successor_patch_mask = patch_axis < (patch_counts - 1).unsqueeze(1)
205
+
206
+ # Each record is owned by a patch and predicts the raw suffix starting right
207
+ # after that patch's last raw point.
208
+ target_starts_by_patch = prefix_ends
209
+ next_available_lengths = value_lengths[:, None] - prefix_ends
210
+ forecast_horizon_tensor = torch.full_like(prefix_ends, forecast_horizon)
211
+ target_lens_by_patch = torch.minimum(
212
+ forecast_horizon_tensor, next_available_lengths
213
+ )
214
+ keep_starts_by_patch = torch.clamp(
215
+ loss_start_idxs[:, None] - target_starts_by_patch, min=0
216
+ )
217
+
218
+ # An owner patch before the history/future boundary is context only. The
219
+ # boundary owner itself is the first record allowed to predict supervised
220
+ # future points.
221
+ owner_reaches_loss_start = prefix_ends >= loss_start_idxs[:, None]
222
+ base_record_mask = torch.logical_and(successor_patch_mask, owner_reaches_loss_start)
223
+ record_mask = torch.logical_and(
224
+ base_record_mask,
225
+ torch.logical_and(
226
+ target_lens_by_patch > 0, keep_starts_by_patch < target_lens_by_patch
227
+ ),
228
+ )
229
+ record_target_idx, record_patch_idx = torch.nonzero(record_mask, as_tuple=True)
230
+ if int(record_target_idx.numel()) == 0:
231
+ return None
232
+
233
+ target_lens = target_lens_by_patch[record_target_idx, record_patch_idx]
234
+ target_starts = target_starts_by_patch[record_target_idx, record_patch_idx]
235
+ horizon_idx = torch.arange(forecast_horizon, device=device, dtype=torch.long)
236
+ raw_indices = target_starts.unsqueeze(1) + horizon_idx.unsqueeze(0)
237
+ keep_mask = torch.logical_and(
238
+ horizon_idx.unsqueeze(0) < target_lens.unsqueeze(1),
239
+ raw_indices >= loss_start_idxs[record_target_idx].unsqueeze(1),
240
+ )
241
+ nonempty_record_mask = torch.any(keep_mask, dim=1)
242
+ if not bool(torch.any(nonempty_record_mask)):
243
+ return None
244
+
245
+ keep_mask = keep_mask[nonempty_record_mask]
246
+ target_lens = target_lens[nonempty_record_mask]
247
+ target_starts = target_starts[nonempty_record_mask]
248
+ record_target_idx = record_target_idx[nonempty_record_mask]
249
+ record_patch_idx = record_patch_idx[nonempty_record_mask]
250
+ raw_indices = raw_indices[nonempty_record_mask]
251
+ valid_counts = keep_mask.to(dtype=torch.long).sum(dim=1)
252
+ if bool(torch.any(valid_counts <= 0)):
253
+ raise RuntimeError(
254
+ "TS loss accounting built a record with no supervised points."
255
+ )
256
+ return MoTForecastLossAccounting(
257
+ record_target_idx=record_target_idx,
258
+ record_patch_idx=record_patch_idx,
259
+ target_starts=target_starts,
260
+ target_lens=target_lens,
261
+ raw_indices=raw_indices,
262
+ keep_mask=keep_mask,
263
+ valid_counts=valid_counts,
264
+ )
265
+
266
+
267
+ @dataclass
268
+ class _Span:
269
+ start_token_idx: int
270
+ end_token_idx: int
timebraid/model/timebraid.py ADDED
@@ -0,0 +1,1021 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF-native TimeBraid model with directly registered language and TS components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass, fields
7
+ from typing import TYPE_CHECKING, Any, Optional, Union
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+ from transformers import (
12
+ AutoConfig,
13
+ AutoModelForCausalLM,
14
+ PretrainedConfig,
15
+ PreTrainedModel,
16
+ )
17
+ from transformers.cache_utils import Cache
18
+ from transformers.modeling_outputs import CausalLMOutputWithPast
19
+ from transformers.modeling_utils import get_parameter_dtype
20
+ from transformers.utils import ModelOutput
21
+
22
+ from .mot.bridge_runtime import initialize_timebraid_components
23
+ from .mot.bridge_runtime_mot import sync_gradient_checkpointing_from_decoder_layers
24
+ from .mot.config_contract import (
25
+ MOT_HF_CONFIG_CONTRACT_FIELDS,
26
+ collect_mot_hf_config_contract,
27
+ )
28
+ from .mot.generation_route import (
29
+ GenerationRoute,
30
+ require_mixed_generation_inputs,
31
+ resolve_generation_route,
32
+ )
33
+ from .mot.kv_cache import MoTDynamicCache
34
+ from .mot.model import (
35
+ NormalizedMoTGenerationControls,
36
+ TimeBraidGenerateOutput,
37
+ TimeBraidPayload,
38
+ merge_timebraid_losses,
39
+ normalize_mot_generation_controls,
40
+ run_timebraid_decoder,
41
+ run_timebraid_generate,
42
+ validate_mot_generation_input_ids,
43
+ )
44
+ from .mot.structures import IGNORE_INDEX, TIMEBRAID_TS_PAYLOAD_FIELDS
45
+
46
+ if TYPE_CHECKING: # pragma: no cover - import cycle only matters at runtime
47
+ from ..processing_timebraid import TimeBraidBatchFeature
48
+
49
+ _FINITE_SCAN_CHUNK_ELEMENTS = 8 * 1024 * 1024
50
+ _NON_INFERENCE_CONFIG_FIELDS = (
51
+ "mot_timesfm_model_name_or_path",
52
+ "mot_ts_roi_mse_alpha",
53
+ "mot_ts_loss_weight",
54
+ "mot_lm_loss_weight",
55
+ "mot_ts_understanding_loss_weight",
56
+ )
57
+ _FORBIDDEN_CONFIG_FIELDS = _NON_INFERENCE_CONFIG_FIELDS
58
+ _MOT_GENERATION_CONTROL_FIELDS = (
59
+ "mot_target_horizons",
60
+ "mot_forecast_head_len",
61
+ "mot_target_total_lengths",
62
+ "mot_target_history_span_idxs",
63
+ "mot_return_forecast_quantiles",
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class TimeBraidLossBreakdown(ModelOutput):
69
+ """Fixed scalar loss accounting returned beside the differentiable loss."""
70
+
71
+ loss: Optional[torch.Tensor] = None
72
+ lm_loss: Optional[torch.Tensor] = None
73
+ ts_aux_loss: Optional[torch.Tensor] = None
74
+ ts_aux_point_loss: Optional[torch.Tensor] = None
75
+ ts_aux_quantile_loss: Optional[torch.Tensor] = None
76
+ ts_aux_global_point_loss: Optional[torch.Tensor] = None
77
+ ts_aux_global_quantile_loss: Optional[torch.Tensor] = None
78
+ ts_aux_global_uncapped_loss: Optional[torch.Tensor] = None
79
+ ts_aux_global_cap_scale: Optional[torch.Tensor] = None
80
+ ts_aux_roi_loss: Optional[torch.Tensor] = None
81
+ ts_aux_roi_point_loss: Optional[torch.Tensor] = None
82
+ ts_aux_roi_weighted_point_loss: Optional[torch.Tensor] = None
83
+ ts_aux_roi_uncapped_loss: Optional[torch.Tensor] = None
84
+ ts_aux_roi_weighted_uncapped_loss: Optional[torch.Tensor] = None
85
+ ts_aux_roi_cap_scale: Optional[torch.Tensor] = None
86
+ ts_aux_roi_supervised_points: Optional[torch.Tensor] = None
87
+ ts_aux_roi_supervised_targets: Optional[torch.Tensor] = None
88
+ ts_aux_roi_alpha: Optional[torch.Tensor] = None
89
+ ts_aux_understanding_loss: Optional[torch.Tensor] = None
90
+ ts_aux_understanding_weighted_loss: Optional[torch.Tensor] = None
91
+ ts_aux_understanding_time_loss: Optional[torch.Tensor] = None
92
+ ts_aux_understanding_fft_loss: Optional[torch.Tensor] = None
93
+ ts_aux_understanding_patches: Optional[torch.Tensor] = None
94
+ ts_aux_understanding_points: Optional[torch.Tensor] = None
95
+ ts_aux_understanding_weight: Optional[torch.Tensor] = None
96
+
97
+
98
+ @dataclass
99
+ class TimeBraidOutput(CausalLMOutputWithPast):
100
+ """Stable causal-LM output for every dictionary-returning TimeBraid forward."""
101
+
102
+ token_accuracy: Optional[torch.Tensor] = None
103
+ predicted_tokens: Optional[torch.Tensor] = None
104
+ loss_breakdown: Optional[TimeBraidLossBreakdown] = None
105
+
106
+
107
+ def _present_forbidden_config_fields(config: object) -> list[str]:
108
+ if isinstance(config, Mapping):
109
+ return sorted(field for field in _FORBIDDEN_CONFIG_FIELDS if field in config)
110
+ return sorted(field for field in _FORBIDDEN_CONFIG_FIELDS if hasattr(config, field))
111
+
112
+
113
+ def _validate_finite_named_tensors(
114
+ named_tensors: Iterable[tuple[str, torch.Tensor]],
115
+ *,
116
+ owner: str,
117
+ ) -> None:
118
+ for name, tensor in named_tensors:
119
+ if not tensor.is_floating_point() and not tensor.is_complex():
120
+ continue
121
+ flat = tensor.detach().contiguous().view(-1)
122
+ for start in range(0, int(flat.numel()), _FINITE_SCAN_CHUNK_ELEMENTS):
123
+ chunk = flat[start : start + _FINITE_SCAN_CHUNK_ELEMENTS]
124
+ finite_mask = torch.isfinite(chunk)
125
+ if bool(torch.all(finite_mask)):
126
+ continue
127
+ chunk_index = int(
128
+ torch.nonzero(torch.logical_not(finite_mask), as_tuple=False)[0]
129
+ .detach()
130
+ .cpu()
131
+ .item()
132
+ )
133
+ raise RuntimeError(
134
+ f"{owner} contains a non-finite tensor value: "
135
+ f"name={name!r}, flat_index={start + chunk_index}, dtype={tensor.dtype}."
136
+ )
137
+
138
+
139
+ def _validate_finite_model_tensors(model: nn.Module, *, owner: str) -> None:
140
+ """Reject non-finite floating or complex model state before use."""
141
+ _validate_finite_named_tensors(
142
+ (*model.named_parameters(), *model.named_buffers()),
143
+ owner=owner,
144
+ )
145
+
146
+
147
+ def _runtime_options_from_timebraid_config(config: "TimeBraidConfig") -> dict[str, Any]:
148
+ """Build direct-component initialization options from checkpoint metadata.
149
+
150
+ A contract field's name *is* its runtime option name, so this copies
151
+ rather than translates. The hand-maintained rename table it replaced was
152
+ what allowed one knob to carry two spellings and drift apart.
153
+ """
154
+ contract = collect_mot_hf_config_contract(config)
155
+ options: dict[str, Any] = {
156
+ name: contract[name] for name in MOT_HF_CONFIG_CONTRACT_FIELDS
157
+ }
158
+ # Never restored from a checkpoint: inference always runs with gradient
159
+ # checkpointing off.
160
+ options["mot_ts_gradient_checkpointing"] = False
161
+ options["mot_ts_gradient_checkpointing_use_reentrant"] = False
162
+ compute_dtype = getattr(config, "dtype", None) or getattr(
163
+ config.llm_config, "dtype", None
164
+ )
165
+ if compute_dtype is not None:
166
+ options["mot_compute_dtype"] = compute_dtype
167
+ return options
168
+
169
+
170
+ _LLM_MIRROR_FIELDS = (
171
+ "vocab_size",
172
+ "hidden_size",
173
+ "intermediate_size",
174
+ "num_hidden_layers",
175
+ "num_attention_heads",
176
+ "num_key_value_heads",
177
+ "head_dim",
178
+ "max_position_embeddings",
179
+ "tie_word_embeddings",
180
+ "dtype",
181
+ "bos_token_id",
182
+ "eos_token_id",
183
+ "pad_token_id",
184
+ "use_cache",
185
+ "sliding_window",
186
+ "layer_types",
187
+ "_attn_implementation",
188
+ "_attn_implementation_internal",
189
+ )
190
+ _LLM_SERIALIZED_MIRROR_FIELDS = tuple(
191
+ field for field in _LLM_MIRROR_FIELDS if not field.startswith("_")
192
+ )
193
+
194
+
195
+ class TimeBraidConfig(PretrainedConfig):
196
+ r"""Composite config for TimeBraid's language and time-series components."""
197
+
198
+ model_type = "timebraid"
199
+ is_composition = True
200
+ has_no_defaults_at_init = True
201
+ sub_configs = {"llm_config": AutoConfig}
202
+
203
+ def __init__(
204
+ self,
205
+ llm_config: Optional[PretrainedConfig | dict[str, Any]] = None,
206
+ **kwargs: Any,
207
+ ) -> None:
208
+ if llm_config is None:
209
+ raise ValueError("TimeBraidConfig requires `llm_config`.")
210
+ forbidden_fields = _present_forbidden_config_fields(kwargs)
211
+ if forbidden_fields:
212
+ raise ValueError(
213
+ "TimeBraidConfig does not restore training-only or external "
214
+ f"TimesFM fields: {forbidden_fields}."
215
+ )
216
+ nested_forbidden_fields = _present_forbidden_config_fields(llm_config)
217
+ if nested_forbidden_fields:
218
+ raise ValueError(
219
+ "TimeBraidConfig llm_config does not restore training-only or "
220
+ f"external TimesFM fields: {nested_forbidden_fields}."
221
+ )
222
+
223
+ if isinstance(llm_config, PretrainedConfig):
224
+ resolved_llm_config = llm_config
225
+ else:
226
+ llm_config_dict = dict(llm_config)
227
+ llm_model_type = llm_config_dict.pop("model_type", None)
228
+ if llm_model_type is None:
229
+ raise ValueError(
230
+ "TimeBraidConfig `llm_config` must include `model_type`."
231
+ )
232
+ resolved_llm_config = AutoConfig.for_model(
233
+ llm_model_type, **llm_config_dict
234
+ )
235
+
236
+ outer_mirrors = sorted(
237
+ field for field in _LLM_SERIALIZED_MIRROR_FIELDS if field in kwargs
238
+ )
239
+ if outer_mirrors:
240
+ raise ValueError(
241
+ "TimeBraidConfig stores LLM fields only in llm_config; remove outer fields "
242
+ f"{outer_mirrors}."
243
+ )
244
+ kwargs.setdefault("architectures", ["TimeBraid"])
245
+ # PretrainedConfig applies dtype and attention overrides during its
246
+ # initializer. Register the nested config first so those composite
247
+ # controls reach Qwen instead of remaining only on the outer wrapper.
248
+ self.llm_config = resolved_llm_config
249
+ super().__init__(**kwargs)
250
+ self._sync_llm_surface()
251
+
252
+ @classmethod
253
+ def from_llm_config(
254
+ cls, llm_config: PretrainedConfig, **kwargs: Any
255
+ ) -> "TimeBraidConfig":
256
+ return cls(llm_config=llm_config, **kwargs)
257
+
258
+ def _sync_llm_surface(self) -> None:
259
+ """Derive the non-serialized wrapper surface generic HF helpers consume."""
260
+ for field in _LLM_MIRROR_FIELDS:
261
+ if hasattr(self.llm_config, field):
262
+ setattr(self, field, getattr(self.llm_config, field))
263
+
264
+ def to_dict(self) -> dict[str, Any]:
265
+ """Serialize only inference restore state, never training objectives."""
266
+ payload = super().to_dict()
267
+ forbidden_fields = _present_forbidden_config_fields(payload)
268
+ if forbidden_fields:
269
+ raise RuntimeError(
270
+ "TimeBraidConfig contains forbidden training-only or external "
271
+ f"TimesFM fields: {forbidden_fields}."
272
+ )
273
+ for field in _LLM_SERIALIZED_MIRROR_FIELDS:
274
+ payload.pop(field, None)
275
+ nested = payload.get("llm_config")
276
+ if isinstance(nested, dict):
277
+ nested_forbidden_fields = _present_forbidden_config_fields(nested)
278
+ if nested_forbidden_fields:
279
+ raise RuntimeError(
280
+ "TimeBraidConfig llm_config contains forbidden training-only, "
281
+ f"or external TimesFM fields: {nested_forbidden_fields}."
282
+ )
283
+ return payload
284
+
285
+
286
+ def _labels_with_language_supervision(
287
+ labels: Optional[torch.LongTensor],
288
+ ) -> Optional[torch.LongTensor]:
289
+ if isinstance(labels, torch.Tensor) and (
290
+ labels.numel() == 0 or not bool(torch.any(labels.ne(IGNORE_INDEX)).item())
291
+ ):
292
+ return None
293
+ return labels
294
+
295
+
296
+ def _require_binary_plain_attention_mask(attention_mask: object) -> None:
297
+ """Reject packed segment ids before delegating a plain call to native Qwen."""
298
+ # Rank-4 prepared masks remain part of Qwen's public ABI. TimeBraid packing
299
+ # uses positive segment ids only in the rank-2 mask produced by its
300
+ # processor/collator, so the plain route must accept exactly binary values
301
+ # there instead of letting Qwen collapse every nonzero id to one segment.
302
+ if not isinstance(attention_mask, torch.Tensor) or attention_mask.ndim != 2:
303
+ return
304
+ binary_values = torch.logical_or(attention_mask.eq(0), attention_mask.eq(1))
305
+ if not bool(torch.all(binary_values).item()):
306
+ raise RuntimeError(
307
+ "Plain Qwen fallback requires a binary 2D attention_mask with values "
308
+ "in {0, 1}; packed segment ids require the TimeBraid processor/collator "
309
+ f"payload, got dtype={attention_mask.dtype}, shape={tuple(attention_mask.shape)}."
310
+ )
311
+
312
+
313
+ _TIMEBRAID_KWARG_PREFIXES = ("mot_", "ts_")
314
+ _KNOWN_TIMEBRAID_KWARGS = frozenset(
315
+ TIMEBRAID_TS_PAYLOAD_FIELDS + _MOT_GENERATION_CONTROL_FIELDS
316
+ )
317
+
318
+
319
+ def _reject_unknown_timebraid_kwargs(generation_kwargs: Mapping[str, object]) -> None:
320
+ """Reject misspelled TimeBraid arguments instead of forwarding them.
321
+
322
+ `mot_` and `ts_` are TimeBraid's own argument namespace, so an unrecognized
323
+ name there is a mistake we can name. Unprefixed names belong to Hugging
324
+ Face and must keep flowing through, or this package would break whenever
325
+ transformers adds a generation argument.
326
+
327
+ Without this, a typo such as `mot_target_horizon` was silently forwarded,
328
+ the request routed to plain text, and the caller received prose with no
329
+ forecast and no error.
330
+ """
331
+ unknown = sorted(
332
+ name
333
+ for name in generation_kwargs
334
+ if name.startswith(_TIMEBRAID_KWARG_PREFIXES)
335
+ and name not in _KNOWN_TIMEBRAID_KWARGS
336
+ )
337
+ if unknown:
338
+ raise TypeError(
339
+ f"Unknown TimeBraid generation argument(s) {unknown}. Supported "
340
+ f"names are {sorted(_KNOWN_TIMEBRAID_KWARGS)}."
341
+ )
342
+
343
+
344
+ def _fixed_loss_breakdown(
345
+ breakdown: Optional[Mapping[str, torch.Tensor]],
346
+ ) -> Optional[TimeBraidLossBreakdown]:
347
+ if breakdown is None:
348
+ return None
349
+ reference = next(
350
+ (value for value in breakdown.values() if isinstance(value, torch.Tensor)),
351
+ None,
352
+ )
353
+ if reference is None:
354
+ raise RuntimeError("TimeBraid loss accounting returned no tensor values.")
355
+ zero = reference.detach().new_zeros(())
356
+ values: dict[str, torch.Tensor] = {}
357
+ for output_field in fields(TimeBraidLossBreakdown):
358
+ value = breakdown.get(output_field.name)
359
+ if value is None:
360
+ value = zero.clone()
361
+ if not isinstance(value, torch.Tensor) or value.numel() != 1:
362
+ raise RuntimeError(
363
+ "TimeBraid loss accounting values must be scalar tensors: "
364
+ f"field={output_field.name!r}, value={value!r}."
365
+ )
366
+ values[output_field.name] = value.detach().reshape(())
367
+ return TimeBraidLossBreakdown(**values)
368
+
369
+
370
+ def _as_timebraid_output(
371
+ outputs: object,
372
+ breakdown: Optional[Mapping[str, torch.Tensor]],
373
+ ) -> TimeBraidOutput:
374
+ return TimeBraidOutput(
375
+ loss=getattr(outputs, "loss", None),
376
+ logits=getattr(outputs, "logits", None),
377
+ past_key_values=getattr(outputs, "past_key_values", None),
378
+ hidden_states=getattr(outputs, "hidden_states", None),
379
+ attentions=getattr(outputs, "attentions", None),
380
+ token_accuracy=getattr(outputs, "token_accuracy", None),
381
+ predicted_tokens=getattr(outputs, "predicted_tokens", None),
382
+ loss_breakdown=_fixed_loss_breakdown(breakdown),
383
+ )
384
+
385
+
386
+ def _build_lm_output(
387
+ *,
388
+ llm: PreTrainedModel,
389
+ hidden_states: torch.Tensor,
390
+ past_key_values: object,
391
+ decoder_hidden_states: object,
392
+ decoder_attentions: object,
393
+ labels: Optional[torch.LongTensor],
394
+ labels_for_loss: Optional[torch.LongTensor],
395
+ shift_labels: Optional[torch.LongTensor],
396
+ logits_to_keep: Union[int, torch.Tensor],
397
+ skip_logits: Optional[bool],
398
+ loss_kwargs: Mapping[str, object],
399
+ ) -> TimeBraidOutput:
400
+ """Project decoder hidden states and own the complete language-loss contract."""
401
+ if skip_logits is not None and type(skip_logits) is not bool:
402
+ raise TypeError(
403
+ f"TimeBraid forward skip_logits must be a bool or None, got {skip_logits!r}."
404
+ )
405
+
406
+ # `skip_logits=True` only ever meant "the fused Liger loss will consume the
407
+ # hidden states instead of logits". That path was unreachable — the flag
408
+ # enabling it was hardcoded False with no setter — so it is gone, and the
409
+ # request is refused rather than silently ignored.
410
+ if skip_logits:
411
+ raise RuntimeError(
412
+ "skip_logits=True is not supported: TimeBraid always projects logits "
413
+ "through the language head."
414
+ )
415
+
416
+ slice_indices = (
417
+ slice(-logits_to_keep, None)
418
+ if isinstance(logits_to_keep, int)
419
+ else logits_to_keep
420
+ )
421
+ kept_hidden_states = hidden_states[:, slice_indices, :]
422
+ has_effective_supervision = labels_for_loss is not None or shift_labels is not None
423
+ loss = None
424
+ # Only the removed fused-loss path produced these; the language head does
425
+ # not, so they stay None and keep TimeBraidOutput's shape stable.
426
+ token_accuracy = None
427
+ predicted_tokens = None
428
+ logits = llm.lm_head(kept_hidden_states)
429
+ if has_effective_supervision:
430
+ loss = llm.loss_function(
431
+ logits=logits,
432
+ labels=labels_for_loss,
433
+ shift_labels=shift_labels,
434
+ vocab_size=llm.config.vocab_size,
435
+ **loss_kwargs,
436
+ )
437
+
438
+ return TimeBraidOutput(
439
+ loss=loss,
440
+ logits=logits,
441
+ past_key_values=past_key_values,
442
+ hidden_states=decoder_hidden_states,
443
+ attentions=decoder_attentions,
444
+ token_accuracy=token_accuracy,
445
+ predicted_tokens=predicted_tokens,
446
+ )
447
+
448
+
449
+ class TimeBraid(PreTrainedModel):
450
+ r"""Top-level model with direct language, time-series, and attention components."""
451
+
452
+ # This is the complete trainable/inference component graph. Keeping the
453
+ # ownership list on the public model makes optional topology readable
454
+ # without inspecting the numeric execution helpers.
455
+ llm: PreTrainedModel
456
+ generation_tsfm: nn.Module
457
+ understanding_tsfm: Optional[nn.Module]
458
+ global_residual_attention: nn.ModuleDict
459
+ packed_attention: nn.Module
460
+ understanding_head: Optional[nn.Module]
461
+
462
+ config_class = TimeBraidConfig
463
+ base_model_prefix = "llm"
464
+ main_input_name = "input_ids"
465
+ _supports_flash_attn_2 = True
466
+ _supports_sdpa = True
467
+ supports_gradient_checkpointing = True
468
+ _tied_weights_keys = {"llm.lm_head.weight": "llm.model.embed_tokens.weight"}
469
+
470
+ def __init__(
471
+ self,
472
+ config: TimeBraidConfig,
473
+ llm: Optional[PreTrainedModel] = None,
474
+ runtime_options: Optional[dict[str, Any]] = None,
475
+ ) -> None:
476
+ if not isinstance(config, TimeBraidConfig):
477
+ raise ValueError(
478
+ f"TimeBraid requires TimeBraidConfig, got {type(config).__name__}."
479
+ )
480
+ super().__init__(config)
481
+ self.llm = (
482
+ llm
483
+ if llm is not None
484
+ else AutoModelForCausalLM.from_config(config.llm_config)
485
+ )
486
+ self.config.llm_config = self.llm.config
487
+ self.config._sync_llm_surface()
488
+ self.generation_config = self.llm.generation_config
489
+
490
+ resolved_runtime_options = _runtime_options_from_timebraid_config(config)
491
+ if runtime_options:
492
+ resolved_runtime_options.update(runtime_options)
493
+ initialize_timebraid_components(
494
+ self,
495
+ self.llm.model.config,
496
+ resolved_runtime_options,
497
+ )
498
+ # Optional training integration may enable the fused hidden-to-loss tail
499
+ # after construction; that tail does not replace either Qwen forward method.
500
+ self.generation_tsfm.eval()
501
+ if self.understanding_tsfm is not None:
502
+ self.understanding_tsfm.eval()
503
+ self._sync_hf_loading_contracts()
504
+
505
+ def _sync_hf_loading_contracts(self) -> None:
506
+ """Synchronize tied-weight and parallelism metadata without generic post-init."""
507
+ self._tied_weights_keys = {
508
+ "llm.lm_head.weight": "llm.model.embed_tokens.weight"
509
+ }
510
+ self.all_tied_weights_keys = dict(self._tied_weights_keys)
511
+ tied_keys = getattr(self.llm, "all_tied_weights_keys", None)
512
+ if tied_keys:
513
+ self.all_tied_weights_keys.update(
514
+ {f"llm.{key}": f"llm.{value}" for key, value in tied_keys.items()}
515
+ )
516
+ # Transformers expects composite wrappers to expose concrete empty
517
+ # parallelism plans during distributed `from_pretrained()` allocator
518
+ # warmup, so mirror the child plans here.
519
+ for plan_attr in ("_tp_plan", "_ep_plan", "_pp_plan"):
520
+ merged_plan = {}
521
+ child_plan = getattr(self.llm, plan_attr, None)
522
+ if child_plan:
523
+ merged_plan.update(
524
+ {f"llm.{key}": value for key, value in child_plan.copy().items()}
525
+ )
526
+ setattr(self, plan_attr, merged_plan)
527
+
528
+ def train(self, mode: bool = True) -> "TimeBraid":
529
+ super().train(mode)
530
+ # TimesFM towers stay in eval mode during full-parameter training. Module
531
+ # mode controls stochastic behavior independently of `requires_grad`.
532
+ self.generation_tsfm.eval()
533
+ if self.understanding_tsfm is not None:
534
+ self.understanding_tsfm.eval()
535
+ return self
536
+
537
+ def gradient_checkpointing_enable(
538
+ self, gradient_checkpointing_kwargs: Optional[dict[str, Any]] = None
539
+ ) -> None:
540
+ super().gradient_checkpointing_enable(
541
+ gradient_checkpointing_kwargs=gradient_checkpointing_kwargs
542
+ )
543
+ sync_gradient_checkpointing_from_decoder_layers(self, self.llm.model.layers)
544
+
545
+ def gradient_checkpointing_disable(self) -> None:
546
+ super().gradient_checkpointing_disable()
547
+ sync_gradient_checkpointing_from_decoder_layers(self, self.llm.model.layers)
548
+
549
+ def forward(
550
+ self,
551
+ input_ids: Optional[torch.LongTensor] = None,
552
+ attention_mask: Optional[torch.Tensor] = None,
553
+ position_ids: Optional[torch.LongTensor] = None,
554
+ past_key_values: Optional[Cache] = None,
555
+ inputs_embeds: Optional[torch.FloatTensor] = None,
556
+ labels: Optional[torch.LongTensor] = None,
557
+ use_cache: Optional[bool] = None,
558
+ cache_position: Optional[torch.LongTensor] = None,
559
+ logits_to_keep: Union[int, torch.Tensor] = 0,
560
+ output_attentions: Optional[bool] = None,
561
+ output_hidden_states: Optional[bool] = None,
562
+ return_dict: Optional[bool] = None,
563
+ skip_logits: Optional[bool] = None,
564
+ shift_labels: Optional[torch.LongTensor] = None,
565
+ ts_values: object = None,
566
+ ts_lengths: object = None,
567
+ ts_loss_start_idxs: object = None,
568
+ ts_loss_roi_masks: object = None,
569
+ ts_roles: object = None,
570
+ ts_segment_ids: object = None,
571
+ ts_span_mask: object = None,
572
+ ts_text_start_token_idxs: object = None,
573
+ ts_text_end_token_idxs: object = None,
574
+ active_rows: Optional[torch.Tensor] = None,
575
+ num_items_in_batch: Optional[object] = None,
576
+ mot_ts_num_records_by_horizon: Optional[object] = None,
577
+ mot_ts_num_roi_targets_by_horizon: Optional[object] = None,
578
+ mot_ts_understanding_num_points: Optional[object] = None,
579
+ mot_ts_understanding_num_fft_elements: Optional[object] = None,
580
+ **kwargs: Any,
581
+ ) -> TimeBraidOutput | tuple[Any, ...]:
582
+ # Generation-only controls can accompany processor batches, but never
583
+ # participate in the causal forward computation.
584
+ for key in _MOT_GENERATION_CONTROL_FIELDS:
585
+ kwargs.pop(key, None)
586
+
587
+ payload = TimeBraidPayload(
588
+ ts_values=ts_values,
589
+ ts_lengths=ts_lengths,
590
+ ts_loss_start_idxs=ts_loss_start_idxs,
591
+ ts_loss_roi_masks=ts_loss_roi_masks,
592
+ ts_roles=ts_roles,
593
+ ts_segment_ids=ts_segment_ids,
594
+ ts_span_mask=ts_span_mask,
595
+ ts_text_start_token_idxs=ts_text_start_token_idxs,
596
+ ts_text_end_token_idxs=ts_text_end_token_idxs,
597
+ )
598
+ use_timebraid_decoder = (
599
+ isinstance(past_key_values, MoTDynamicCache) or payload.has_runtime_inputs()
600
+ )
601
+ if not use_timebraid_decoder and isinstance(input_ids, torch.Tensor):
602
+ use_timebraid_decoder = bool(
603
+ torch.logical_or(
604
+ input_ids.eq(int(self.ts_open_token_id)),
605
+ input_ids.eq(int(self.ts_close_token_id)),
606
+ )
607
+ .any()
608
+ .item()
609
+ )
610
+ if active_rows is not None and not isinstance(past_key_values, MoTDynamicCache):
611
+ raise RuntimeError("active_rows requires an existing MoTDynamicCache.")
612
+
613
+ if return_dict is None:
614
+ resolved_return_dict = bool(self.llm.config.use_return_dict)
615
+ elif type(return_dict) is bool:
616
+ resolved_return_dict = return_dict
617
+ else:
618
+ raise TypeError(
619
+ f"TimeBraid forward return_dict must be a bool or None, got {return_dict!r}."
620
+ )
621
+ labels_for_loss = _labels_with_language_supervision(labels)
622
+ if not use_timebraid_decoder:
623
+ _require_binary_plain_attention_mask(attention_mask)
624
+ if skip_logits is not None and type(skip_logits) is not bool:
625
+ raise TypeError(
626
+ "TimeBraid forward skip_logits must be a bool or None, got "
627
+ f"{skip_logits!r}."
628
+ )
629
+ use_outer_lm_head = shift_labels is not None and labels_for_loss is None
630
+ if skip_logits and not use_outer_lm_head:
631
+ raise ValueError(
632
+ "skip_logits=True requires fused TimeBraid language loss."
633
+ )
634
+ plain_loss_kwargs = dict(kwargs)
635
+ if num_items_in_batch is not None:
636
+ plain_loss_kwargs["num_items_in_batch"] = num_items_in_batch
637
+ plain_forward_kwargs = dict(plain_loss_kwargs)
638
+ if shift_labels is not None:
639
+ plain_forward_kwargs["shift_labels"] = shift_labels
640
+ if use_outer_lm_head:
641
+ # Run Qwen's ordinary decoder, then let TimeBraid consume its
642
+ # hidden states. This outer tail exists for explicit
643
+ # shift-label-only loss, where Qwen cannot build the labels.
644
+ decoder_outputs = self.llm.model(
645
+ input_ids=input_ids,
646
+ attention_mask=attention_mask,
647
+ position_ids=position_ids,
648
+ past_key_values=past_key_values,
649
+ inputs_embeds=inputs_embeds,
650
+ use_cache=use_cache,
651
+ cache_position=cache_position,
652
+ output_attentions=output_attentions,
653
+ output_hidden_states=output_hidden_states,
654
+ return_dict=True,
655
+ **kwargs,
656
+ )
657
+ outputs = _build_lm_output(
658
+ llm=self.llm,
659
+ hidden_states=decoder_outputs.last_hidden_state,
660
+ past_key_values=decoder_outputs.past_key_values,
661
+ decoder_hidden_states=decoder_outputs.hidden_states,
662
+ decoder_attentions=decoder_outputs.attentions,
663
+ labels=labels,
664
+ labels_for_loss=labels_for_loss,
665
+ shift_labels=shift_labels,
666
+ logits_to_keep=logits_to_keep,
667
+ skip_logits=skip_logits,
668
+ loss_kwargs=plain_loss_kwargs,
669
+ )
670
+ else:
671
+ outputs = self.llm(
672
+ input_ids=input_ids,
673
+ attention_mask=attention_mask,
674
+ position_ids=position_ids,
675
+ past_key_values=past_key_values,
676
+ inputs_embeds=inputs_embeds,
677
+ labels=labels_for_loss,
678
+ use_cache=use_cache,
679
+ cache_position=cache_position,
680
+ logits_to_keep=logits_to_keep,
681
+ output_attentions=output_attentions,
682
+ output_hidden_states=output_hidden_states,
683
+ return_dict=True,
684
+ **plain_forward_kwargs,
685
+ )
686
+ outputs, breakdown = merge_timebraid_losses(
687
+ model=self,
688
+ mot_runtime=None,
689
+ outputs=outputs,
690
+ )
691
+ timebraid_output = _as_timebraid_output(outputs, breakdown)
692
+ if resolved_return_dict:
693
+ return timebraid_output
694
+ # Preserve Qwen's tuple prefix and ordering. Logits keeps its slot
695
+ # when fused execution leaves it as None, followed by optional decoder
696
+ # outputs; TimeBraid's token statistics append afterward.
697
+ tuple_output = (timebraid_output.logits,)
698
+ tuple_output += tuple(
699
+ value
700
+ for value in (
701
+ timebraid_output.past_key_values,
702
+ timebraid_output.hidden_states,
703
+ timebraid_output.attentions,
704
+ )
705
+ if value is not None
706
+ )
707
+ if timebraid_output.loss is not None:
708
+ tuple_output = (timebraid_output.loss,) + tuple_output
709
+ if timebraid_output.token_accuracy is not None:
710
+ tuple_output += (timebraid_output.token_accuracy,)
711
+ if timebraid_output.predicted_tokens is not None:
712
+ tuple_output += (timebraid_output.predicted_tokens,)
713
+ return tuple_output
714
+
715
+ if not resolved_return_dict:
716
+ raise RuntimeError("TS-routed TimeBraid forward requires return_dict=True.")
717
+ cache_implementation = kwargs.pop("cache_implementation", None)
718
+ if use_cache is None:
719
+ configured_use_cache = self.llm.model.config.use_cache
720
+ if type(configured_use_cache) is not bool:
721
+ raise TypeError(
722
+ "Qwen config use_cache must be a bool, got "
723
+ f"{configured_use_cache!r}."
724
+ )
725
+ resolved_use_cache = configured_use_cache and not self.llm.model.training
726
+ elif type(use_cache) is bool:
727
+ resolved_use_cache = use_cache
728
+ else:
729
+ raise TypeError(
730
+ f"TimeBraid forward use_cache must be a bool or None, got {use_cache!r}."
731
+ )
732
+ decoder_result = run_timebraid_decoder(
733
+ owner=self,
734
+ text_model=self.llm.model,
735
+ payload=payload,
736
+ input_ids=input_ids,
737
+ attention_mask=attention_mask,
738
+ position_ids=position_ids,
739
+ past_key_values=past_key_values,
740
+ inputs_embeds=inputs_embeds,
741
+ use_cache=resolved_use_cache,
742
+ cache_position=cache_position,
743
+ output_attentions=output_attentions,
744
+ output_hidden_states=output_hidden_states,
745
+ return_dict=True,
746
+ active_rows=active_rows,
747
+ cache_implementation=cache_implementation,
748
+ collect_incremental_kv_cache=resolved_use_cache,
749
+ )
750
+
751
+ loss_kwargs = dict(kwargs)
752
+ if num_items_in_batch is not None:
753
+ loss_kwargs["num_items_in_batch"] = num_items_in_batch
754
+ outputs = _build_lm_output(
755
+ llm=self.llm,
756
+ hidden_states=decoder_result.hidden_states,
757
+ past_key_values=decoder_result.past_key_values,
758
+ decoder_hidden_states=None,
759
+ decoder_attentions=None,
760
+ labels=labels,
761
+ labels_for_loss=labels_for_loss,
762
+ shift_labels=shift_labels,
763
+ logits_to_keep=logits_to_keep,
764
+ skip_logits=skip_logits,
765
+ loss_kwargs=loss_kwargs,
766
+ )
767
+ outputs, breakdown = merge_timebraid_losses(
768
+ model=self,
769
+ mot_runtime=decoder_result.mot_runtime,
770
+ outputs=outputs,
771
+ mot_ts_num_records_by_horizon=mot_ts_num_records_by_horizon,
772
+ mot_ts_num_roi_targets_by_horizon=mot_ts_num_roi_targets_by_horizon,
773
+ mot_ts_understanding_num_points=mot_ts_understanding_num_points,
774
+ mot_ts_understanding_num_fft_elements=(
775
+ mot_ts_understanding_num_fft_elements
776
+ ),
777
+ )
778
+ return _as_timebraid_output(outputs, breakdown)
779
+
780
+ def save_pretrained(self, *args: Any, **kwargs: Any) -> None:
781
+ # Match Transformers' own dtype authority, but update both composite
782
+ # config surfaces before the base implementation serializes either one.
783
+ if self.config.llm_config is not self.llm.config:
784
+ raise RuntimeError(
785
+ "TimeBraid save requires config.llm_config to be the live LLM config."
786
+ )
787
+ # The checkpoint is self-contained, so machine-local source paths are
788
+ # neither restore semantics nor useful public provenance.
789
+ self.config._name_or_path = ""
790
+ self.config.llm_config._name_or_path = ""
791
+ self.config.llm_config.dtype = get_parameter_dtype(self)
792
+ self.config._sync_llm_surface()
793
+ return super().save_pretrained(*args, **kwargs)
794
+
795
+ def prepare_inputs_for_generation(
796
+ self, *args: Any, **kwargs: Any
797
+ ) -> dict[str, Any]:
798
+ return self.llm.prepare_inputs_for_generation(*args, **kwargs)
799
+
800
+ def generate_mixed(
801
+ self,
802
+ inputs: "TimeBraidBatchFeature",
803
+ *,
804
+ max_new_tokens: Optional[int] = None,
805
+ max_length: Optional[int] = None,
806
+ mot_target_total_lengths: Optional[Sequence[int]] = None,
807
+ mot_forecast_head_len: Optional[int] = None,
808
+ mot_return_forecast_quantiles: bool = False,
809
+ eos_token_id: Optional[Union[int, Sequence[int]]] = None,
810
+ pad_token_id: Optional[int] = None,
811
+ use_cache: Optional[bool] = None,
812
+ cache_implementation: Optional[str] = None,
813
+ ) -> TimeBraidGenerateOutput:
814
+ """Generate mixed text and time series, or raise.
815
+
816
+ `inputs` is the mapping returned by
817
+ `TimeBraidProcessor.apply_chat_template`, which carries `input_ids`,
818
+ `attention_mask`, the complete TS payload, and the target horizons.
819
+ Everything the processor cannot know is a keyword argument here.
820
+
821
+ This is the explicit counterpart to `generate`. It states its whole
822
+ contract in its signature, so a misspelled argument is a `TypeError`
823
+ rather than a silent downgrade, and an argument this scheduler cannot
824
+ honour — `temperature`, `logits_processor`, anything sampling-related —
825
+ is simply not a parameter. Unlike `generate`, it never falls back to
826
+ plain text: if the request does not resolve to mixed generation it
827
+ raises and says which signal was missing.
828
+
829
+ Supply exactly one of `max_new_tokens` or `max_length`.
830
+
831
+ TimeBraid's own arguments keep the `mot_` prefix they carry everywhere
832
+ else — in `config.json`, in the processor's output, and in `generate`'s
833
+ keyword arguments — so one name means one thing at every boundary.
834
+ """
835
+ require_mixed_generation_inputs(inputs)
836
+ input_ids = validate_mot_generation_input_ids(inputs["input_ids"])
837
+ batch_size = int(input_ids.shape[0])
838
+
839
+ payload = TimeBraidPayload(
840
+ **{name: inputs.get(name) for name in TIMEBRAID_TS_PAYLOAD_FIELDS}
841
+ )
842
+ controls = NormalizedMoTGenerationControls.normalize(
843
+ batch_size=batch_size,
844
+ # Supplied by the processor, which owns the prompt surface.
845
+ target_horizons=inputs.get("mot_target_horizons"),
846
+ target_history_span_idxs=inputs.get("mot_target_history_span_idxs"),
847
+ # Supplied by the caller: the processor cannot know these.
848
+ target_total_lengths=mot_target_total_lengths,
849
+ forecast_head_len=mot_forecast_head_len,
850
+ return_forecast_quantiles=mot_return_forecast_quantiles,
851
+ )
852
+
853
+ # No route resolution here: `require_mixed_generation_inputs` above has
854
+ # already established the one signal that matters, a complete TS
855
+ # payload. Inferring intent is the compatibility shim's problem.
856
+ if controls.target_horizons is None:
857
+ raise RuntimeError(
858
+ "generate_mixed requires `mot_target_horizons` in `inputs`. Pass "
859
+ "`horizon=` to TimeBraidProcessor.apply_chat_template, which "
860
+ "supplies it; use 0 to forbid TS rollout."
861
+ )
862
+
863
+ generation_kwargs: dict[str, Any] = {}
864
+ for name, value in (
865
+ ("max_new_tokens", max_new_tokens),
866
+ ("max_length", max_length),
867
+ ("eos_token_id", eos_token_id),
868
+ ("pad_token_id", pad_token_id),
869
+ ("use_cache", use_cache),
870
+ ("cache_implementation", cache_implementation),
871
+ ):
872
+ if value is not None:
873
+ generation_kwargs[name] = value
874
+
875
+ return run_timebraid_generate(
876
+ model=self,
877
+ text_model=self.llm.model,
878
+ payload=payload,
879
+ input_ids=input_ids,
880
+ attention_mask=inputs.get("attention_mask"),
881
+ controls=controls,
882
+ kwargs=generation_kwargs,
883
+ )
884
+
885
+ def generate(
886
+ self, *args: Any, **kwargs: Any
887
+ ) -> TimeBraidGenerateOutput | torch.Tensor | ModelOutput:
888
+ """Hugging Face-compatible dispatch over `**kwargs`.
889
+
890
+ Kept so `model.generate(**processor_output, max_new_tokens=...)` keeps
891
+ working, and so plain-text requests still reach the host language
892
+ model. It resolves a route and delegates; the contract for mixed
893
+ generation lives on `generate_mixed`.
894
+ """
895
+ generation_kwargs = dict(kwargs)
896
+ payload = TimeBraidPayload.pop_from_kwargs(generation_kwargs)
897
+ generation_controls = {
898
+ key: generation_kwargs.pop(key)
899
+ for key in _MOT_GENERATION_CONTROL_FIELDS
900
+ if key in generation_kwargs
901
+ }
902
+ _reject_unknown_timebraid_kwargs(generation_kwargs)
903
+
904
+ positional_input = args[0] if args else None
905
+ keyword_input = generation_kwargs.get("input_ids")
906
+ hf_input = generation_kwargs.get("inputs")
907
+ route_input = (
908
+ positional_input
909
+ if positional_input is not None
910
+ else (keyword_input if keyword_input is not None else hf_input)
911
+ )
912
+ if isinstance(route_input, torch.Tensor) and route_input.ndim == 2:
913
+ control_batch_size = int(route_input.shape[0])
914
+ elif isinstance(payload.ts_values, torch.Tensor) and payload.ts_values.ndim > 0:
915
+ control_batch_size = int(payload.ts_values.shape[0])
916
+ else:
917
+ control_batch_size = 1
918
+ controls = normalize_mot_generation_controls(
919
+ generation_controls,
920
+ batch_size=control_batch_size,
921
+ )
922
+
923
+ if isinstance(generation_kwargs.get("past_key_values"), MoTDynamicCache):
924
+ raise RuntimeError(
925
+ "MoT native mixed generate accepts a fresh prompt and owns its full "
926
+ "phase scheduler; continue a public forward cache through "
927
+ "model.forward instead."
928
+ )
929
+
930
+ decision = resolve_generation_route(
931
+ payload=payload,
932
+ route_input=route_input,
933
+ target_horizons=controls.target_horizons,
934
+ target_total_lengths=controls.target_total_lengths,
935
+ ts_open_token_id=int(self.ts_open_token_id),
936
+ ts_close_token_id=int(self.ts_close_token_id),
937
+ )
938
+ if decision.route is GenerationRoute.PLAIN_TEXT:
939
+ _require_binary_plain_attention_mask(
940
+ generation_kwargs.get("attention_mask")
941
+ )
942
+ return self.llm.generate(*args, **generation_kwargs)
943
+ if controls.target_horizons is None:
944
+ raise RuntimeError(
945
+ "Mixed generation requires explicit `mot_target_horizons`. Pass "
946
+ "`horizon=` to TimeBraidProcessor.apply_chat_template, which "
947
+ "supplies it; use 0 to forbid TS rollout, or a positive integer "
948
+ "to allow one generated TS span."
949
+ )
950
+
951
+ if len(args) > 1:
952
+ raise TypeError(
953
+ "TS generation accepts at most one positional input tensor."
954
+ )
955
+ provided_inputs = [
956
+ value
957
+ for value in (positional_input, keyword_input, hf_input)
958
+ if value is not None
959
+ ]
960
+ if len(provided_inputs) != 1:
961
+ raise ValueError(
962
+ "TS generation requires exactly one of positional inputs, "
963
+ "input_ids, or inputs."
964
+ )
965
+ input_ids = validate_mot_generation_input_ids(provided_inputs[0])
966
+ generation_kwargs.pop("input_ids", None)
967
+ generation_kwargs.pop("inputs", None)
968
+ attention_mask = generation_kwargs.pop("attention_mask", None)
969
+ return run_timebraid_generate(
970
+ model=self,
971
+ text_model=self.llm.model,
972
+ payload=payload,
973
+ input_ids=input_ids,
974
+ attention_mask=attention_mask,
975
+ controls=controls,
976
+ kwargs=generation_kwargs,
977
+ )
978
+
979
+ def get_input_embeddings(self) -> nn.Module:
980
+ return self.llm.get_input_embeddings()
981
+
982
+ def set_input_embeddings(self, value: nn.Module) -> None:
983
+ self.llm.set_input_embeddings(value)
984
+
985
+ def get_output_embeddings(self) -> Optional[nn.Module]:
986
+ return self.llm.get_output_embeddings()
987
+
988
+ def set_output_embeddings(self, value: nn.Module) -> None:
989
+ self.llm.set_output_embeddings(value)
990
+
991
+ def tie_weights(self) -> None:
992
+ super().tie_weights()
993
+ self._sync_hf_loading_contracts()
994
+
995
+ def can_generate(self) -> bool:
996
+ llm = self._modules.get("llm") if hasattr(self, "_modules") else None
997
+ return True if llm is None else llm.can_generate()
998
+
999
+
1000
+ def register_timebraid_auto_classes() -> None:
1001
+ """Register TimeBraid with HF auto classes in the active Python process."""
1002
+ from transformers import AutoProcessor
1003
+
1004
+ from ..processing_timebraid import TimeBraidProcessor
1005
+
1006
+ AutoConfig.register(TimeBraidConfig.model_type, TimeBraidConfig, exist_ok=True)
1007
+ AutoProcessor.register(TimeBraidConfig, TimeBraidProcessor, exist_ok=True)
1008
+ # Deliberately not registered with plain `AutoModel`: that class means
1009
+ # "base model without a task head", and TimeBraid is a causal LM. The
1010
+ # published auto_map must not advertise it either — see AUTO_MAP in
1011
+ # scripts/build-huggingface-timebraid-model.py.
1012
+ AutoModelForCausalLM.register(TimeBraidConfig, TimeBraid, exist_ok=True)
1013
+
1014
+
1015
+ __all__ = [
1016
+ "TimeBraid",
1017
+ "TimeBraidConfig",
1018
+ "TimeBraidLossBreakdown",
1019
+ "TimeBraidOutput",
1020
+ "register_timebraid_auto_classes",
1021
+ ]
timebraid/processing_timebraid.py ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face processor for TimeBraid text and time-series generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import torch
11
+ from transformers import ProcessorMixin
12
+ from transformers.feature_extraction_utils import BatchFeature
13
+
14
+ from .data.mot_utils import (
15
+ ROLE_TO_ID,
16
+ build_mot_batch_timeseries_tensors,
17
+ normalize_timeseries_spans,
18
+ )
19
+ from .model.mot.generation_route import FinishReason
20
+
21
+ _ASSISTANT_PREFIX = "<|im_start|>assistant\n<think>\n\n</think>\n\n"
22
+ _ASSISTANT_SUFFIX = "<|im_end|>"
23
+ _NO_THINK_SUFFIX = "/no_think"
24
+ _STAT_SIGNIFICANT_DIGITS = 6
25
+ _STAT_DECIMAL_LOWER_BOUND = 1.0e-4
26
+ _STAT_DECIMAL_UPPER_BOUND = 1.0e6
27
+ _QWEN_STOP_TOKENS = ("<|im_end|>", "<|endoftext|>")
28
+
29
+
30
+ def _coerce_finite_float(value: float, *, name: str) -> float:
31
+ coerced = float(value)
32
+ if not math.isfinite(coerced):
33
+ raise ValueError(f"{name} must be finite, got {value!r}")
34
+ return coerced
35
+
36
+
37
+ def _normalize_scientific_notation(text: str) -> str:
38
+ mantissa, exponent = text.split("e")
39
+ mantissa = mantissa.rstrip("0").rstrip(".")
40
+ if mantissa in {"", "-0"}:
41
+ return "0"
42
+ sign = ""
43
+ if exponent.startswith("-"):
44
+ sign = "-"
45
+ digits = exponent.lstrip("+-0") or "0"
46
+ return f"{mantissa}e{sign}{digits}"
47
+
48
+
49
+ def _strip_fixed_decimal(text: str) -> str:
50
+ stripped = text.rstrip("0").rstrip(".") if "." in text else text
51
+ return "0" if stripped in {"", "-0"} else stripped
52
+
53
+
54
+ def format_stat(
55
+ value: float,
56
+ significant_digits: int = _STAT_SIGNIFICANT_DIGITS,
57
+ decimal_lower_bound: float = _STAT_DECIMAL_LOWER_BOUND,
58
+ decimal_upper_bound: float = _STAT_DECIMAL_UPPER_BOUND,
59
+ ) -> str:
60
+ """Format one prompt-visible normalization statistic."""
61
+ if significant_digits <= 0:
62
+ raise ValueError(
63
+ f"significant_digits must be positive, got {significant_digits}"
64
+ )
65
+ if decimal_lower_bound <= 0:
66
+ raise ValueError(
67
+ f"decimal_lower_bound must be positive, got {decimal_lower_bound}"
68
+ )
69
+ if decimal_upper_bound <= decimal_lower_bound:
70
+ raise ValueError(
71
+ "decimal_upper_bound must be greater than decimal_lower_bound, got "
72
+ f"{decimal_upper_bound} <= {decimal_lower_bound}"
73
+ )
74
+ coerced = _coerce_finite_float(value, name="value")
75
+ if coerced == 0.0:
76
+ return "0"
77
+ magnitude = abs(coerced)
78
+ if decimal_lower_bound <= magnitude < decimal_upper_bound:
79
+ exponent = math.floor(math.log10(magnitude))
80
+ decimals = max(0, significant_digits - 1 - exponent)
81
+ rendered = _strip_fixed_decimal(f"{coerced:.{decimals}f}")
82
+ else:
83
+ rendered = _normalize_scientific_notation(
84
+ f"{coerced:.{significant_digits - 1}e}"
85
+ )
86
+ parsed = float(rendered)
87
+ if rendered in {"", "-0"} or (coerced != 0.0 and parsed == 0.0):
88
+ raise ValueError(
89
+ "Formatted normalization statistic lost its nonzero magnitude: "
90
+ f"value={coerced!r}, rendered={rendered!r}."
91
+ )
92
+ relative_error = abs(parsed - coerced) / abs(coerced)
93
+ if relative_error > 10.0 ** (1 - significant_digits):
94
+ raise ValueError(
95
+ "Formatted normalization statistic lost too much precision: "
96
+ f"value={coerced!r}, rendered={rendered!r}."
97
+ )
98
+ return rendered
99
+
100
+
101
+ def compute_zscore_stats(
102
+ values: Sequence[float], eps: float = 1.0e-6
103
+ ) -> tuple[float, float]:
104
+ """Return population mean and epsilon-floored population standard deviation."""
105
+ if not values:
106
+ raise ValueError("Time-series values must be non-empty.")
107
+ mean = sum(values) / float(len(values))
108
+ variance = sum((value - mean) ** 2 for value in values) / float(len(values))
109
+ std = max(variance**0.5, float(eps))
110
+ if not math.isfinite(mean) or not math.isfinite(std):
111
+ raise ValueError("Time-series z-score statistics must be finite.")
112
+ return float(mean), float(std)
113
+
114
+
115
+ def zscore_with_stats(values: Sequence[float], mean: float, std: float) -> list[float]:
116
+ """Normalize finite values with explicit statistics."""
117
+ if not math.isfinite(std) or std <= 0.0:
118
+ raise ValueError(f"std must be finite and positive, got {std!r}.")
119
+ normalized = [(float(value) - mean) / std for value in values]
120
+ if any(not math.isfinite(value) for value in normalized):
121
+ raise ValueError("Time-series z-score values must be finite.")
122
+ return normalized
123
+
124
+
125
+ def build_inline_named_zscore_span_reference(
126
+ *, length_tag: int, mean: float, std: float, precision: int | None = None
127
+ ) -> str:
128
+ """Render the canonical stats block and generic TS delimiter pair."""
129
+ del precision
130
+ if type(length_tag) is not int or length_tag <= 0:
131
+ raise ValueError(f"length_tag must be a positive integer, got {length_tag!r}.")
132
+ return (
133
+ f"<stats>len={length_tag}, mean={format_stat(mean)}, "
134
+ f"std={format_stat(std)}</stats> <ts></ts>"
135
+ )
136
+
137
+
138
+ def format_qwen_chat_turns(turns: Sequence[tuple[str, str]]) -> str:
139
+ """Render the canonical Qwen transcript used by TimeBraid."""
140
+ if not turns:
141
+ raise ValueError("Chat turns must be non-empty.")
142
+ rendered: list[str] = []
143
+ for index, (role, content) in enumerate(turns):
144
+ if role not in {"system", "user", "assistant"}:
145
+ raise ValueError(f"Unsupported chat role {role!r} at index {index}.")
146
+ allow_empty_scaffold = role == "assistant" and index == len(turns) - 1
147
+ if not content and not allow_empty_scaffold:
148
+ raise ValueError(f"Chat content must be non-empty at index {index}.")
149
+ if role == "assistant":
150
+ rendered.append(
151
+ f"<|im_start|>assistant\n<think>\n\n</think>\n\n{content}<|im_end|>\n"
152
+ )
153
+ else:
154
+ rendered.append(f"<|im_start|>{role}\n{content}<|im_end|>\n")
155
+ return "".join(rendered)
156
+
157
+
158
+ def _qwen_stop_token_ids(tokenizer: Any) -> list[int]:
159
+ """Resolve only the canonical single-token Qwen generation stops."""
160
+ resolved: list[int] = []
161
+ eos_token_id = getattr(tokenizer, "eos_token_id", None)
162
+ if type(eos_token_id) is int and eos_token_id >= 0:
163
+ resolved.append(eos_token_id)
164
+ for token in _QWEN_STOP_TOKENS:
165
+ token_ids = tokenizer(token, add_special_tokens=False)["input_ids"]
166
+ if not isinstance(token_ids, list) or len(token_ids) != 1:
167
+ continue
168
+ token_id = int(token_ids[0])
169
+ if tokenizer.unk_token_id is not None and token_id == int(
170
+ tokenizer.unk_token_id
171
+ ):
172
+ continue
173
+ if token_id not in resolved:
174
+ resolved.append(token_id)
175
+ return resolved
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class TimeBraidRequestContext:
180
+ """What `post_process_generation` needs that the tensors do not carry.
181
+
182
+ Typed rather than a bare dict because it travels beside the batch: a
183
+ serving worker that hands the tensors to another process needs to know
184
+ exactly what else must go with them, and a 9-key dict documented nowhere
185
+ could not tell it.
186
+ """
187
+
188
+ prompt_text: str
189
+ horizon: int | None
190
+ target_series_index: int
191
+ normalization: Any
192
+ prompt_width: int
193
+ prompt_tokens: int
194
+ num_timeseries_spans: int
195
+ eos_token_ids: Sequence[int]
196
+ pad_token_id: int | None
197
+
198
+
199
+ class TimeBraidBatchFeature(BatchFeature):
200
+ """Tensor inputs plus the non-mapping context needed for decoding.
201
+
202
+ The context rides as an attribute rather than as batch data because it is
203
+ not a tensor. `BatchFeature.to()` returns self, so the documented
204
+ processor -> generate -> post_process flow keeps it; `{**inputs}` and any
205
+ cross-process hand-off do not. Pass `context=` explicitly there.
206
+ """
207
+
208
+ def __init__(
209
+ self, data: dict[str, Any], *, postprocess_context: TimeBraidRequestContext
210
+ ):
211
+ if not isinstance(postprocess_context, TimeBraidRequestContext):
212
+ raise TypeError(
213
+ "postprocess_context must be a TimeBraidRequestContext, got "
214
+ f"{type(postprocess_context).__name__}."
215
+ )
216
+ super().__init__(data=data)
217
+ self.postprocess_context = postprocess_context
218
+
219
+
220
+ class TimeBraidProcessor(ProcessorMixin):
221
+ """Prepare one TimeBraid completion and decode its mixed generation output."""
222
+
223
+ attributes = ["tokenizer"]
224
+ tokenizer_class = "AutoTokenizer"
225
+
226
+ def __init__(
227
+ self,
228
+ tokenizer,
229
+ max_spans_per_sample: int = 64,
230
+ normalization_epsilon: float = 1.0e-6,
231
+ ) -> None:
232
+ if type(max_spans_per_sample) is not int or max_spans_per_sample <= 0:
233
+ raise ValueError(
234
+ "max_spans_per_sample must be a positive integer, got "
235
+ f"{max_spans_per_sample!r}."
236
+ )
237
+ if (
238
+ isinstance(normalization_epsilon, bool)
239
+ or not isinstance(normalization_epsilon, (int, float))
240
+ or not math.isfinite(float(normalization_epsilon))
241
+ or float(normalization_epsilon) <= 0.0
242
+ ):
243
+ raise ValueError(
244
+ "normalization_epsilon must be finite and positive, got "
245
+ f"{normalization_epsilon!r}."
246
+ )
247
+ self.max_spans_per_sample = max_spans_per_sample
248
+ self.normalization_epsilon = float(normalization_epsilon)
249
+ super().__init__(tokenizer)
250
+ self._validate_delimiters()
251
+
252
+ @property
253
+ def model_input_names(self) -> list[str]:
254
+ return [
255
+ "input_ids",
256
+ "attention_mask",
257
+ "ts_values",
258
+ "ts_lengths",
259
+ "ts_loss_start_idxs",
260
+ "ts_loss_roi_masks",
261
+ "ts_roles",
262
+ "ts_segment_ids",
263
+ "ts_span_mask",
264
+ "ts_text_start_token_idxs",
265
+ "ts_text_end_token_idxs",
266
+ "mot_target_horizons",
267
+ "mot_target_history_span_idxs",
268
+ ]
269
+
270
+ def _validate_delimiters(self) -> None:
271
+ resolved: list[int] = []
272
+ for delimiter in ("<ts>", "</ts>"):
273
+ token_ids = self.tokenizer(delimiter, add_special_tokens=False)["input_ids"]
274
+ if not isinstance(token_ids, list) or len(token_ids) != 1:
275
+ raise ValueError(
276
+ f"TimeBraid tokenizer must encode {delimiter!r} as one token, got {token_ids!r}."
277
+ )
278
+ token_id = int(token_ids[0])
279
+ if self.tokenizer.unk_token_id is not None and token_id == int(
280
+ self.tokenizer.unk_token_id
281
+ ):
282
+ raise ValueError(
283
+ f"TimeBraid tokenizer resolves {delimiter!r} to unk_token_id={token_id}."
284
+ )
285
+ resolved.append(token_id)
286
+ if resolved[0] == resolved[1]:
287
+ raise ValueError("TimeBraid TS delimiters must use distinct token IDs.")
288
+
289
+ def __call__(
290
+ self,
291
+ *,
292
+ messages: Sequence[Mapping[str, str]],
293
+ timeseries: Sequence[Sequence[float]] | None = None,
294
+ horizon: int | None = None,
295
+ target_series_index: int | None = None,
296
+ return_tensors: str = "pt",
297
+ ) -> TimeBraidBatchFeature:
298
+ return self.apply_chat_template(
299
+ messages,
300
+ timeseries=timeseries,
301
+ horizon=horizon,
302
+ target_series_index=target_series_index,
303
+ add_generation_prompt=True,
304
+ tokenize=True,
305
+ return_dict=True,
306
+ return_tensors=return_tensors,
307
+ )
308
+
309
+ def apply_chat_template(
310
+ self,
311
+ messages: Sequence[Mapping[str, str]],
312
+ *,
313
+ timeseries: Sequence[Sequence[float]] | None = None,
314
+ horizon: int | None = None,
315
+ target_series_index: int | None = None,
316
+ add_generation_prompt: bool = True,
317
+ tokenize: bool = True,
318
+ return_dict: bool = True,
319
+ return_tensors: str = "pt",
320
+ ) -> TimeBraidBatchFeature:
321
+ """Prepare one request; a positive horizon starts numeric forecasting."""
322
+ if add_generation_prompt is not True:
323
+ raise ValueError(
324
+ "add_generation_prompt must be true for TimeBraid inference."
325
+ )
326
+ if tokenize is not True:
327
+ raise ValueError("tokenize must be true for TimeBraid inference.")
328
+ if return_dict is not True:
329
+ raise ValueError("return_dict must be true for TimeBraid inference.")
330
+ if return_tensors != "pt":
331
+ raise ValueError(f"return_tensors must be 'pt', got {return_tensors!r}.")
332
+ normalized_messages = self._normalize_messages(messages)
333
+ raw_series = self._normalize_raw_timeseries(
334
+ [] if timeseries is None else timeseries
335
+ )
336
+ if horizon is not None and (type(horizon) is not int or horizon <= 0):
337
+ raise ValueError(f"horizon must be a positive integer, got {horizon!r}.")
338
+ resolved_target_series_index: int | None = None
339
+ if horizon is None:
340
+ if target_series_index is not None:
341
+ raise ValueError("target_series_index requires a forecast horizon.")
342
+ else:
343
+ if not raw_series:
344
+ raise ValueError("horizon requires at least one input time series.")
345
+ if target_series_index is None:
346
+ if len(raw_series) > 1:
347
+ raise ValueError(
348
+ "target_series_index is required when forecasting from "
349
+ "multiple input time series."
350
+ )
351
+ resolved_target_series_index = 0
352
+ elif type(target_series_index) is not int:
353
+ raise ValueError(
354
+ "target_series_index must be an integer, got "
355
+ f"{target_series_index!r}."
356
+ )
357
+ elif not 0 <= target_series_index < len(raw_series):
358
+ raise ValueError(
359
+ "target_series_index must select an input time series, got "
360
+ f"{target_series_index!r} for {len(raw_series)} series."
361
+ )
362
+ else:
363
+ resolved_target_series_index = target_series_index
364
+
365
+ spans: list[dict[str, Any]] = []
366
+ normalization: list[dict[str, float | str]] = []
367
+ references: list[str] = []
368
+ for values in raw_series:
369
+ try:
370
+ mean, std = compute_zscore_stats(values, self.normalization_epsilon)
371
+ except (OverflowError, ValueError) as exc:
372
+ raise ValueError(
373
+ "Time-series values cannot be z-score normalized."
374
+ ) from exc
375
+ normalized_values = zscore_with_stats(values, mean=mean, std=std)
376
+ role = "context" if horizon is not None else "observed"
377
+ spans.append(
378
+ {
379
+ "len": len(normalized_values),
380
+ "role": role,
381
+ "values": normalized_values,
382
+ "loss_start": len(normalized_values),
383
+ }
384
+ )
385
+ references.append(
386
+ build_inline_named_zscore_span_reference(
387
+ length_tag=len(values), mean=mean, std=std
388
+ )
389
+ )
390
+ normalization.append(
391
+ {
392
+ "method": "history_population_zscore",
393
+ "mean": mean,
394
+ "std": std,
395
+ "epsilon": self.normalization_epsilon,
396
+ }
397
+ )
398
+
399
+ if references:
400
+ references_text = "\n".join(
401
+ f"Series {index + 1}: {reference}"
402
+ for index, reference in enumerate(references)
403
+ )
404
+ target_text = ""
405
+ if horizon is not None and len(references) > 1:
406
+ target_text = (
407
+ "\nForecast target: "
408
+ f"Series {int(resolved_target_series_index) + 1}."
409
+ )
410
+ normalized_messages[-1] = (
411
+ "user",
412
+ normalized_messages[-1][1]
413
+ + "\n\nTime series inputs:\n"
414
+ + references_text
415
+ + target_text,
416
+ )
417
+ last_role, last_content = normalized_messages[-1]
418
+ normalized_messages[-1] = (
419
+ last_role,
420
+ last_content.rstrip() + "\n" + _NO_THINK_SUFFIX,
421
+ )
422
+ transcript = format_qwen_chat_turns([*normalized_messages, ("assistant", "")])
423
+ if not transcript.endswith(_ASSISTANT_PREFIX + _ASSISTANT_SUFFIX + "\n"):
424
+ raise RuntimeError("TimeBraid assistant scaffold rendering drifted.")
425
+ prompt = transcript[: -len(_ASSISTANT_SUFFIX + "\n")]
426
+
427
+ previous_padding_side = getattr(self.tokenizer, "padding_side", None)
428
+ self.tokenizer.padding_side = "left"
429
+ try:
430
+ tokenized = self.tokenizer(
431
+ [prompt],
432
+ add_special_tokens=False,
433
+ padding=True,
434
+ return_tensors="pt",
435
+ )
436
+ finally:
437
+ if previous_padding_side is not None:
438
+ self.tokenizer.padding_side = previous_padding_side
439
+ input_ids = tokenized["input_ids"]
440
+ attention_mask = tokenized["attention_mask"]
441
+ data: dict[str, Any] = {
442
+ "input_ids": input_ids,
443
+ "attention_mask": attention_mask,
444
+ }
445
+ normalized_spans = normalize_timeseries_spans(
446
+ spans,
447
+ sample_idx=0,
448
+ require_segment_id=False,
449
+ default_segment_id=1,
450
+ max_spans_per_sample=self.max_spans_per_sample,
451
+ )
452
+ if normalized_spans:
453
+ data.update(
454
+ build_mot_batch_timeseries_tensors(
455
+ tokenizer=self.tokenizer,
456
+ input_ids_by_sample=input_ids.tolist(),
457
+ spans_by_sample=[normalized_spans],
458
+ max_spans_per_sample=self.max_spans_per_sample,
459
+ )
460
+ )
461
+ data["mot_target_horizons"] = torch.tensor(
462
+ [0 if horizon is None else horizon], dtype=torch.long
463
+ )
464
+ if horizon is not None:
465
+ data["mot_target_history_span_idxs"] = torch.tensor(
466
+ [int(resolved_target_series_index)], dtype=torch.long
467
+ )
468
+ if len(normalized_spans) >= self.max_spans_per_sample:
469
+ raise ValueError(
470
+ "Forecasting requires one additional output span; provide "
471
+ f"fewer than {self.max_spans_per_sample} input time series."
472
+ )
473
+ # Forecasting is an explicit output request, so use the existing
474
+ # open-target protocol instead of asking the LM to emit <ts>.
475
+ # All input series and text remain visible; only the selected
476
+ # series supplies the numeric target's history and scale.
477
+ self._open_forecast_target(data, int(resolved_target_series_index))
478
+ prompt += "<ts>"
479
+ input_ids = data["input_ids"]
480
+ attention_mask = data["attention_mask"]
481
+ return TimeBraidBatchFeature(
482
+ data,
483
+ postprocess_context=TimeBraidRequestContext(
484
+ prompt_text=prompt,
485
+ horizon=horizon,
486
+ target_series_index=resolved_target_series_index,
487
+ normalization=normalization,
488
+ prompt_width=int(input_ids.shape[1]),
489
+ prompt_tokens=int(attention_mask[0].sum().item()),
490
+ num_timeseries_spans=len(normalized_spans)
491
+ + (1 if horizon is not None else 0),
492
+ eos_token_ids=_qwen_stop_token_ids(self.tokenizer),
493
+ pad_token_id=self.tokenizer.pad_token_id,
494
+ ),
495
+ )
496
+
497
+ def _open_forecast_target(
498
+ self, data: dict[str, Any], target_series_index: int
499
+ ) -> None:
500
+ """Seed a separate assistant target from the selected observed history."""
501
+ input_ids = data["input_ids"]
502
+ open_position = int(input_ids.shape[1])
503
+ open_id = self.tokenizer.convert_tokens_to_ids("<ts>")
504
+ data["input_ids"] = torch.cat(
505
+ [input_ids, input_ids.new_tensor([[open_id]])], dim=1
506
+ )
507
+ mask = data["attention_mask"]
508
+ data["attention_mask"] = torch.cat([mask, mask.new_ones((1, 1))], dim=1)
509
+ for field, value in list(data.items()):
510
+ if field.startswith("ts_"):
511
+ data[field] = torch.cat(
512
+ [value, value[:, target_series_index : target_series_index + 1]],
513
+ dim=1,
514
+ )
515
+ data["ts_roles"][0, -1] = ROLE_TO_ID["target"]
516
+ data["ts_text_start_token_idxs"][0, -1] = open_position
517
+ data["ts_text_end_token_idxs"][0, -1] = -1
518
+
519
+ def _normalize_messages(
520
+ self, messages: Sequence[Mapping[str, str]]
521
+ ) -> list[tuple[str, str]]:
522
+ if not isinstance(messages, Sequence) or isinstance(messages, (str, bytes)):
523
+ raise ValueError("messages must be a non-empty sequence.")
524
+ normalized: list[tuple[str, str]] = []
525
+ for index, message in enumerate(messages):
526
+ if not isinstance(message, Mapping):
527
+ raise ValueError(f"messages[{index}] must be an object.")
528
+ role = message.get("role")
529
+ content = message.get("content")
530
+ if role not in {"system", "user", "assistant"}:
531
+ raise ValueError(f"messages[{index}] has unsupported role {role!r}.")
532
+ if not isinstance(content, str) or not content.strip():
533
+ raise ValueError(f"messages[{index}].content must be non-empty text.")
534
+ try:
535
+ content.encode("utf-8", errors="strict")
536
+ except UnicodeEncodeError as exc:
537
+ raise ValueError(
538
+ f"messages[{index}].content must be valid UTF-8 text."
539
+ ) from exc
540
+ normalized.append((str(role), content.strip()))
541
+ if not normalized or normalized[-1][0] != "user":
542
+ raise ValueError("messages must end with a user turn.")
543
+ return normalized
544
+
545
+ def _normalize_raw_timeseries(
546
+ self, timeseries: Sequence[Sequence[float]]
547
+ ) -> list[list[float]]:
548
+ if not isinstance(timeseries, Sequence) or isinstance(timeseries, (str, bytes)):
549
+ raise ValueError("timeseries must be a sequence of numeric sequences.")
550
+ normalized: list[list[float]] = []
551
+ for series_index, series in enumerate(timeseries):
552
+ if not isinstance(series, Sequence) or isinstance(series, (str, bytes)):
553
+ raise ValueError(
554
+ f"timeseries[{series_index}] must be a numeric sequence."
555
+ )
556
+ values: list[float] = []
557
+ for value_index, value in enumerate(series):
558
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
559
+ raise ValueError(
560
+ f"timeseries[{series_index}][{value_index}] must be finite."
561
+ )
562
+ try:
563
+ casted = float(value)
564
+ except (OverflowError, ValueError) as exc:
565
+ raise ValueError(
566
+ f"timeseries[{series_index}][{value_index}] must be finite."
567
+ ) from exc
568
+ if not math.isfinite(casted):
569
+ raise ValueError(
570
+ f"timeseries[{series_index}][{value_index}] must be finite."
571
+ )
572
+ values.append(casted)
573
+ if not values:
574
+ raise ValueError(f"timeseries[{series_index}] must be non-empty.")
575
+ normalized.append(values)
576
+ return normalized
577
+
578
+ def post_process_generation(
579
+ self,
580
+ output: Any,
581
+ *,
582
+ model_inputs: TimeBraidBatchFeature | None = None,
583
+ context: TimeBraidRequestContext | None = None,
584
+ ) -> dict[str, Any]:
585
+ """Decode one mixed generation result.
586
+
587
+ Supply either `model_inputs` — the object `apply_chat_template`
588
+ returned, which carries its own context — or `context` directly. The
589
+ second form exists because the context is an attribute rather than
590
+ batch data, so `{**inputs}` and any cross-process hand-off drop it.
591
+ """
592
+ if model_inputs is not None and context is not None:
593
+ raise TypeError("Pass either model_inputs or context, not both.")
594
+ if context is None:
595
+ if model_inputs is None:
596
+ raise TypeError(
597
+ "post_process_generation needs the request context: pass "
598
+ "`model_inputs=` with the object returned by "
599
+ "apply_chat_template, or `context=` with its "
600
+ "`.postprocess_context` if the batch was rebuilt or moved "
601
+ "between processes."
602
+ )
603
+ if not isinstance(model_inputs, TimeBraidBatchFeature):
604
+ raise TypeError("model_inputs must be returned by TimeBraidProcessor.")
605
+ context = model_inputs.postprocess_context
606
+ if not isinstance(context, TimeBraidRequestContext):
607
+ raise TypeError(
608
+ "context must be a TimeBraidRequestContext, got "
609
+ f"{type(context).__name__}."
610
+ )
611
+ generated_ids = getattr(output, "sequences", output)
612
+ if not isinstance(generated_ids, torch.Tensor) or generated_ids.ndim != 2:
613
+ raise TypeError("generation output must expose sequences shaped [1, L].")
614
+ if int(generated_ids.shape[0]) != 1:
615
+ raise ValueError("TimeBraid postprocessing supports one request at a time.")
616
+ prompt_width = int(context.prompt_width)
617
+ suffix_ids = [
618
+ int(token_id)
619
+ for token_id in generated_ids[0, prompt_width:].detach().cpu().tolist()
620
+ ]
621
+ completion_tokens = len(suffix_ids)
622
+ eos_hit = False
623
+ eos_token_ids = set(context.eos_token_ids)
624
+ for index, token_id in enumerate(suffix_ids):
625
+ if token_id in eos_token_ids:
626
+ completion_tokens = index + 1
627
+ suffix_ids = suffix_ids[:index]
628
+ eos_hit = True
629
+ break
630
+ if not eos_hit:
631
+ pad_token_id = context.pad_token_id
632
+ while (
633
+ suffix_ids
634
+ and pad_token_id is not None
635
+ and suffix_ids[-1] == int(pad_token_id)
636
+ ):
637
+ suffix_ids.pop()
638
+ completion_tokens = len(suffix_ids)
639
+ decoded = self.tokenizer.decode(suffix_ids, skip_special_tokens=False)
640
+ text = decoded.split(_ASSISTANT_SUFFIX, 1)[0].strip()
641
+ if "</think>" in text:
642
+ text = text.split("</think>", 1)[1].strip()
643
+ text = text.replace("<ts>", "").replace("</ts>", "").strip()
644
+
645
+ horizon = context.horizon
646
+ generated_ts = getattr(output, "generated_ts_values", None)
647
+ normalized_values: list[float] = []
648
+ if generated_ts is not None:
649
+ if generated_ts == []:
650
+ generated_ts = [[]]
651
+ if not isinstance(generated_ts, list) or len(generated_ts) != 1:
652
+ raise ValueError(
653
+ "Generated time-series values are not request-aligned."
654
+ )
655
+ normalized_values = [float(value) for value in generated_ts[0]]
656
+ if horizon is None and normalized_values:
657
+ raise ValueError("A horizon-free completion emitted time-series values.")
658
+ if horizon is not None and len(normalized_values) != int(horizon):
659
+ raise ValueError(
660
+ "Generated time-series horizon mismatch: "
661
+ f"expected={horizon}, got={len(normalized_values)}."
662
+ )
663
+ if any(not math.isfinite(value) for value in normalized_values):
664
+ raise ValueError("Generated time-series values must be finite.")
665
+
666
+ rollout_records = getattr(output, "rollout_records", None)
667
+ record = None
668
+ if rollout_records is not None:
669
+ if rollout_records == []:
670
+ rollout_records = None
671
+ if rollout_records is not None:
672
+ if not isinstance(rollout_records, list) or len(rollout_records) != 1:
673
+ raise ValueError("Generation rollout records are not request-aligned.")
674
+ record = rollout_records[0]
675
+ if not isinstance(record, Mapping):
676
+ raise TypeError("Generation rollout record must be an object.")
677
+ timeseries_result = None
678
+ if normalized_values:
679
+ normalization = context.normalization
680
+ target_series_index = context.target_series_index
681
+ if type(
682
+ target_series_index
683
+ ) is not int or not 0 <= target_series_index < len(normalization):
684
+ raise ValueError(
685
+ "Numeric generation requires a valid target normalization record."
686
+ )
687
+ mean = float(normalization[target_series_index]["mean"])
688
+ std = float(normalization[target_series_index]["std"])
689
+ values = [value * std + mean for value in normalized_values]
690
+ if any(not math.isfinite(value) for value in values):
691
+ raise ValueError("Generated time-series denormalization overflowed.")
692
+ timeseries_result = {
693
+ "values": values,
694
+ "normalized_values": normalized_values,
695
+ "target_series_index": target_series_index,
696
+ }
697
+
698
+ decode_impl = "hf_generate"
699
+ finish_reason = "stop" if eos_hit else "length"
700
+ if record is not None:
701
+ decode_impl = str(record.get("decode_impl") or "")
702
+ scheduler_finish = str(record.get("finish_reason") or "")
703
+ if scheduler_finish == FinishReason.EOS_OR_PROTOCOL_STOP:
704
+ finish_reason = "stop"
705
+ elif scheduler_finish == FinishReason.TEXT_BUDGET:
706
+ finish_reason = "length"
707
+ else:
708
+ raise ValueError(
709
+ f"Unknown TimeBraid scheduler finish reason {scheduler_finish!r}."
710
+ )
711
+ return {
712
+ "content": text,
713
+ "timeseries": timeseries_result,
714
+ "target_series_index": context.target_series_index,
715
+ "normalization": context.normalization or None,
716
+ "finish_reason": finish_reason,
717
+ "prompt_tokens": int(context.prompt_tokens),
718
+ "completion_tokens": completion_tokens,
719
+ "decode_impl": decode_impl,
720
+ }
721
+
722
+
723
+ __all__ = [
724
+ "TimeBraidBatchFeature",
725
+ "TimeBraidProcessor",
726
+ ]