from __future__ import annotations
import random
import subprocess
from collections import OrderedDict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DistributedSampler, Sampler
TS_START_TOKEN = ""
TS_END_TOKEN = ""
SCALE_START_TOKEN = ""
SCALE_END_TOKEN = ""
PromptMode = str
STAGE1_PROMPT_VARIANTS_UNIVAR: tuple[str, ...] = (
"请描述这个时间序列:{ts_start} {ts_end}",
"请概括这个时间序列:{ts_start} {ts_end}",
"请总结这个时间序列:{ts_start} {ts_end}",
"请简要描述这个时间序列:{ts_start} {ts_end}",
"请简要概括这个时间序列:{ts_start} {ts_end}",
"请总结这个时间序列的主要特征:{ts_start} {ts_end}",
"请描述这个时间序列的主要表现:{ts_start} {ts_end}",
"请概括这个时间序列的整体情况:{ts_start} {ts_end}",
"请总结这个时间序列的整体特征:{ts_start} {ts_end}",
"请简述这个时间序列的主要模式:{ts_start} {ts_end}",
"请描述该时间序列:{ts_start} {ts_end}",
"请概括该时间序列:{ts_start} {ts_end}",
"请总结该时间序列:{ts_start} {ts_end}",
"请描述该时间序列的主要特征:{ts_start} {ts_end}",
"请概括该时间序列的整体特征:{ts_start} {ts_end}",
"请总结该时间序列的主要表现:{ts_start} {ts_end}",
"请对这个时间序列做简要描述:{ts_start} {ts_end}",
"请对这个时间序列做简要概括:{ts_start} {ts_end}",
"请对这个时间序列做简要总结:{ts_start} {ts_end}",
"请简要总结这个时间序列的主要情况:{ts_start} {ts_end}",
)
STAGE1_PROMPT_VARIANTS_BIVAR: tuple[str, ...] = (
"请描述这个双变量时间序列:{ts_start} {ts_end}",
"请概括这个双变量时间序列:{ts_start} {ts_end}",
"请总结这个双变量时间序列:{ts_start} {ts_end}",
"请简要描述这个双变量时间序列的整体表现:{ts_start} {ts_end}",
"请简要概括这个双变量时间序列:{ts_start} {ts_end}",
"请概括这个双变量时间序列的主要关系特征:{ts_start} {ts_end}",
"请总结这个双变量时间序列的整体变化与相互关系:{ts_start} {ts_end}",
"请总结这个双变量时间序列的主要特征:{ts_start} {ts_end}",
"请描述这个双变量时间序列的主要表现:{ts_start} {ts_end}",
"请概括这个双变量时间序列的整体情况:{ts_start} {ts_end}",
"请总结这个双变量时间序列的整体特征:{ts_start} {ts_end}",
"请简述这个双变量时间序列的主要关系模式:{ts_start} {ts_end}",
"请描述该双变量时间序列:{ts_start} {ts_end}",
"请概括该双变量时间序列:{ts_start} {ts_end}",
"请描述该双变量时间序列的主要表现:{ts_start} {ts_end}",
"请概括该双变量序列的整体特征:{ts_start} {ts_end}",
"请总结该双变量序列的主要模式:{ts_start} {ts_end}",
"请对这对时间序列做简要描述:{ts_start} {ts_end}",
"请对这对时间序列做简要概括:{ts_start} {ts_end}",
"请简要总结这对时间序列的主要情况:{ts_start} {ts_end}",
)
STAGE1_PROMPT_VARIANTS_MULTIVAR: tuple[str, ...] = (
"请描述这个多变量时间序列:{ts_start} {ts_end}",
"请概括这个多变量时间序列:{ts_start} {ts_end}",
"请总结这个多变量时间序列:{ts_start} {ts_end}",
"请简要描述这个多变量系统的整体表现:{ts_start} {ts_end}",
"请简要概括这个多变量时间序列:{ts_start} {ts_end}",
"请概括该多变量系统的主要结构特征:{ts_start} {ts_end}",
"请总结该多变量序列的整体变化模式:{ts_start} {ts_end}",
"请总结这个多变量时间序列的主要特征:{ts_start} {ts_end}",
"请描述这个多变量系统的主要表现:{ts_start} {ts_end}",
"请概括这个多变量系统的整体情况:{ts_start} {ts_end}",
"请总结这个多变量系统的整体特征:{ts_start} {ts_end}",
"请简述这个多变量系统的主要模式:{ts_start} {ts_end}",
"请描述该多变量时间序列:{ts_start} {ts_end}",
"请概括该多变量时间序列:{ts_start} {ts_end}",
"请描述该多变量时间序列的主要表现:{ts_start} {ts_end}",
"请概括该多变量时间序列的整体特征:{ts_start} {ts_end}",
"请总结该多变量系统的主要模式:{ts_start} {ts_end}",
"请对这个多变量时间序列做简要描述:{ts_start} {ts_end}",
"请对这个多变量时间序列做简要概括:{ts_start} {ts_end}",
"请简要总结这个多变量系统的主要情况:{ts_start} {ts_end}",
)
STAGE1_PROMPT_VARIANTS_BY_MODE: dict[PromptMode, tuple[str, ...]] = {
"univar": STAGE1_PROMPT_VARIANTS_UNIVAR,
"bivar": STAGE1_PROMPT_VARIANTS_BIVAR,
"multivar": STAGE1_PROMPT_VARIANTS_MULTIVAR,
}
STAGE2_SYSTEM_PROMPT = "你是专业的时间序列分析助手,请仅根据给定时间序列完成分析。"
STAGE2_PROMPT_FAMILIES_UNIVAR: dict[str, tuple[str, ...]] = {
"overall": (
"请对这个时间序列做深入分析:{ts_start} {ts_end}",
"请结合整体形态,对该时间序列做较完整的分析:{ts_start} {ts_end}",
"请围绕整体表现和变化脉络,对该序列进行深入分析:{ts_start} {ts_end}",
),
"pattern": (
"请分析该时间序列的主要模式及其相互关系:{ts_start} {ts_end}",
"请围绕趋势、周期性和局部波动,对该序列进行综合分析:{ts_start} {ts_end}",
"请从主要模式及其关联的角度,对该时间序列进行分析:{ts_start} {ts_end}",
),
"stability": (
"请分析这个时间序列的稳定性与可预测性:{ts_start} {ts_end}",
"请判断该序列的变化是否稳定,并说明其可预测性的来源:{ts_start} {ts_end}",
"请从稳定性和可预测性的角度,对该时间序列进行分析:{ts_start} {ts_end}",
),
"risk": (
"请分析该时间序列是否存在结构变化风险,并说明依据:{ts_start} {ts_end}",
"请从长期趋势、波动变化和潜在结构切换的角度分析该序列:{ts_start} {ts_end}",
"请评估该时间序列的结构风险,并结合整体变化给出分析:{ts_start} {ts_end}",
),
}
STAGE2_PROMPT_FAMILIES_BIVAR: dict[str, tuple[str, ...]] = {
"overall": (
"请对这个双变量时间序列做深入分析,重点概括整体变化与两条序列的关系:{ts_start} {ts_end}",
"请结合整体形态与变量间联系,对该双变量时间序列做较完整的分析:{ts_start} {ts_end}",
"请围绕整体表现、协同变化和差异关系,对这对时间序列进行深入分析:{ts_start} {ts_end}",
),
"pattern": (
"请分析该双变量时间序列的主要关系模式及其相互作用:{ts_start} {ts_end}",
"请围绕趋势一致性、节律同步和局部波动联动,对这对序列进行综合分析:{ts_start} {ts_end}",
"请从关系结构与模式特征的角度,对该双变量时间序列进行分析:{ts_start} {ts_end}",
),
"stability": (
"请分析这对时间序列关系结构的稳定性与可预测性:{ts_start} {ts_end}",
"请判断该双变量序列的协同变化是否稳定,并说明其可预测性的来源:{ts_start} {ts_end}",
"请从关系稳定性和联合可预测性的角度,对这对时间序列进行分析:{ts_start} {ts_end}",
),
"risk": (
"请分析该双变量时间序列是否存在关系结构变化风险,并说明依据:{ts_start} {ts_end}",
"请从长期趋势、波动联动和潜在结构切换的角度分析这对序列:{ts_start} {ts_end}",
"请评估这对时间序列的耦合风险,并结合整体变化给出分析:{ts_start} {ts_end}",
),
}
STAGE2_PROMPT_FAMILIES_MULTIVAR: dict[str, tuple[str, ...]] = {
"overall": (
"请对这个多变量时间序列系统做深入分析,重点概括整体结构与动态模式:{ts_start} {ts_end}",
"请结合系统整体形态与变量间协同关系,对该多变量时间序列做较完整的分析:{ts_start} {ts_end}",
"请围绕整体表现、系统结构和变量间互动,对该多变量序列进行深入分析:{ts_start} {ts_end}",
),
"pattern": (
"请分析该多变量时间序列系统的主要模式特征及其相互关系:{ts_start} {ts_end}",
"请围绕因子结构、同步协同、领先-滞后与局部波动,对该多变量系统进行综合分析:{ts_start} {ts_end}",
"请从系统模式与变量间关联的角度,对该多变量时间序列进行分析:{ts_start} {ts_end}",
),
"stability": (
"请分析这个多变量时间序列系统的结构稳定性与可预测性:{ts_start} {ts_end}",
"请判断该多变量系统的协同结构是否稳定,并说明其可预测性的来源:{ts_start} {ts_end}",
"请从系统稳定性和联合可预测性的角度,对该多变量时间序列进行分析:{ts_start} {ts_end}",
),
"risk": (
"请分析该多变量时间序列系统是否存在结构变化风险,并说明依据:{ts_start} {ts_end}",
"请从长期趋势、相关结构变化、波动联动和潜在状态切换的角度分析该多变量系统:{ts_start} {ts_end}",
"请评估该多变量时间序列的结构脆弱性与异常风险,并结合整体变化给出分析:{ts_start} {ts_end}",
),
}
STAGE2_PROMPT_FAMILIES_BY_MODE: dict[PromptMode, dict[str, tuple[str, ...]]] = {
"univar": STAGE2_PROMPT_FAMILIES_UNIVAR,
"bivar": STAGE2_PROMPT_FAMILIES_BIVAR,
"multivar": STAGE2_PROMPT_FAMILIES_MULTIVAR,
}
# Backward-compatible aliases: in the multivar package, the default exported prompt
# set should reflect the multivariate training path.
STAGE1_PROMPT_VARIANTS = STAGE1_PROMPT_VARIANTS_MULTIVAR
STAGE2_PROMPT_FAMILIES = STAGE2_PROMPT_FAMILIES_MULTIVAR
DEFAULT_STAGE2_PROMPT_FAMILY_WEIGHTS: dict[str, float] = {
"overall": 0.4,
"pattern": 0.25,
"stability": 0.2,
"risk": 0.15,
}
DEFAULT_STAGE2_LEVEL_WEIGHTS: dict[str, float] = {
# Phase 3 (2026-06-10): enable level_1/2 captions to give ScaleEncoder
# + LLM strong supervision for absolute (mu, sigma) decoding. Phase 2
# used only level_3/4 (analytical captions), starving the signal for
# simple-stats metrics (mean/std/min/max/median/...). See phase3_design
# §4.1.1. When the runner doesn't provide --level12-jsonl, train_stage1
# forces level_1/2 weights to 0 to preserve backward compatibility.
"level_1": 0.5,
"level_2": 0.3,
"level_3": 2.0,
"level_4": 1.0,
}
def register_ts_special_tokens(tokenizer, ts_token: str = "") -> int:
token_ids = register_alignment_special_tokens(
tokenizer,
ts_start_token=ts_token,
ts_end_token=TS_END_TOKEN,
scale_start_token=SCALE_START_TOKEN,
scale_end_token=SCALE_END_TOKEN,
)
return token_ids["ts_start"]
def register_alignment_special_tokens(
tokenizer,
*,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
scale_start_token: str = SCALE_START_TOKEN,
scale_end_token: str = SCALE_END_TOKEN,
) -> dict[str, int]:
tokens = [ts_start_token, ts_end_token, scale_start_token, scale_end_token]
vocab = tokenizer.get_vocab()
missing_tokens = [token for token in tokens if token not in vocab]
if missing_tokens:
tokenizer.add_special_tokens({"additional_special_tokens": missing_tokens})
return {
"ts_start": tokenizer.convert_tokens_to_ids(ts_start_token),
"ts_end": tokenizer.convert_tokens_to_ids(ts_end_token),
"scale_start": tokenizer.convert_tokens_to_ids(scale_start_token),
"scale_end": tokenizer.convert_tokens_to_ids(scale_end_token),
}
def collate_fn(
batch: list[dict[str, Any]],
tokenizer,
ts_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
ignore_index: int = -100,
max_length: int | None = None,
) -> dict[str, Any]:
if not batch:
raise ValueError("collate_fn requires a non-empty batch.")
special_token_ids = register_alignment_special_tokens(
tokenizer,
ts_start_token=ts_token,
ts_end_token=ts_end_token,
)
batch_type = _detect_batch_type(batch)
raw_ts_sequences: list[torch.Tensor] = []
channel_counts: list[int] = []
ts_lengths_list: list[int] = []
input_ids_list: list[torch.Tensor] = []
attention_masks: list[torch.Tensor] = []
labels_list: list[torch.Tensor] = []
text_valid_lengths: list[int] = []
ts_start_positions: list[int] = []
ts_end_positions: list[int] = []
for sample in batch:
raw_ts = _normalize_raw_ts(sample["raw_ts"])
raw_ts_sequences.append(raw_ts)
channel_counts.append(raw_ts.shape[0])
ts_lengths_list.append(raw_ts.shape[-1])
if batch_type == "text":
input_ids, attention_mask, labels = _build_text_training_example(
sample=sample,
tokenizer=tokenizer,
ignore_index=ignore_index,
max_length=max_length,
)
else:
input_ids, attention_mask, labels = _build_pretokenized_example(
sample=sample,
ignore_index=ignore_index,
max_length=max_length,
)
valid_length, start_pos, end_pos = _validate_single_placeholder(
input_ids=input_ids,
attention_mask=attention_mask,
ts_start_token_id=special_token_ids["ts_start"],
ts_end_token_id=special_token_ids["ts_end"],
)
text_valid_lengths.append(valid_length)
ts_start_positions.append(start_pos)
ts_end_positions.append(end_pos)
input_ids_list.append(input_ids)
attention_masks.append(attention_mask)
labels_list.append(labels)
batch_size = len(raw_ts_sequences)
max_channels = max(channel_counts)
max_ts_len = max(ts_lengths_list)
raw_ts = torch.zeros(
batch_size,
max_channels,
max_ts_len,
dtype=torch.float32,
)
raw_ts_channel_mask = torch.zeros(
batch_size,
max_channels,
dtype=torch.long,
)
raw_ts_attention_mask = torch.zeros(
batch_size,
max_ts_len,
dtype=torch.long,
)
for batch_index, sequence in enumerate(raw_ts_sequences):
n_channels, seq_len = sequence.shape
raw_ts[batch_index, :n_channels, :seq_len] = sequence
raw_ts_channel_mask[batch_index, :n_channels] = 1
raw_ts_attention_mask[batch_index, :seq_len] = 1
input_ids = pad_sequence(
input_ids_list,
batch_first=True,
padding_value=_get_pad_token_id(tokenizer),
)
attention_mask = pad_sequence(
attention_masks,
batch_first=True,
padding_value=0,
)
labels = pad_sequence(
labels_list,
batch_first=True,
padding_value=ignore_index,
)
return {
"raw_ts": raw_ts,
"raw_ts_channel_mask": raw_ts_channel_mask,
"raw_ts_attention_mask": raw_ts_attention_mask,
"text_valid_lengths": text_valid_lengths,
"ts_start_positions": ts_start_positions,
"ts_end_positions": ts_end_positions,
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels,
}
def get_stage1_prompt_variants(
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_variants: Sequence[str] | None = None,
prompt_mode: PromptMode = "multivar",
) -> list[str]:
variants = prompt_variants or STAGE1_PROMPT_VARIANTS_BY_MODE[prompt_mode]
formatted = [
_format_prompt_variant(
variant,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
)
for variant in variants
]
if not formatted:
raise ValueError("At least one stage1 prompt variant is required.")
return formatted
def sample_stage1_prompt(
*,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_variants: Sequence[str] | None = None,
prompt_mode: PromptMode = "multivar",
rng: random.Random | None = None,
) -> str:
variants = get_stage1_prompt_variants(
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_variants=prompt_variants,
prompt_mode=prompt_mode,
)
chooser = rng.choice if rng is not None else random.choice
return chooser(variants)
def build_stage1_training_samples(
record: dict[str, Any],
*,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_variants: Sequence[str] | None = None,
rng: random.Random | None = None,
) -> list[dict[str, Any]]:
_validate_stage1_record(record)
samples: list[dict[str, Any]] = []
for level_key in ("level_1", "level_2"):
samples.append(
_build_stage1_sample(
record=record,
level_key=level_key,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_variants=prompt_variants,
rng=rng,
)
)
return samples
class Stage1AlignmentDataset(torch.utils.data.Dataset):
def __init__(
self,
records: Sequence[dict[str, Any]],
*,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_variants: Sequence[str] | None = None,
seed: int = 0,
dynamic: bool = True,
) -> None:
self.records = list(records)
self.ts_start_token = ts_start_token
self.ts_end_token = ts_end_token
self.prompt_variants = prompt_variants
self.seed = seed
self.dynamic = dynamic
self.epoch = 0
for record in self.records:
_validate_stage1_record(record)
def __len__(self) -> int:
return len(self.records) * 2
def set_epoch(self, epoch: int) -> None:
self.epoch = epoch
def __getitem__(self, index: int) -> dict[str, Any]:
if index < 0:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError("Stage1AlignmentDataset index out of range.")
record_index, level_index = divmod(index, 2)
level_key = ("level_1", "level_2")[level_index]
sample_seed = self.seed + index
if self.dynamic:
sample_seed += self.epoch * max(len(self), 1)
rng = random.Random(sample_seed)
return _build_stage1_sample(
record=self.records[record_index],
level_key=level_key,
ts_start_token=self.ts_start_token,
ts_end_token=self.ts_end_token,
prompt_variants=self.prompt_variants,
rng=rng,
)
class Stage2AlignmentDataset(torch.utils.data.Dataset):
def __init__(
self,
records: Sequence[dict[str, Any]],
*,
system_prompt: str = STAGE2_SYSTEM_PROMPT,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_families: dict[str, Sequence[str]] | None = None,
prompt_family_weights: dict[str, float] | None = None,
level_weights: dict[str, float] | None = None,
seed: int = 0,
dynamic: bool = True,
) -> None:
self.records = list(records)
self.system_prompt = system_prompt
self.ts_start_token = ts_start_token
self.ts_end_token = ts_end_token
self.prompt_families = prompt_families
self.prompt_family_weights = prompt_family_weights
self.level_weights = level_weights
self.seed = seed
self.dynamic = dynamic
self.epoch = 0
for record in self.records:
_validate_stage2_record(record)
def __len__(self) -> int:
return len(self.records)
def set_epoch(self, epoch: int) -> None:
self.epoch = epoch
def __getitem__(self, index: int) -> dict[str, Any]:
if index < 0:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError("Stage2AlignmentDataset index out of range.")
sample_seed = self.seed + index
if self.dynamic:
sample_seed += self.epoch * max(len(self.records), 1)
rng = random.Random(sample_seed)
return build_stage2_training_sample(
self.records[index],
system_prompt=self.system_prompt,
ts_start_token=self.ts_start_token,
ts_end_token=self.ts_end_token,
prompt_families=self.prompt_families,
prompt_family_weights=self.prompt_family_weights,
level_weights=self.level_weights,
rng=rng,
)
class QAWarmupDataset(torch.utils.data.Dataset):
"""SFT warm-up dataset that consumes pre-built (prompt, answer) QA pairs.
Records are produced by build_qa_warmup_jsonl.py: each one already carries
a fully-rendered Chinese prompt (with ` ` placeholders) and the
deterministic reference answer (`metric_anchor = value;` joined by `;`).
"""
def __init__(
self,
records: Sequence[dict[str, Any]],
*,
system_prompt: str | None = None,
seed: int = 0,
dynamic: bool = False,
) -> None:
self.records = list(records)
self.system_prompt = system_prompt
self.seed = seed
self.dynamic = dynamic
self.epoch = 0
for record in self.records:
_validate_qa_warmup_record(record)
def __len__(self) -> int:
return len(self.records)
def set_epoch(self, epoch: int) -> None:
self.epoch = epoch
def __getitem__(self, index: int) -> dict[str, Any]:
if index < 0:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError("QAWarmupDataset index out of range.")
record = self.records[index]
sample: dict[str, Any] = {
"raw_ts": record["raw_ts"],
"prompt": record["prompt"],
"target_text": record["answer"],
"target_level": "qa_warmup",
"source_id": record.get("id"),
}
# Prefer the record's own system_prompt (mixed pool: TSQA rows carry
# TSQA_SYSTEM_PROMPT); fall back to the dataset-level default for rows
# without one (our metric_qa) — identical to the previous behavior.
sp = record.get("system_prompt", self.system_prompt)
if sp is not None:
sample["system_prompt"] = sp
if "metric_keys" in record:
sample["metric_keys"] = record["metric_keys"]
return sample
def load_qa_warmup_records_from_jsonl(
*,
raw_values_path: str | Path,
qa_pairs_path: str | Path,
limit: int | None = None,
) -> list[dict[str, Any]]:
"""Join raw_values + qa_pairs by id. One record per QA pair (multiple per id)."""
raw_records = _read_jsonl_file(raw_values_path)
raw_by_id: dict[int, dict[str, Any]] = {}
for record in raw_records:
if "id" not in record:
continue
if not _has_valid_ts_values(record.get("values")):
continue
raw_by_id[int(record["id"])] = record
qa_records = _read_jsonl_file(qa_pairs_path, limit=limit)
records: list[dict[str, Any]] = []
for qa in qa_records:
record_id = qa.get("id")
if record_id is None:
continue
raw = raw_by_id.get(int(record_id))
if raw is None:
continue
prompt = qa.get("prompt")
answer = qa.get("answer")
if not isinstance(prompt, str) or not isinstance(answer, str):
continue
if not prompt.strip() or not answer.strip():
continue
rec = {
"id": int(record_id),
"qa_index": qa.get("qa_index"),
"raw_ts": raw["values"],
"prompt": prompt,
"answer": answer,
"metric_keys": qa.get("metric_keys", []),
}
# Carry per-row system_prompt / task_type through so a mixed warmup pool
# (metric_qa + caption + TSQA) keeps each task's own system prompt. Rows
# without these fields (our metric_qa sft_pairs) fall back to the
# dataset-level default in QAWarmupDataset — behavior unchanged.
if qa.get("system_prompt") is not None:
rec["system_prompt"] = qa["system_prompt"]
if qa.get("task_type") is not None:
rec["task_type"] = qa["task_type"]
records.append(rec)
return records
def _validate_qa_warmup_record(record: dict[str, Any]) -> None:
missing = [key for key in ("raw_ts", "prompt", "answer") if key not in record]
if missing:
raise ValueError(
f"QAWarmup record is missing required fields: {', '.join(missing)}."
)
if not _has_valid_ts_values(record["raw_ts"]):
raise ValueError("QAWarmup record raw_ts must be a non-empty sequence without null values.")
prompt = record["prompt"]
if not isinstance(prompt, str) or TS_START_TOKEN not in prompt or TS_END_TOKEN not in prompt:
raise ValueError(
f"QAWarmup prompt must contain both {TS_START_TOKEN} and {TS_END_TOKEN}."
)
def build_univariate_stage1_records(
sample_records: Sequence[dict[str, Any]],
level_records: Sequence[dict[str, Any]],
) -> list[dict[str, Any]]:
samples_by_id = {
int(record["id"]): record
for record in sample_records
}
levels_by_id = OrderedDict(
(int(record["id"]), record)
for record in level_records
)
records: list[dict[str, Any]] = []
for record_id, level_record in levels_by_id.items():
if record_id not in samples_by_id:
raise ValueError(f"Missing sample values for record id {record_id}.")
sample_record = samples_by_id[record_id]
if not _has_valid_ts_values(sample_record.get("values")):
continue
records.append(
{
"id": record_id,
"dataset": sample_record.get("dataset"),
"channel": sample_record.get("channel"),
"raw_ts": sample_record["values"],
"level_1": (
level_record.get("level_1_revised")
or level_record.get("original_level_1")
or level_record.get("level_1")
),
"level_2": (
level_record.get("level_2_revised")
or level_record.get("original_level_2")
or level_record.get("level_2")
),
}
)
return records
def build_univariate_stage2_records(
sample_records: Sequence[dict[str, Any]],
level3_records: Sequence[dict[str, Any]],
level4_records: Sequence[dict[str, Any]],
level12_records: Sequence[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
samples_by_id = {
int(record["id"]): record
for record in sample_records
}
level3_by_id = OrderedDict(
(int(record["id"]), record)
for record in level3_records
)
level4_by_id = {
int(record["id"]): record
for record in level4_records
}
# Phase 3: optional level_1/level_2 supply. Records only get level_1/level_2
# populated when level12_records is provided; otherwise the sampler in
# train_stage1 forces their weights to 0 so the missing field is never read.
level12_by_id: dict[int, dict[str, Any]] = {}
if level12_records is not None:
level12_by_id = {
int(record["id"]): record
for record in level12_records
}
records: list[dict[str, Any]] = []
for record_id, level3_record in level3_by_id.items():
if record_id not in samples_by_id:
raise ValueError(f"Missing sample values for record id {record_id}.")
if record_id not in level4_by_id:
raise ValueError(f"Missing level_4 text for record id {record_id}.")
sample_record = samples_by_id[record_id]
if not _has_valid_ts_values(sample_record.get("values")):
continue
level4_record = level4_by_id[record_id]
record: dict[str, Any] = {
"id": record_id,
"dataset": sample_record.get("dataset"),
"channel": sample_record.get("channel"),
"raw_ts": sample_record["values"],
"level_3": level3_record["level_3"],
"level_4": level4_record["level_4"],
"level_3_prompt": level3_record.get("prompt"),
"level_4_prompt": level4_record.get("prompt"),
}
level12_record = level12_by_id.get(record_id)
if level12_record is not None:
lvl1 = (
level12_record.get("level_1_revised")
or level12_record.get("original_level_1")
or level12_record.get("level_1")
)
lvl2 = (
level12_record.get("level_2_revised")
or level12_record.get("original_level_2")
or level12_record.get("level_2")
)
if lvl1 is not None:
record["level_1"] = lvl1
record["level_1_prompt"] = level12_record.get("level_1_prompt") or level12_record.get("prompt")
if lvl2 is not None:
record["level_2"] = lvl2
record["level_2_prompt"] = level12_record.get("level_2_prompt") or level12_record.get("prompt")
records.append(record)
return records
def load_stage1_records_from_jsonl(
*,
samples_path: str | Path,
level12_path: str | Path,
limit: int | None = None,
) -> list[dict[str, Any]]:
sample_records = _read_jsonl_file(samples_path, limit=limit)
level_records = _read_jsonl_file(level12_path, limit=limit)
return build_univariate_stage1_records(sample_records, level_records)
def load_stage1_records_from_univar_tar(
archive_path: str | Path,
*,
limit: int | None = None,
) -> list[dict[str, Any]]:
sample_records = _read_jsonl_from_tar_zst(
archive_path,
member_path="univar/samples.jsonl",
limit=limit,
)
level_records = _read_jsonl_from_tar_zst(
archive_path,
member_path="univar/level12.jsonl",
limit=limit,
)
return build_univariate_stage1_records(sample_records, level_records)
def load_stage2_records_from_jsonl(
*,
samples_path: str | Path,
level3_path: str | Path,
level4_path: str | Path,
level12_path: str | Path | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
sample_records = _read_jsonl_file(samples_path, limit=limit)
level3_records = _read_jsonl_file(level3_path, limit=limit)
level4_records = _read_jsonl_file(level4_path, limit=limit)
level12_records = (
_read_jsonl_file(level12_path, limit=limit) if level12_path else None
)
return build_univariate_stage2_records(
sample_records,
level3_records,
level4_records,
level12_records=level12_records,
)
def load_stage2_records_from_univar_tar(
samples_archive_path: str | Path,
level34_archive_path: str | Path,
*,
limit: int | None = None,
) -> list[dict[str, Any]]:
sample_records = _read_jsonl_from_tar_zst(
samples_archive_path,
member_path="univar/samples.jsonl",
limit=limit,
)
level3_records = _read_jsonl_from_tar_zst(
level34_archive_path,
member_path="univar_level_34/level3.jsonl",
limit=limit,
)
level4_records = _read_jsonl_from_tar_zst(
level34_archive_path,
member_path="univar_level_34/level4.jsonl",
limit=limit,
)
return build_univariate_stage2_records(sample_records, level3_records, level4_records)
def _detect_batch_type(batch: list[dict[str, Any]]) -> str:
has_text = [("prompt" in sample and "target_text" in sample) for sample in batch]
has_tokens = [("input_ids" in sample and "labels" in sample) for sample in batch]
if all(has_text) and not any(has_tokens):
return "text"
if all(has_tokens) and not any(has_text):
return "tokenized"
raise ValueError(
"Batch must contain either only prompt/target_text samples or only pretokenized samples."
)
def _build_stage1_sample(
*,
record: dict[str, Any],
level_key: str,
ts_start_token: str,
ts_end_token: str,
prompt_variants: Sequence[str] | None,
rng: random.Random | None,
) -> dict[str, Any]:
prompt_mode = _infer_prompt_mode(record["raw_ts"])
return {
"raw_ts": record["raw_ts"],
"prompt": sample_stage1_prompt(
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_variants=prompt_variants,
prompt_mode=prompt_mode,
rng=rng,
),
"target_text": record[level_key],
"target_level": level_key,
"source_id": record.get("id"),
}
def get_stage2_prompt_families(
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_families: dict[str, Sequence[str]] | None = None,
prompt_mode: PromptMode = "multivar",
) -> dict[str, list[str]]:
families = prompt_families or STAGE2_PROMPT_FAMILIES_BY_MODE[prompt_mode]
formatted = {
family: [
_format_prompt_variant(
variant,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
)
for variant in variants
]
for family, variants in families.items()
}
if not formatted:
raise ValueError("At least one stage2 prompt family is required.")
for family, variants in formatted.items():
if not variants:
raise ValueError(f"Stage2 prompt family '{family}' must contain at least one prompt.")
return formatted
def sample_stage2_prompt(
*,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_families: dict[str, Sequence[str]] | None = None,
prompt_family_weights: dict[str, float] | None = None,
prompt_mode: PromptMode = "multivar",
rng: random.Random | None = None,
) -> tuple[str, str]:
formatted_families = get_stage2_prompt_families(
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_families=prompt_families,
prompt_mode=prompt_mode,
)
family_weights = _resolve_weight_mapping(
prompt_family_weights,
defaults=DEFAULT_STAGE2_PROMPT_FAMILY_WEIGHTS,
allowed_keys=formatted_families.keys(),
mapping_name="stage2 prompt family weights",
)
chooser = rng or random
family = _weighted_choice(family_weights, chooser)
return family, chooser.choice(formatted_families[family])
def sample_stage2_target_level(
*,
level_weights: dict[str, float] | None = None,
rng: random.Random | None = None,
) -> str:
chooser = rng or random
weights = _resolve_weight_mapping(
level_weights,
defaults=DEFAULT_STAGE2_LEVEL_WEIGHTS,
allowed_keys=DEFAULT_STAGE2_LEVEL_WEIGHTS.keys(),
mapping_name="stage2 target weights",
)
return _weighted_choice(weights, chooser)
def build_stage2_training_sample(
record: dict[str, Any],
*,
system_prompt: str = STAGE2_SYSTEM_PROMPT,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_families: dict[str, Sequence[str]] | None = None,
prompt_family_weights: dict[str, float] | None = None,
level_weights: dict[str, float] | None = None,
rng: random.Random | None = None,
) -> dict[str, Any]:
_validate_stage2_record(record)
prompt_mode = _infer_prompt_mode(record["raw_ts"])
target_level = sample_stage2_target_level(level_weights=level_weights, rng=rng)
if target_level in ("level_1", "level_2"):
# Phase 3: level_1/2 are short basic-stat captions. 8cd82fa enabled the
# data fields + weights but never wired a prompt path for them, so the
# aligned-family machinery (DIRECT_STAGE2_SOURCE_PROMPT_RULES /
# ALIGNED_STAGE2_PROMPT_VARIANTS) only covers level_3/4 → KeyError when
# level_1/2 is sampled. Reuse the Stage 1 generic describe prompts
# (STAGE1_PROMPT_VARIANTS_BY_MODE), which match the short-caption task.
source_prompt = None
family = "describe"
prompt = sample_stage1_prompt(
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_mode=prompt_mode,
rng=rng,
)
elif (source_prompt := _get_stage2_source_prompt(record, target_level)):
family, prompt = build_aligned_stage2_prompt(
source_prompt=source_prompt,
target_level=target_level,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_families=prompt_families,
prompt_mode=prompt_mode,
rng=rng,
)
else:
family, prompt = sample_stage2_prompt(
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
prompt_families=prompt_families,
prompt_family_weights=prompt_family_weights,
prompt_mode=prompt_mode,
rng=rng,
)
# Phase 3 (2026-06-10): when env TS_ALIGN_PROMPT_TASK_TYPE=1, prepend a
# "caption" prefix. Pairs with the QA-side prefix
# injected by build_single_metric_prompt so the model can disambiguate
# which reward branch it's being trained on. Env-gated for backward
# compat; Phase 2 SFT builds remain unchanged.
from .qa_templates import task_type_token_prefix
task_type_prefix = task_type_token_prefix("caption")
if task_type_prefix:
prompt = task_type_prefix + prompt
return {
"raw_ts": record["raw_ts"],
"system_prompt": system_prompt,
"prompt": prompt,
"target_text": record[target_level],
"target_level": target_level,
"prompt_family": family,
"source_prompt": source_prompt,
"source_id": record.get("id"),
}
def _validate_stage1_record(record: dict[str, Any]) -> None:
missing = [key for key in ("raw_ts", "level_1", "level_2") if key not in record]
if missing:
raise ValueError(
f"Stage1 record is missing required fields: {', '.join(missing)}."
)
if not _has_valid_ts_values(record["raw_ts"]):
raise ValueError("Stage1 record raw_ts must be a non-empty sequence without null values.")
def _validate_stage2_record(record: dict[str, Any]) -> None:
missing = [key for key in ("raw_ts", "level_3", "level_4") if key not in record]
if missing:
raise ValueError(
f"Stage2 record is missing required fields: {', '.join(missing)}."
)
if not _has_valid_ts_values(record["raw_ts"]):
raise ValueError("Stage2 record raw_ts must be a non-empty sequence without null values.")
def _has_valid_ts_values(values: Any) -> bool:
if values is None:
return False
try:
tensor = _normalize_raw_ts(values)
except (TypeError, ValueError):
return False
return tensor.numel() > 0 and torch.isfinite(tensor).all().item()
def _infer_prompt_mode(raw_ts: Any) -> PromptMode:
tensor = _normalize_raw_ts(raw_ts)
n_channels = int(tensor.shape[0])
if n_channels <= 1:
return "univar"
if n_channels == 2:
return "bivar"
return "multivar"
def _get_stage2_source_prompt(record: dict[str, Any], target_level: str) -> str | None:
prompt_key = f"{target_level}_prompt"
source_prompt = record.get(prompt_key)
if isinstance(source_prompt, str) and source_prompt.strip():
return source_prompt.strip()
return None
def build_aligned_stage2_prompt(
*,
source_prompt: str,
target_level: str,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
prompt_families: dict[str, Sequence[str]] | None = None,
prompt_mode: PromptMode = "multivar",
rng: random.Random | None = None,
) -> tuple[str, str]:
source_topic = infer_stage2_source_topic(
source_prompt=source_prompt,
target_level=target_level,
prompt_mode=prompt_mode,
)
family = SOURCE_TOPIC_TO_PROMPT_FAMILY[source_topic]
variants = get_aligned_stage2_prompt_variants(
prompt_mode=prompt_mode,
source_topic=source_topic,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
)
chooser = rng.choice if rng is not None else random.choice
return family, chooser(variants)
def infer_stage2_prompt_family_from_source_prompt(
*,
source_prompt: str,
target_level: str,
prompt_mode: PromptMode = "multivar",
) -> str:
source_topic = infer_stage2_source_topic(
source_prompt=source_prompt,
target_level=target_level,
prompt_mode=prompt_mode,
)
return SOURCE_TOPIC_TO_PROMPT_FAMILY[source_topic]
def infer_stage2_source_topic(
*,
source_prompt: str,
target_level: str,
prompt_mode: PromptMode = "multivar",
) -> str:
headline = _extract_source_prompt_headline(source_prompt)
rules = DIRECT_STAGE2_SOURCE_PROMPT_RULES_BY_MODE[prompt_mode][target_level]
for phrase, source_topic in rules:
if phrase in headline:
return source_topic
raise ValueError(
f"Unable to map source prompt headline to aligned topic for mode={prompt_mode}, "
f"target_level={target_level}, headline={headline!r}."
)
def get_aligned_stage2_prompt_variants(
*,
prompt_mode: PromptMode,
source_topic: str,
ts_start_token: str = TS_START_TOKEN,
ts_end_token: str = TS_END_TOKEN,
) -> list[str]:
prompt_variants = ALIGNED_STAGE2_PROMPT_VARIANTS_BY_MODE[prompt_mode][source_topic]
return [
_format_prompt_variant(
variant,
ts_start_token=ts_start_token,
ts_end_token=ts_end_token,
)
for variant in prompt_variants
]
def _contains_any(text: str, keywords: Sequence[str]) -> bool:
return any(keyword in text for keyword in keywords)
def _extract_source_prompt_headline(source_prompt: str) -> str:
for line in source_prompt.splitlines():
headline = line.strip()
if headline:
return headline
return source_prompt.strip()
SOURCE_TOPIC_TO_PROMPT_FAMILY: dict[str, str] = {
"pattern": "pattern",
"association": "pattern",
"summary": "overall",
"coupling_stability": "stability",
"frequency": "stability",
"risk": "risk",
"deep_summary": "overall",
}
DIRECT_STAGE2_SOURCE_PROMPT_RULES_UNIVAR: dict[str, tuple[tuple[str, str], ...]] = {
"level_3": (
("综合分析其主要模式", "pattern"),
("主要模式特征", "pattern"),
("模式总结", "summary"),
("关联关系", "association"),
("核心模式", "summary"),
),
"level_4": (
("稳定性、复杂度和结构风险", "coupling_stability"),
("稳定性和可预测性", "coupling_stability"),
("频率结构与复杂度", "frequency"),
("异常风险与结构脆弱性", "risk"),
("最具技术价值的深层特征", "deep_summary"),
),
}
DIRECT_STAGE2_SOURCE_PROMPT_RULES_BIVAR: dict[str, tuple[tuple[str, str], ...]] = {
"level_3": (
("综合分析两序列的关系模式", "pattern"),
("主要关系模式", "pattern"),
("关系总结", "summary"),
("内在关联", "association"),
("最突出的关系特征", "summary"),
),
"level_4": (
("综合分析两序列的深层耦合特性", "deep_summary"),
("耦合结构与时变稳定性", "coupling_stability"),
("因果结构与频域特征", "frequency"),
("结构脆弱性与极端联动风险", "risk"),
("最具技术价值的深层特征", "deep_summary"),
),
}
DIRECT_STAGE2_SOURCE_PROMPT_RULES_MULTIVAR: dict[str, tuple[tuple[str, str], ...]] = {
"level_3": (
("综合分析其结构和动态模式", "pattern"),
("主要模式特征", "pattern"),
("总结概括", "summary"),
("关联关系", "association"),
("核心特征", "summary"),
),
"level_4": (
("综合分析系统的动态耦合特性和结构稳定性", "deep_summary"),
("动态耦合结构与时变稳定性", "coupling_stability"),
("频率特征与季节性结构", "frequency"),
("结构脆弱性与异常特征", "risk"),
("最具技术价值的深层特征", "deep_summary"),
),
}
DIRECT_STAGE2_SOURCE_PROMPT_RULES_BY_MODE: dict[
PromptMode, dict[str, tuple[tuple[str, str], ...]]
] = {
"univar": DIRECT_STAGE2_SOURCE_PROMPT_RULES_UNIVAR,
"bivar": DIRECT_STAGE2_SOURCE_PROMPT_RULES_BIVAR,
"multivar": DIRECT_STAGE2_SOURCE_PROMPT_RULES_MULTIVAR,
}
ALIGNED_STAGE2_PROMPT_VARIANTS_UNIVAR: dict[str, tuple[str, ...]] = {
"pattern": (
"请综合分析这个时间序列的主要模式:{ts_start} {ts_end}",
"请识别这个时间序列的主要模式特征:{ts_start} {ts_end}",
"请分析这个时间序列的主要模式:{ts_start} {ts_end}",
),
"association": (
"请分析这个时间序列各特征之间的关联关系:{ts_start} {ts_end}",
"请分析这个时间序列的关联关系:{ts_start} {ts_end}",
),
"summary": (
"请对这个时间序列做模式总结:{ts_start} {ts_end}",
"请概括这个时间序列的核心模式:{ts_start} {ts_end}",
"请总结这个时间序列的模式:{ts_start} {ts_end}",
),
"coupling_stability": (
"请分析这个时间序列的稳定性、复杂度和结构风险:{ts_start} {ts_end}",
"请重点分析这个时间序列的稳定性和可预测性:{ts_start} {ts_end}",
"请分析这个时间序列的稳定性和可预测性:{ts_start} {ts_end}",
),
"frequency": (
"请深入分析这个时间序列的频率结构与复杂度:{ts_start} {ts_end}",
"请分析这个时间序列的频率结构与复杂度:{ts_start} {ts_end}",
),
"risk": (
"请评估这个时间序列的异常风险与结构脆弱性:{ts_start} {ts_end}",
"请评估这个时间序列的结构脆弱性与异常风险:{ts_start} {ts_end}",
),
"deep_summary": (
"请概括这个时间序列最具技术价值的深层特征:{ts_start} {ts_end}",
"请概括这个时间序列的深层特征:{ts_start} {ts_end}",
),
}
ALIGNED_STAGE2_PROMPT_VARIANTS_BIVAR: dict[str, tuple[str, ...]] = {
"pattern": (
"请综合分析这对时间序列的关系模式:{ts_start} {ts_end}",
"请识别这对时间序列的主要关系模式:{ts_start} {ts_end}",
"请分析这对时间序列的关系模式:{ts_start} {ts_end}",
),
"association": (
"请分析这对时间序列各维度特征之间的内在关联:{ts_start} {ts_end}",
"请分析这对时间序列的内在关联:{ts_start} {ts_end}",
),
"summary": (
"请对这对时间序列做关系总结:{ts_start} {ts_end}",
"请概括这对时间序列最突出的关系特征:{ts_start} {ts_end}",
"请总结这对时间序列的关系特征:{ts_start} {ts_end}",
),
"coupling_stability": (
"请分析该双变量时间序列的耦合结构与时变稳定性:{ts_start} {ts_end}",
"请重点分析这对时间序列的耦合结构与时变稳定性:{ts_start} {ts_end}",
"请分析这对时间序列的耦合结构与时变稳定性:{ts_start} {ts_end}",
),
"frequency": (
"请分析该双变量时间序列的因果结构与频域特征:{ts_start} {ts_end}",
"请深入分析该双变量时间序列的因果结构与频域特征:{ts_start} {ts_end}",
"请分析这对时间序列的因果结构与频域特征:{ts_start} {ts_end}",
),
"risk": (
"请评估该双变量时间序列的结构脆弱性与极端联动风险:{ts_start} {ts_end}",
"请评估这对时间序列的结构脆弱性与极端联动风险:{ts_start} {ts_end}",
),
"deep_summary": (
"请概括该双变量时间序列最具技术价值的深层特征:{ts_start} {ts_end}",
"请概括这对时间序列最具技术价值的深层特征:{ts_start} {ts_end}",
),
}
ALIGNED_STAGE2_PROMPT_VARIANTS_MULTIVAR: dict[str, tuple[str, ...]] = {
"pattern": (
"请综合分析该多变量时间序列的结构和动态模式:{ts_start} {ts_end}",
"请识别该多变量系统的主要模式特征:{ts_start} {ts_end}",
"请分析该多变量时间序列的结构和动态模式:{ts_start} {ts_end}",
),
"association": (
"请分析该多变量系统各特征之间的关联关系:{ts_start} {ts_end}",
"请分析该多变量时间序列的关联关系:{ts_start} {ts_end}",
),
"summary": (
"请对该多变量系统做总结概括:{ts_start} {ts_end}",
"请概括该多变量系统的核心特征:{ts_start} {ts_end}",
"请总结该多变量时间序列的特征:{ts_start} {ts_end}",
),
"coupling_stability": (
"请分析该多变量时间序列的动态耦合特性和结构稳定性:{ts_start} {ts_end}",
"请分析该多变量系统的动态耦合结构与时变稳定性:{ts_start} {ts_end}",
"请重点分析该多变量系统的动态耦合结构与时变稳定性:{ts_start} {ts_end}",
"请分析该多变量系统的动态耦合与结构稳定性:{ts_start} {ts_end}",
),
"frequency": (
"请分析该多变量时间序列的频率特征与季节性结构:{ts_start} {ts_end}",
"请深入分析该多变量时间序列的频率特征与季节性结构:{ts_start} {ts_end}",
"请分析该多变量系统的频率特征与季节性结构:{ts_start} {ts_end}",
),
"risk": (
"请评估该多变量时间序列的结构脆弱性与异常特征:{ts_start} {ts_end}",
"请评估该多变量系统的结构脆弱性与异常特征:{ts_start} {ts_end}",
),
"deep_summary": (
"请概括该多变量时间序列最具技术价值的深层特征:{ts_start} {ts_end}",
"请概括该多变量系统最具技术价值的深层特征:{ts_start} {ts_end}",
),
}
ALIGNED_STAGE2_PROMPT_VARIANTS_BY_MODE: dict[PromptMode, dict[str, tuple[str, ...]]] = {
"univar": ALIGNED_STAGE2_PROMPT_VARIANTS_UNIVAR,
"bivar": ALIGNED_STAGE2_PROMPT_VARIANTS_BIVAR,
"multivar": ALIGNED_STAGE2_PROMPT_VARIANTS_MULTIVAR,
}
def _normalize_raw_ts(raw_ts: Any) -> torch.Tensor:
tensor = torch.as_tensor(raw_ts, dtype=torch.float32)
if tensor.ndim == 1:
return tensor.unsqueeze(0)
if tensor.ndim == 2:
if tensor.shape[0] == 1:
return tensor
if tensor.shape[1] == 1:
return tensor.transpose(0, 1)
# For multivariate inputs, prefer [C, L]. If the first dimension is much
# larger, interpret the tensor as [L, C] and transpose into channel-first layout.
if tensor.shape[0] > tensor.shape[1]:
return tensor.transpose(0, 1)
return tensor
raise ValueError("raw_ts must have shape [L], [C, L], or [L, C].")
def _build_text_training_example(
*,
sample: dict[str, Any],
tokenizer,
ignore_index: int,
max_length: int | None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
prompt_messages: list[dict[str, str]] = []
system_prompt = sample.get("system_prompt")
if system_prompt is not None:
prompt_messages.append({"role": "system", "content": system_prompt})
prompt_messages.append({"role": "user", "content": sample["prompt"]})
full_messages = [
*prompt_messages,
{"role": "assistant", "content": sample["target_text"]},
]
prompt_ids = _apply_chat_template(
tokenizer,
prompt_messages,
add_generation_prompt=True,
)
input_ids = _apply_chat_template(
tokenizer,
full_messages,
add_generation_prompt=False,
)
if len(prompt_ids) >= len(input_ids):
raise ValueError("Chat template must leave assistant tokens after the user prompt prefix.")
attention_mask = [1] * len(input_ids)
labels = [ignore_index] * len(prompt_ids) + input_ids[len(prompt_ids) :]
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long)
attention_mask_tensor = torch.tensor(attention_mask, dtype=torch.long)
labels_tensor = torch.tensor(labels, dtype=torch.long)
return _truncate_text_fields(
input_ids=input_ids_tensor,
attention_mask=attention_mask_tensor,
labels=labels_tensor,
max_length=max_length,
)
def _build_pretokenized_example(
*,
sample: dict[str, Any],
ignore_index: int,
max_length: int | None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
input_ids = torch.as_tensor(sample["input_ids"], dtype=torch.long)
attention_mask = torch.as_tensor(
sample.get("attention_mask", torch.ones_like(input_ids)),
dtype=torch.long,
)
labels = torch.as_tensor(sample["labels"], dtype=torch.long)
if input_ids.ndim != 1 or attention_mask.ndim != 1 or labels.ndim != 1:
raise ValueError("Pretokenized input_ids, attention_mask, and labels must be 1D.")
return _truncate_text_fields(
input_ids=input_ids,
attention_mask=attention_mask,
labels=labels,
max_length=max_length,
)
def _truncate_text_fields(
*,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
labels: torch.Tensor,
max_length: int | None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if max_length is None or input_ids.numel() <= max_length:
return input_ids, attention_mask, labels
input_ids = input_ids[:max_length]
attention_mask = attention_mask[:max_length]
labels = labels[:max_length]
return input_ids, attention_mask, labels
def _apply_chat_template(
tokenizer,
messages: list[dict[str, str]],
*,
add_generation_prompt: bool,
) -> list[int]:
if not hasattr(tokenizer, "apply_chat_template"):
raise ValueError("Tokenizer must support apply_chat_template for stage1 text examples.")
input_ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=add_generation_prompt,
)
if isinstance(input_ids, torch.Tensor):
return input_ids.tolist()
return list(input_ids)
def _validate_single_placeholder(
*,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
ts_start_token_id: int,
ts_end_token_id: int,
) -> tuple[int, int, int]:
valid_length = int(attention_mask.sum().item())
valid_input_ids = input_ids[:valid_length]
start_positions = (valid_input_ids == ts_start_token_id).nonzero(as_tuple=False).flatten()
end_positions = (valid_input_ids == ts_end_token_id).nonzero(as_tuple=False).flatten()
if start_positions.numel() != 1 or end_positions.numel() != 1:
raise ValueError("Each sample must contain exactly one and one token.")
start_pos = int(start_positions.item())
end_pos = int(end_positions.item())
if start_pos >= end_pos:
raise ValueError(" must appear before in each sample.")
return valid_length, start_pos, end_pos
def _format_prompt_variant(
variant: str,
*,
ts_start_token: str,
ts_end_token: str,
) -> str:
formatted = variant.format(
ts_start=ts_start_token,
ts_end=ts_end_token,
)
if formatted.count(ts_start_token) != 1 or formatted.count(ts_end_token) != 1:
raise ValueError(
"Each stage1 prompt variant must contain exactly one and one placeholder."
)
return formatted
def _resolve_weight_mapping(
weights: dict[str, float] | None,
*,
defaults: dict[str, float],
allowed_keys,
mapping_name: str,
) -> dict[str, float]:
resolved = dict(defaults)
if weights is not None:
resolved.update(weights)
resolved = {key: float(value) for key, value in resolved.items() if key in allowed_keys}
if not resolved:
raise ValueError(f"{mapping_name} must contain at least one entry.")
if any(value < 0 for value in resolved.values()):
raise ValueError(f"{mapping_name} cannot contain negative weights.")
total = sum(resolved.values())
if total <= 0:
raise ValueError(f"{mapping_name} must sum to a positive value.")
return resolved
def _weighted_choice(weights: dict[str, float], rng: random.Random) -> str:
total = sum(weights.values())
threshold = rng.random() * total
cumulative = 0.0
last_key = next(iter(weights))
for key, value in weights.items():
cumulative += value
last_key = key
if threshold <= cumulative:
return key
return last_key
def _read_jsonl_file(path: str | Path, *, limit: int | None = None) -> list[dict[str, Any]]:
import json
records: list[dict[str, Any]] = []
with open(path, "r", encoding="utf-8") as handle:
for index, line in enumerate(handle):
if limit is not None and index >= limit:
break
records.append(json.loads(line))
return records
def _read_jsonl_from_tar_zst(
archive_path: str | Path,
*,
member_path: str,
limit: int | None = None,
) -> list[dict[str, Any]]:
import json
command = (
f"zstd -dc {Path(archive_path)} | tar -xOf - {member_path}"
)
process = subprocess.Popen(
["bash", "-lc", command],
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
)
records: list[dict[str, Any]] = []
assert process.stdout is not None
assert process.stderr is not None
try:
for index, line in enumerate(process.stdout):
if limit is not None and index >= limit:
process.terminate()
break
if not line.strip():
continue
records.append(json.loads(line))
finally:
process.stdout.close()
stderr = process.stderr.read()
process.wait()
if process.returncode not in (0, -15):
raise RuntimeError(f"Failed to extract {member_path} from {archive_path}: {stderr}")
return records
def _get_pad_token_id(tokenizer) -> int:
pad_token_id = getattr(tokenizer, "pad_token_id", None)
if pad_token_id is not None:
return int(pad_token_id)
eos_token_id = getattr(tokenizer, "eos_token_id", None)
if eos_token_id is not None:
return int(eos_token_id)
raise ValueError("Tokenizer must define pad_token_id or eos_token_id for collation.")
# ---------------------------------------------------------------------------
# Phase 3 Tier-3 §T0 (2026-06-10): resumable samplers with persistent
# (epoch, position) state, so --resume-from sees the same prompt sequence
# the pre-kill trajectory would have. Closes the only remaining post-resume
# divergence source after Tier-2 §5/§6 (trainer_state + RNG persistence) —
# without T0, even with model weights + optimizer + RNG perfectly restored
# the DataLoader would yield the FIRST batch of a fresh epoch instead of
# resuming mid-epoch, and the next ~50 micro-batches would consume entirely
# different prompts than the original run.
#
# Both classes save {epoch: int, position_in_epoch: int}; load_state_dict
# resumes from the saved position; set_epoch (called explicitly at epoch
# boundary by the train loop) resets the position. Single-rank and
# distributed variants share the same state-dict shape so train_stage3
# can save without caring which is in use.
# ---------------------------------------------------------------------------
class _ResumableSamplerMixin:
"""Shared (epoch, position) persistence for the two resumable samplers."""
epoch: int
_position_in_epoch: int
def state_dict(self) -> dict[str, int]:
return {
"epoch": int(self.epoch),
"position_in_epoch": int(self._position_in_epoch),
}
def load_state_dict(self, state: dict[str, int]) -> None:
# Use set_epoch so any subclass-specific bookkeeping fires (e.g.
# DistributedSampler reseeds its shuffle generator off epoch).
self.set_epoch(int(state.get("epoch", 0)))
self._position_in_epoch = int(state.get("position_in_epoch", 0))
class ResumableRandomSampler(_ResumableSamplerMixin, Sampler[int]):
"""Single-rank shuffle sampler with deterministic per-epoch ordering.
Replaces the implicit RandomSampler inside DataLoader(shuffle=True) for
Phase 3 GRPO single-GPU training. Seeds the shuffle off (seed, epoch)
so the index sequence is reproducible across runs, and remembers how
many indices were yielded so resume picks up mid-epoch instead of
restarting from index 0. Drops the implicit non-determinism that
DataLoader(shuffle=True) ships with by default.
"""
def __init__(self, data_source, *, seed: int = 42, epoch: int = 0):
self.data_source = data_source
self.seed = int(seed)
self.epoch = int(epoch)
self._position_in_epoch = 0
def set_epoch(self, epoch: int) -> None:
self.epoch = int(epoch)
self._position_in_epoch = 0
def __iter__(self):
generator = torch.Generator()
generator.manual_seed(self.seed + self.epoch)
indices = torch.randperm(len(self.data_source), generator=generator).tolist()
skip = self._position_in_epoch
for offset, idx in enumerate(indices[skip:], start=skip):
# Update BEFORE yield: a Python generator suspended at `yield`
# never resumes if the consumer breaks. If we updated after the
# yield, an early break would leave the saved position pointing
# at the just-yielded index (so resume would re-yield it). The
# consumer is committed to consuming the value the moment
# __next__ returns, so charging position += 1 first matches
# "elements yielded" exactly.
self._position_in_epoch = offset + 1
yield idx
# Iter exhausted — caller should advance epoch + call set_epoch.
def __len__(self):
return len(self.data_source)
class ResumableDistributedSampler(_ResumableSamplerMixin, DistributedSampler):
"""DistributedSampler with mid-epoch resume support.
Index ordering matches the parent DistributedSampler exactly — the
Phase 3 contribution is only that we remember how many of those indices
have already been yielded and skip ahead on the next __iter__. Used by
Phase 3 GRPO under FSDP / DDP where the existing code already wires a
DistributedSampler via train_stage3_grpo.py.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._position_in_epoch = 0
def set_epoch(self, epoch: int) -> None:
super().set_epoch(epoch)
self._position_in_epoch = 0
def __iter__(self):
# super().__iter__ yields a generator; materialise once so the
# skip-and-resume logic can index into the deterministic order.
base_indices = list(super().__iter__())
skip = self._position_in_epoch
for offset, idx in enumerate(base_indices[skip:], start=skip):
# See ResumableRandomSampler.__iter__: update position BEFORE
# yield to keep state consistent under early consumer-break.
self._position_in_epoch = offset + 1
yield idx