` を送信。",
+ "serve.state.running": "実行中", "serve.state.stopped": "停止中",
+ "serve.auth_on": "認証あり", "serve.auth_off": "認証なし(ループバックのみ)",
+ "serve.err.models_dir": "モデルディレクトリが存在しません", "serve.err.port": "ポートは 1–65535 の範囲です",
+ "serve.err.remote_needs_key": "ループバック以外では「リモートアクセスを許可」と API key が必要です",
+ "serve.err.busy": "サーバーは既に実行中です。先に停止してください",
+ },
+}
+for _lang, _entries in _TOOLS_TRANSLATIONS.items():
+ TRANSLATIONS[_lang].update(_entries)
+
+
+# ---- Config tab: adaptive training planner (ui/planner_panel.py) -------------------
+_PLANNER_TRANSLATIONS: Dict[str, Dict[str, str]] = {
+ "zh-Hans": {
+ "plan.title": "🧭 自动规划训练参数",
+ "plan.intro": "填写目标后生成 v3 架构和各阶段独立的训练参数。学习率与 batch 按 DeepSeek LLM 缩放律(arXiv 2401.02954)估算,内存按实测标定的模型估算;数据集路径用于推断 token 数、序列长度、记录数和分词器。所有数值都标注来源。",
+ "plan.target": "目标总参数量(M)", "plan.use_moe": "启用 MoE", "plan.experts": "专家数",
+ "plan.depth": "循环深度", "plan.active_mode": "激活规模的指定方式",
+ "plan.mode.experts": "激活专家数(top-k)", "plan.mode.params": "激活参数量(M)",
+ "plan.active_value": "激活值(专家数或 M 参数;0 = 自动)", "plan.memory": "内存节省幅度",
+ "plan.mem.none": "不节省(最快)", "plan.mem.balanced": "均衡(int8 KV,专家 RAM 75%)",
+ "plan.mem.aggressive": "激进(共享路由,激活重算)", "plan.mem.extreme": "极限(优化器分页,专家 RAM 25%)",
+ "plan.data": "数据集路径(每行一个;可为 shards 目录、jsonl、parquet)", "plan.ram": "可用内存 GB(0 = 自动检测)",
+ "plan.tokens": "预训练 token 预算(B;0 = 按数据集)", "plan.vocab": "词表大小(0 = 按分词器报告)",
+ "plan.seq_len": "序列长度(0 = 按数据集)", "plan.run": "⚙️ 生成规划", "plan.path": "规划文件路径",
+ "plan.save": "💾 保存规划", "plan.table": "各阶段训练参数",
+ "plan.table_help": "每行对应一个训练阶段,编辑后立即生效;空单元格表示沿用共享参数。pretrain 行与下方的共享训练参数同步。训练页按所选阶段合并对应行。",
+ "plan.report": "规划报告", "plan.applied": "✅ 已更新各阶段参数",
+ "plan.err.target": "❌ 目标参数量必须大于 0", "plan.err.active_experts": "❌ 激活专家数不能超过专家总数",
+ "plan.err.no_plan": "❌ 请先生成规划",
+ },
+ "en": {
+ "plan.title": "🧭 Auto-plan training parameters",
+ "plan.intro": "Describe the target and get a v3 architecture plus independent hyperparameters for every stage. LR and batch follow the DeepSeek LLM scaling law (arXiv 2401.02954); memory uses a model calibrated on measured runs. Dataset paths supply token counts, sequence length, record counts and the tokenizer. Every figure is labelled with its source.",
+ "plan.target": "Target total parameters (M)", "plan.use_moe": "Use MoE", "plan.experts": "Experts",
+ "plan.depth": "Recurrent depth", "plan.active_mode": "Specify active size by",
+ "plan.mode.experts": "Active experts (top-k)", "plan.mode.params": "Active parameters (M)",
+ "plan.active_value": "Active value (experts or M params; 0 = auto)", "plan.memory": "Memory saving",
+ "plan.mem.none": "None (fastest)", "plan.mem.balanced": "Balanced (int8 KV, 75% expert RAM)",
+ "plan.mem.aggressive": "Aggressive (shared routing, activation recompute)",
+ "plan.mem.extreme": "Extreme (optimizer paging, 25% expert RAM)",
+ "plan.data": "Dataset paths (one per line; shards dir, jsonl, parquet)", "plan.ram": "Available RAM GB (0 = detect)",
+ "plan.tokens": "Pretrain token budget (B; 0 = from dataset)", "plan.vocab": "Vocab size (0 = tokenizer report)",
+ "plan.seq_len": "Sequence length (0 = from dataset)", "plan.run": "⚙️ Generate plan", "plan.path": "Plan file",
+ "plan.save": "💾 Save plan", "plan.table": "Per-stage hyperparameters",
+ "plan.table_help": "One row per training stage; edits apply immediately and blank cells fall back to the shared values. The pretrain row mirrors the shared training widgets below. The Train tab merges the row of the selected stage.",
+ "plan.report": "Plan report", "plan.applied": "✅ Stage parameters updated",
+ "plan.err.target": "❌ Target parameters must be > 0", "plan.err.active_experts": "❌ Active experts cannot exceed the expert count",
+ "plan.err.no_plan": "❌ Generate a plan first",
+ },
+ "zh-Hant": {
+ "plan.title": "🧭 自動規劃訓練參數",
+ "plan.intro": "填寫目標後產生 v3 架構和各階段獨立的訓練參數。學習率與 batch 依 DeepSeek LLM 縮放律(arXiv 2401.02954)估算,記憶體依實測標定的模型估算;資料集路徑用於推斷 token 數、序列長度、記錄數和分詞器。所有數值都標註來源。",
+ "plan.target": "目標總參數量(M)", "plan.use_moe": "啟用 MoE", "plan.experts": "專家數",
+ "plan.depth": "循環深度", "plan.active_mode": "啟用規模的指定方式",
+ "plan.mode.experts": "啟用專家數(top-k)", "plan.mode.params": "啟用參數量(M)",
+ "plan.active_value": "啟用值(專家數或 M 參數;0 = 自動)", "plan.memory": "記憶體節省幅度",
+ "plan.mem.none": "不節省(最快)", "plan.mem.balanced": "均衡(int8 KV,專家 RAM 75%)",
+ "plan.mem.aggressive": "激進(共享路由,啟動重算)", "plan.mem.extreme": "極限(最佳化器分頁,專家 RAM 25%)",
+ "plan.data": "資料集路徑(每行一個;可為 shards 目錄、jsonl、parquet)", "plan.ram": "可用記憶體 GB(0 = 自動偵測)",
+ "plan.tokens": "預訓練 token 預算(B;0 = 依資料集)", "plan.vocab": "詞表大小(0 = 依分詞器報告)",
+ "plan.seq_len": "序列長度(0 = 依資料集)", "plan.run": "⚙️ 產生規劃", "plan.path": "規劃檔案路徑",
+ "plan.save": "💾 儲存規劃", "plan.table": "各階段訓練參數",
+ "plan.table_help": "每列對應一個訓練階段,編輯後立即生效;空儲存格表示沿用共享參數。pretrain 列與下方的共享訓練參數同步。訓練頁依所選階段合併對應列。",
+ "plan.report": "規劃報告", "plan.applied": "✅ 已更新各階段參數",
+ "plan.err.target": "❌ 目標參數量必須大於 0", "plan.err.active_experts": "❌ 啟用專家數不能超過專家總數",
+ "plan.err.no_plan": "❌ 請先產生規劃",
+ },
+ "ja": {
+ "plan.title": "🧭 学習パラメータの自動計画",
+ "plan.intro": "目標を入力すると、v3 アーキテクチャとステージごとに独立したハイパーパラメータを生成します。学習率と batch は DeepSeek LLM のスケーリング則(arXiv 2401.02954)、メモリは実測で較正したモデルで推定します。データセットのパスからトークン数・系列長・レコード数・トークナイザを推定します。すべての数値に出典を付けます。",
+ "plan.target": "目標総パラメータ数(M)", "plan.use_moe": "MoE を使う", "plan.experts": "エキスパート数",
+ "plan.depth": "再帰深さ", "plan.active_mode": "アクティブ規模の指定方法",
+ "plan.mode.experts": "アクティブエキスパート数(top-k)", "plan.mode.params": "アクティブパラメータ数(M)",
+ "plan.active_value": "アクティブ値(エキスパート数または M パラメータ;0 = 自動)", "plan.memory": "メモリ節約レベル",
+ "plan.mem.none": "なし(最速)", "plan.mem.balanced": "バランス(int8 KV、エキスパート RAM 75%)",
+ "plan.mem.aggressive": "積極(共有ルーティング、活性再計算)", "plan.mem.extreme": "最大(オプティマイザのページング、エキスパート RAM 25%)",
+ "plan.data": "データセットのパス(1 行 1 つ;shards ディレクトリ、jsonl、parquet)", "plan.ram": "使用可能 RAM GB(0 = 自動検出)",
+ "plan.tokens": "事前学習トークン予算(B;0 = データセットから)", "plan.vocab": "語彙サイズ(0 = トークナイザレポート)",
+ "plan.seq_len": "系列長(0 = データセットから)", "plan.run": "⚙️ 計画を生成", "plan.path": "計画ファイル",
+ "plan.save": "💾 計画を保存", "plan.table": "ステージ別ハイパーパラメータ",
+ "plan.table_help": "1 行が 1 ステージに対応し、編集はすぐに反映されます。空欄は共有値を使います。pretrain 行は下の共有学習パラメータと同期します。学習タブは選択したステージの行をマージします。",
+ "plan.report": "計画レポート", "plan.applied": "✅ ステージ別パラメータを更新しました",
+ "plan.err.target": "❌ 目標パラメータ数は 0 より大きくしてください", "plan.err.active_experts": "❌ アクティブエキスパート数はエキスパート総数以下にしてください",
+ "plan.err.no_plan": "❌ 先に計画を生成してください",
+ },
+}
+for _lang, _entries in _PLANNER_TRANSLATIONS.items():
+ TRANSLATIONS[_lang].update(_entries)
diff --git a/ui/planner_panel.py b/ui/planner_panel.py
new file mode 100644
index 0000000..785f91e
--- /dev/null
+++ b/ui/planner_panel.py
@@ -0,0 +1,177 @@
+"""ui/planner_panel.py — Config tab "auto plan" accordion.
+
+The user states a target (total parameters, MoE on/off, expert count, active
+parameters *or* active experts, memory-saving level, optional datasets) and
+:func:`chronos.model.planner.plan_training` returns a v3 architecture plus
+independent hyperparameters for every training stage. The stage table is
+editable; edits are written back to ``config_state["stage_overrides"]``,
+which the Train tab merges over the shared values for the selected stage.
+
+The handlers are plain functions so they can be tested without Gradio.
+"""
+from __future__ import annotations
+
+import json
+import os
+
+from chronos.model.planner import (
+ MEMORY_LEVELS, STAGE_KEYS, PlanRequest, overrides_from_table, plan_training, stage_table,
+)
+from ui.gradio_compat import gr
+from ui.i18n import register_translatable, t
+from ui.tabs.tokenizer_tab import parse_paths
+
+ACTIVE_MODES = ("experts", "params")
+STAGE_HEADERS = ["stage"] + list(STAGE_KEYS)
+
+
+def run_plan(target_m, use_moe, num_experts, active_mode, active_value, memory_saving, data_paths,
+ ram_gb, token_budget_b, depth, vocab_size, seq_len) -> dict:
+ """Build a PlanRequest from UI values (millions / billions) and run the planner."""
+ target = float(target_m or 0) * 1e6
+ if target <= 0:
+ raise ValueError("target")
+ use_moe = bool(use_moe)
+ value = float(active_value or 0)
+ active_params = value * 1e6 if use_moe and active_mode == "params" and value > 0 else None
+ active_experts = int(value) if use_moe and active_mode == "experts" and value >= 1 else None
+ if use_moe and active_experts and active_experts > int(num_experts or 1):
+ raise ValueError("active_experts")
+ request = PlanRequest(
+ target_total=target, use_moe=use_moe, num_experts=int(num_experts or 1),
+ active_params=active_params, active_experts=active_experts,
+ memory_saving=memory_saving if memory_saving in MEMORY_LEVELS else "none",
+ data_paths=tuple(parse_paths(data_paths or "")),
+ ram_gb=float(ram_gb) if ram_gb else None,
+ token_budget=float(token_budget_b) * 1e9 if token_budget_b else None,
+ recurrent_depth=int(depth or 4),
+ vocab_size=int(vocab_size) if vocab_size else None,
+ seq_len=int(seq_len) if seq_len else None,
+ )
+ return plan_training(request)
+
+
+def plan_summary(plan: dict) -> str:
+ """Short Markdown summary; every figure carries its label."""
+ arch, scal, mem = plan["report"]["architecture"], plan["report"]["scaling"], plan["report"]["memory"]
+ lines = [
+ f"**{arch['total_params'] / 1e6:.2f}M** total · **{arch['active_params'] / 1e6:.2f}M** active "
+ f"(union ≤ {arch['active_union_upper'] / 1e6:.2f}M) — {arch['params_source']}",
+ f"h={arch['hidden_size']} · L={arch['physical_layers']} (groups {arch['recurrent_groups']} × depth "
+ f"{arch['recurrent_depth']} → {arch['virtual_layers']} virtual) · heads {arch['heads']} · "
+ f"E={arch['experts']} top-{arch['top_k']} width {arch['expert_width']}",
+ f"LR {scal['chosen_lr']:g} · batch {scal['chosen_batch_tokens']:,} tokens · "
+ f"{scal['tokens']:.3g} tokens ({scal['tokens_source']}) — estimated, {scal['law']}",
+ f"micro {mem['micro_tokens']:,} tokens × accum {mem['accumulation_steps']} · training peak ≈ "
+ f"{mem['training_peak_gb']} GiB (estimated) of {mem['target_gb']} GiB target · KV {mem['kv_cache_dtype']} · "
+ f"router {mem['router_scope']}",
+ ]
+ lines += [f"- {note}" for note in plan["report"]["notes"]]
+ return "\n\n".join(lines)
+
+
+def apply_stage_rows(cfg: dict, rows) -> dict:
+ """Write an edited stage table back into the config (pretrain row also sets the shared values)."""
+ cfg = dict(cfg or {})
+ if hasattr(rows, "values") and hasattr(rows, "columns"): # pandas DataFrame
+ rows = rows.values.tolist()
+ overrides = overrides_from_table(rows)
+ cfg["stage_overrides"] = overrides
+ cfg.update(overrides.get("pretrain", {}))
+ return cfg
+
+
+def stage_rows(cfg: dict) -> list[list]:
+ return stage_table((cfg or {}).get("stage_overrides") or {})[1]
+
+
+def save_plan(plan: dict, path: str) -> str:
+ """Write the full plan (config, per-stage values, MLX long-run phases, report) as JSON."""
+ path = os.path.abspath(os.path.expanduser(path or "./out/chronos_plan.json"))
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(plan, fh, indent=2, ensure_ascii=False, default=str)
+ return path
+
+
+def build_planner_panel(config_state, config_result, config_outputs: list) -> None:
+ """Render the accordion inside the Config tab and wire it to ``config_state``.
+
+ ``config_result(cfg)`` must return the values for ``config_outputs`` (state,
+ JSON view, every editor widget and the estimator panel).
+ """
+ with gr.Accordion(t("plan.title"), open=False) as acc:
+ register_translatable(acc, "plan.title")
+ intro = gr.Markdown(t("plan.intro"))
+ with gr.Row():
+ target = gr.Number(label=t("plan.target"), value=200, precision=2, minimum=0.1)
+ use_moe = gr.Checkbox(label=t("plan.use_moe"), value=True)
+ experts = gr.Number(label=t("plan.experts"), value=32, precision=0, minimum=1)
+ depth = gr.Number(label=t("plan.depth"), value=4, precision=0, minimum=1, maximum=16)
+ with gr.Row():
+ active_mode = gr.Radio(choices=[(t("plan.mode.experts"), "experts"), (t("plan.mode.params"), "params")],
+ value="experts", label=t("plan.active_mode"))
+ active_value = gr.Number(label=t("plan.active_value"), value=2, precision=2, minimum=0)
+ memory = gr.Dropdown(choices=[(t(f"plan.mem.{lvl}"), lvl) for lvl in MEMORY_LEVELS],
+ value="balanced", label=t("plan.memory"))
+ with gr.Row():
+ data_paths = gr.Textbox(label=t("plan.data"), lines=2, scale=3,
+ placeholder="./dataset/shards/\n./dataset/sft_mini_512.jsonl")
+ with gr.Column(scale=2):
+ ram_gb = gr.Number(label=t("plan.ram"), value=0, precision=1, minimum=0)
+ token_budget = gr.Number(label=t("plan.tokens"), value=0, precision=3, minimum=0)
+ with gr.Column(scale=2):
+ vocab = gr.Number(label=t("plan.vocab"), value=0, precision=0, minimum=0)
+ seq_len = gr.Number(label=t("plan.seq_len"), value=0, precision=0, minimum=0)
+ with gr.Row():
+ plan_btn = gr.Button(t("plan.run"), variant="primary")
+ plan_path = gr.Textbox(value="./out/chronos_plan.json", label=t("plan.path"), scale=2)
+ save_btn = gr.Button(t("plan.save"), variant="secondary")
+ summary = gr.Markdown("")
+ table_md = gr.Markdown(t("plan.table_help"))
+ table = gr.Dataframe(headers=STAGE_HEADERS, value=[], type="array", interactive=True,
+ label=t("plan.table"), wrap=False, static_columns=[0])
+ report = gr.JSON(label=t("plan.report"), value={})
+ plan_state = gr.State({})
+ for comp, key in ((intro, "plan.intro"), (target, "plan.target"), (use_moe, "plan.use_moe"),
+ (experts, "plan.experts"), (depth, "plan.depth"), (active_mode, "plan.active_mode"),
+ (active_value, "plan.active_value"), (memory, "plan.memory"), (data_paths, "plan.data"),
+ (ram_gb, "plan.ram"), (token_budget, "plan.tokens"), (vocab, "plan.vocab"),
+ (seq_len, "plan.seq_len"), (plan_btn, "plan.run"), (plan_path, "plan.path"),
+ (save_btn, "plan.save"), (table_md, "plan.table_help"), (table, "plan.table"),
+ (report, "plan.report")):
+ register_translatable(comp, key)
+
+ def on_plan(*values):
+ try:
+ plan = run_plan(*values)
+ except (ValueError, TypeError, OSError) as exc:
+ key = f"plan.err.{exc}" if str(exc) in ("target", "active_experts") else None
+ message = t(key) if key else f"❌ {type(exc).__name__}: {exc}"
+ return [message, gr.update(), gr.update(), gr.update()] + [gr.update() for _ in config_outputs]
+ cfg = plan["config"]
+ return [plan_summary(plan), stage_rows(cfg), plan["report"], plan] + config_result(cfg)
+
+ def on_apply(cfg, rows):
+ try:
+ cfg = apply_stage_rows(cfg, rows)
+ except (ValueError, TypeError) as exc:
+ return [f"❌ {exc}"] + [gr.update() for _ in config_outputs]
+ return [t("plan.applied")] + config_result(cfg)
+
+ def on_save(plan, cfg, path):
+ if not plan:
+ return t("plan.err.no_plan")
+ plan = dict(plan, config=dict(cfg), stage_overrides=dict((cfg or {}).get("stage_overrides") or {}))
+ return f"✅ {save_plan(plan, path)}"
+
+ plan_inputs = [target, use_moe, experts, active_mode, active_value, memory, data_paths, ram_gb, token_budget,
+ depth, vocab, seq_len]
+ plan_btn.click(fn=on_plan, inputs=plan_inputs, outputs=[summary, table, report, plan_state] + config_outputs)
+ # Cell edits apply immediately, so the table never holds un-applied values
+ # that a later refresh could discard.
+ table.input(fn=on_apply, inputs=[config_state, table], outputs=[summary] + config_outputs)
+ save_btn.click(fn=on_save, inputs=[plan_state, config_state, plan_path], outputs=[summary])
+ # Presets, loaded configs and shared-widget edits (which keep the pretrain
+ # row in sync, see merge_config_inputs) refresh the table.
+ config_state.change(fn=stage_rows, inputs=[config_state], outputs=[table])
diff --git a/ui/presets.py b/ui/presets.py
index e1c3cc6..9ac41b8 100644
--- a/ui/presets.py
+++ b/ui/presets.py
@@ -23,138 +23,248 @@
# These values match MiniMindConfig(hidden_size=768, num_hidden_layers=8,
# use_moe=True) field-for-field, with Chronos-specific extensions left at
# their conservative defaults (no router-anchor by default).
-MINIMIND_MOE_DEFAULTS: Dict = {
- # MiniMind core
- "hidden_size": 768,
- "num_hidden_layers": 8,
- "num_attention_heads": 8,
- "num_key_value_heads": 4,
- "vocab_size": 6400,
- "intermediate_size": 0, # 0 = auto: ceil(H·π/64)·64 → 2432
- "moe_intermediate_size": 0, # 0 = auto, same value
- "max_seq_len": 512, # training sequence length (NOT max_position_embeddings)
- "tie_word_embeddings": True,
-
- # MoE
- "num_experts": 4,
- "num_experts_per_tok": 2, # M8c: top-2 doubles activated params at fixed total params
- "num_shared_experts": 1, # Chronos extension; minimind has no shared
-
- # Attention shape (Chronos hybrid)
- "kv_latent_dim": 64,
- "rope_dim": 32,
- "sliding_window_size": 2048,
- "use_hybrid_attention": True,
-
- # Chronos prediction
- "lookahead_steps": 2,
-
- # Loss coefficients
- "lambda_balance": 5e-4,
- "lambda_temporal": 1e-3,
- "lambda_lookahead": 0.1,
- "lambda_lookahead_topk": 0.05,
- "lambda_router_anchor": 0.0,
- "fallback_mask_prob": 0.0,
-
- # Hardware
- "vram_budget_gb": 4.0,
- "pinned_memory_max_fraction": 0.25,
- "storage_format": "safetensors",
-
- # Training
- "learning_rate": 5e-4,
- "batch_size": 16,
- "accumulation_steps": 8,
- "epochs": 2,
- "max_steps": 0, # 0 = no cap; use epochs * dataset length
- "save_interval": 1000,
- "weight_decay": 0.01,
- "grad_clip": 1.0,
- "log_interval": 10,
- "reward_spec": "toy",
- "max_gen_len": 24,
- "num_generations": 4,
- "temperature": 1.0,
- "beta": 0.1,
- "alpha": 0.7,
- "lambda_or": 0.1,
- "save_dir": "./out",
- "dtype": "auto",
- "cpu_threads": "auto",
- "cpu_budget_percent": 100,
- "num_workers": "auto",
-}
-
-
-# Order matches ui/tabs/config_tab.py::all_inputs (used by Apply-Preset
-# to push values back into the slider widgets in the right slots).
-CONFIG_INPUT_ORDER: List[str] = [
- "hidden_size", "num_hidden_layers", "num_experts", "num_experts_per_tok",
- "num_shared_experts", "lookahead_steps", "kv_latent_dim", "sliding_window_size",
- "num_attention_heads", "num_key_value_heads", "rope_dim", "moe_intermediate_size",
- "vocab_size", "dtype", "tie_word_embeddings",
- "lambda_balance", "lambda_temporal", "lambda_lookahead", "lambda_lookahead_topk", "lambda_router_anchor",
- "fallback_mask_prob",
- "vram_budget_gb", "pinned_memory_max_fraction", "use_hybrid_attention", "storage_format",
- "learning_rate", "batch_size", "accumulation_steps", "max_seq_len",
- "epochs", "max_steps", "save_interval", "log_interval", "save_dir",
- "weight_decay", "grad_clip",
- "cpu_threads", "cpu_budget_percent", "num_workers",
- "reward_spec", "max_gen_len", "num_generations", "temperature",
- "beta", "alpha", "lambda_or",
+MINIMIND_MOE_DEFAULTS: Dict = {'architecture_version': 2,
+ 'recurrent_enabled': True,
+ 'recurrent_groups': 2,
+ 'recurrent_depth': 4,
+ 'min_recurrent_depth': 2,
+ 'prelude_layers': 1,
+ 'coda_layers': 1,
+ 'adaptive_halting': True,
+ 'halting_threshold': 0.99,
+ 'halting_epsilon': 0.01,
+ 'max_inference_depth': 4,
+ 'ponder_loss_coef': 0.01,
+ 'recurrent_distill_coef': 0.05,
+ 'recurrent_stability_coef': 0.001,
+ 'recurrent_stability_threshold': 0.1,
+ 'recurrent_dt_init': 0.25,
+ 'recurrent_dt_min': 0.05,
+ 'recurrent_dt_max': 1.0,
+ 'recurrent_latent_refinement': True,
+ 'recurrent_strategy': 'scheduled',
+ 'recurrent_depth_jitter': 0.0,
+ 'recurrent_parallel_refine': False,
+ 'depth_schedule': 'fixed_then_adaptive',
+ 'depth_schedule_warmup_fraction': 0.25,
+ 'layer_aware_lookahead': True,
+ 'layer_aware_cache': True,
+ 'training_paging': False,
+ 'optimizer_paging': False,
+ 'optimizer_page_dir': './optimizer_pages',
+ 'page_size_mb': 64,
+ 'prefetch_depth': 2,
+ 'activation_checkpointing': False,
+ 'modalities': [],
+ 'auto_detect_modalities': True,
+ 'image_size': 224,
+ 'image_patch_size': 16,
+ 'max_image_tokens': 196,
+ 'audio_sample_rate': 16000,
+ 'max_audio_seconds': 30.0,
+ 'max_audio_tokens': 128,
+ 'modality_dropout': 0.05,
+ 'freeze_modality_encoders': False,
+ 'image_encoder_type': 'patch',
+ 'audio_encoder_type': 'conv',
+ 'modality_fusion_heads': 4,
+ 'media_error_policy': 'warn',
+ 'modality_adapters': {},
+ 'modality_alignment_coef': 0.0,
+ 'hidden_size': 768,
+ 'num_hidden_layers': 8,
+ 'num_attention_heads': 8,
+ 'num_key_value_heads': 4,
+ 'vocab_size': 6400,
+ 'intermediate_size': 0,
+ 'moe_intermediate_size': 0,
+ 'max_seq_len': 512,
+ 'tie_word_embeddings': True,
+ 'num_experts': 4,
+ 'num_experts_per_tok': 2,
+ 'num_shared_experts': 1,
+ 'kv_latent_dim': 64,
+ 'rope_dim': 32,
+ 'sliding_window_size': 2048,
+ 'use_hybrid_attention': True,
+ 'lookahead_steps': 2,
+ 'lambda_balance': 0.0005,
+ 'lambda_temporal': 0.001,
+ 'lambda_lookahead': 0.1,
+ 'lambda_lookahead_topk': 0.05,
+ 'lambda_router_anchor': 0.0,
+ 'fallback_mask_prob': 0.0,
+ 'vram_budget_gb': 4.0,
+ 'pinned_memory_max_fraction': 0.25,
+ 'storage_format': 'safetensors',
+ 'learning_rate': 0.0005,
+ 'batch_size': 16,
+ 'accumulation_steps': 8,
+ 'epochs': 2,
+ 'max_steps': 0,
+ 'save_interval': 1000,
+ 'weight_decay': 0.01,
+ 'grad_clip': 1.0,
+ 'log_interval': 10,
+ 'reward_spec': 'toy',
+ 'max_gen_len': 24,
+ 'num_generations': 4,
+ 'temperature': 1.0,
+ 'beta': 0.1,
+ 'alpha': 0.7,
+ 'lambda_or': 0.1,
+ 'save_dir': './out',
+ 'dtype': 'auto',
+ 'cpu_threads': 'auto',
+ 'cpu_budget_percent': 100,
+ 'num_workers': 'auto'}
+
+
+CONFIG_INPUT_ORDER = ['hidden_size',
+ 'num_hidden_layers',
+ 'num_experts',
+ 'num_experts_per_tok',
+ 'num_shared_experts',
+ 'lookahead_steps',
+ 'kv_latent_dim',
+ 'sliding_window_size',
+ 'num_attention_heads',
+ 'num_key_value_heads',
+ 'rope_dim',
+ 'moe_intermediate_size',
+ 'vocab_size',
+ 'dtype',
+ 'tie_word_embeddings',
+ 'lambda_balance',
+ 'lambda_temporal',
+ 'lambda_lookahead',
+ 'lambda_lookahead_topk',
+ 'lambda_router_anchor',
+ 'fallback_mask_prob',
+ 'vram_budget_gb',
+ 'pinned_memory_max_fraction',
+ 'use_hybrid_attention',
+ 'storage_format',
+ 'learning_rate',
+ 'batch_size',
+ 'accumulation_steps',
+ 'max_seq_len',
+ 'epochs',
+ 'max_steps',
+ 'save_interval',
+ 'log_interval',
+ 'save_dir',
+ 'weight_decay',
+ 'grad_clip',
+ 'cpu_threads',
+ 'cpu_budget_percent',
+ 'num_workers',
+ 'reward_spec',
+ 'max_gen_len',
+ 'num_generations',
+ 'temperature',
+ 'beta',
+ 'alpha',
+ 'lambda_or',
+ 'recurrent_enabled',
+ 'recurrent_groups',
+ 'recurrent_depth',
+ 'min_recurrent_depth',
+ 'adaptive_halting',
+ 'halting_threshold',
+ 'max_inference_depth',
+ 'ponder_loss_coef',
+ 'recurrent_distill_coef',
+ 'recurrent_stability_coef',
+ 'recurrent_latent_refinement',
+ 'depth_schedule',
+ 'recurrent_strategy',
+ 'recurrent_depth_jitter',
+ 'depth_schedule_warmup_fraction',
+ 'layer_aware_cache',
+ 'prefetch_depth',
+ 'training_paging',
+ 'optimizer_paging',
+ 'page_size_mb',
+ 'optimizer_page_dir',
+ 'modalities']
+
+
+# ── Chronos v3 tiers ───────────────────────────────────────────────
+# Generated by the adaptive planner (chronos.model.planner) and stored as JSON
+# so importing the UI never runs the search. ``python -m ui.presets``
+# regenerates the file from V3_TIER_REQUESTS.
+V3_PRESETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "presets_v3.json")
+
+V3_TIER_REQUESTS = [
+ ("Chronos-v3 Tiny (smoke / CI)", "1.5M smoke test; trains in minutes on CPU.",
+ dict(target_total=1.5e6, num_experts=4, active_experts=2, vocab_size=6400, seq_len=128, recurrent_depth=2,
+ memory_saving="none", token_budget=2e6, ram_gb=16)),
+ ("Chronos-v3 50M (en→zh recipe)", "Exactly the enzh_50m_72h recipe (16k Chronos tokenizer, shards_v1).",
+ dict(target_total=50e6, num_experts=36, active_experts=2, data_paths=("Translation Training Data/shards_v1",),
+ memory_saving="none", ram_gb=32)),
+ ("Chronos-v3 200M (32 GB)", "General 200M MoE, balanced memory saving, minimind 6400 vocab.",
+ dict(target_total=200e6, num_experts=32, active_experts=2, vocab_size=6400, seq_len=1024,
+ memory_saving="balanced", ram_gb=32)),
+ ("Chronos-v3 500M (32 GB)", "500M MoE, aggressive memory saving (int8 KV, shared routing, checkpointing).",
+ dict(target_total=500e6, num_experts=48, active_experts=3, vocab_size=6400, seq_len=1024,
+ memory_saving="aggressive", ram_gb=32)),
+ ("Chronos-v3 1B (64 GB)", "1B MoE, 64 fine-grained experts, aggressive memory saving.",
+ dict(target_total=1e9, num_experts=64, active_experts=4, vocab_size=6400, seq_len=2048,
+ memory_saving="aggressive", ram_gb=64)),
]
-# Named presets — keys are user-facing labels (also used in the dropdown).
+def build_v3_presets() -> dict:
+ """Run the planner for every tier; returns the presets_v3.json document."""
+ from chronos.model.planner import PlanRequest, plan_training
+ presets, meta = {}, {}
+ for name, blurb, request in V3_TIER_REQUESTS:
+ plan = plan_training(PlanRequest(**request))
+ cfg = dict(plan["config"])
+ if "tokenizer_path" in cfg:
+ cfg["tokenizer_path"] = "./Translation Training Data/tokenizer_v1" # repo-relative
+ presets[name] = {k: v for k, v in cfg.items() if MINIMIND_MOE_DEFAULTS.get(k, object()) != v}
+ arch, mem = plan["report"]["architecture"], plan["report"]["memory"]
+ meta[name] = dict(
+ summary=blurb, request={k: (list(v) if isinstance(v, tuple) else v) for k, v in request.items()},
+ total_params=arch["total_params"], active_params=arch["active_params"],
+ active_union_upper=arch["active_union_upper"], params_label=arch["params_source"],
+ training_peak_gb=mem["training_peak_gb"], training_peak_label="estimated",
+ notes=plan["report"]["notes"])
+ return {"_about": "Generated by chronos.model.planner.plan_training (inputs in _meta.request). Parameter "
+ "counts are measured on the meta device; memory figures are estimates. Regenerate with "
+ "`python -m ui.presets` instead of hand-editing.",
+ "_meta": meta, "presets": presets}
+
+
+def _load_v3_presets() -> tuple[Dict[str, Dict], Dict[str, Dict]]:
+ try:
+ with open(V3_PRESETS_PATH, encoding="utf-8") as fh:
+ doc = json.load(fh)
+ except (OSError, ValueError):
+ return {}, {}
+ return ({name: {**MINIMIND_MOE_DEFAULTS, **cfg} for name, cfg in doc.get("presets", {}).items()},
+ dict(doc.get("_meta", {})))
+
+
+V3_PRESETS, V3_PRESET_META = _load_v3_presets()
+DEFAULT_PRESET = "Chronos-v3 200M (32 GB)" if "Chronos-v3 200M (32 GB)" in V3_PRESETS else "MiniMind-MoE (default)"
+
PRESETS: Dict[str, Dict] = {
+ **V3_PRESETS,
+ # Legacy v2 shape kept for minimind compatibility (first_only recurrence).
"MiniMind-MoE (default)": dict(MINIMIND_MOE_DEFAULTS),
+}
- # M8c: "Recommended-CN" — sized for the /Users/Fona/Downloads/Hybrid_LLM/Dataset
- # 1.3 GB pretrain corpus. Previously the default 15M-param model
- # (H=256, L=4, E=4, topk=1 → ~6M activated) was far below the size
- # needed for coherent Chinese output (empirically ~100M activated).
- # This preset targets ~120M total / ~60M activated params at vocab=6400.
- "Recommended-CN (≈120M)": {
- **MINIMIND_MOE_DEFAULTS,
- "hidden_size": 512, "num_hidden_layers": 8, "num_experts": 8,
- "num_experts_per_tok": 2, "num_shared_experts": 1,
- "num_attention_heads": 8, "num_key_value_heads": 2,
- "kv_latent_dim": 64, "rope_dim": 32, "sliding_window_size": 2048,
- "max_seq_len": 512, "batch_size": 8, "accumulation_steps": 8,
- "epochs": 1, "save_interval": 500,
- "learning_rate": 3e-4,
- },
-
- "Tiny (smoke / CI)": {
- **MINIMIND_MOE_DEFAULTS,
- "hidden_size": 128, "num_hidden_layers": 2, "num_experts": 4,
- "num_attention_heads": 4, "num_key_value_heads": 4,
- "kv_latent_dim": 16, "rope_dim": 8, "sliding_window_size": 64,
- "max_seq_len": 64, "batch_size": 2, "accumulation_steps": 1,
- "epochs": 1, "save_interval": 100,
- },
-
- "Small (256H × 4L × 4E)": {
- **MINIMIND_MOE_DEFAULTS,
- "hidden_size": 256, "num_hidden_layers": 4, "num_experts": 4,
- "max_seq_len": 256, "batch_size": 8,
- },
-
- "Medium (512H × 8L × 8E)": {
- **MINIMIND_MOE_DEFAULTS,
- "hidden_size": 512, "num_experts": 8,
- "max_seq_len": 1024, "batch_size": 8,
- },
-
- "Large (1024H × 16L × 16E)": {
- **MINIMIND_MOE_DEFAULTS,
- "hidden_size": 1024, "num_hidden_layers": 16, "num_experts": 16,
- "num_attention_heads": 16, "num_key_value_heads": 4,
- "kv_latent_dim": 128, "rope_dim": 64,
- "max_seq_len": 2048, "batch_size": 4, "accumulation_steps": 16,
- "vram_budget_gb": 16.0,
- },
+# Pre-v3 preset names (saved sessions, docs) resolve to the closest v3 tier.
+_PRESET_ALIASES = {
+ "Recommended-CN (≈120M)": "Chronos-v3 200M (32 GB)",
+ "Recommended-CN (≈192M)": "Chronos-v3 200M (32 GB)",
+ "Tiny (smoke / CI)": "Chronos-v3 Tiny (smoke / CI)",
+ "Small (256H × 4L × 4E)": "Chronos-v3 50M (en→zh recipe)",
+ "Medium (512H × 8L × 8E)": "Chronos-v3 200M (32 GB)",
+ "Large (1024H × 16L × 16E)": "Chronos-v3 1B (64 GB)",
}
@@ -163,9 +273,10 @@ def preset_names() -> List[str]:
def get_preset(name: str) -> Dict:
- """Return a deep-ish copy of the named preset (avoids accidental mutation)."""
+ """Return a copy of the named preset (avoids accidental mutation)."""
+ name = _PRESET_ALIASES.get(name, name)
cfg = dict(MINIMIND_MOE_DEFAULTS)
- cfg.update(PRESETS.get(name, {}))
+ cfg.update(json.loads(json.dumps(PRESETS.get(name, {})))) # nested stage_overrides copied too
return cfg
@@ -199,6 +310,17 @@ def values_in_input_order(cfg: Dict) -> List:
"reward_spec": "toy",
"max_gen_len": 24, "num_generations": 4, "temperature": 1.0,
"beta": 0.1, "alpha": 0.7, "lambda_or": 0.1,
+ "recurrent_enabled": True, "recurrent_groups": 2,
+ "recurrent_depth": 4, "min_recurrent_depth": 2,
+ "adaptive_halting": True, "halting_threshold": 0.99,
+ "max_inference_depth": 4, "ponder_loss_coef": 1e-2,
+ "recurrent_distill_coef": 0.05, "recurrent_stability_coef": 1e-3,
+ "recurrent_latent_refinement": True,
+ "depth_schedule": "fixed_then_adaptive", "recurrent_strategy": "scheduled",
+ "recurrent_depth_jitter": 0.0, "depth_schedule_warmup_fraction": 0.25,
+ "layer_aware_cache": True, "prefetch_depth": 2,
+ "training_paging": False, "optimizer_paging": False, "page_size_mb": 64,
+ "optimizer_page_dir": "./optimizer_pages", "modalities": "",
}
out = []
for k in CONFIG_INPUT_ORDER:
@@ -215,6 +337,8 @@ def values_in_input_order(cfg: Dict) -> List:
"full": "float32",
"": "auto",
}.get(normalized, normalized)
+ if k == "modalities" and isinstance(v, (list, tuple)):
+ v = ",".join(v)
out.append(v)
return out
@@ -233,5 +357,16 @@ def load_config(path: str) -> Dict:
with open(os.path.abspath(path)) as f:
loaded = json.load(f)
cfg = dict(MINIMIND_MOE_DEFAULTS)
+ if not isinstance(loaded, dict):
+ raise ValueError("Configuration JSON must be an object")
+ if "max_position_embeddings" in loaded and "max_seq_len" not in loaded:
+ loaded["max_seq_len"] = loaded["max_position_embeddings"]
cfg.update(loaded)
return cfg
+
+
+if __name__ == "__main__": # python -m ui.presets → regenerate presets_v3.json
+ with open(V3_PRESETS_PATH, "w", encoding="utf-8") as fh:
+ json.dump(build_v3_presets(), fh, indent=1, ensure_ascii=False)
+ fh.write("\n")
+ print(f"wrote {V3_PRESETS_PATH}")
diff --git a/ui/presets_v3.json b/ui/presets_v3.json
new file mode 100644
index 0000000..45cc2c9
--- /dev/null
+++ b/ui/presets_v3.json
@@ -0,0 +1,699 @@
+{
+ "_about": "Generated by chronos.model.planner.plan_training (inputs in _meta.request). Parameter counts are measured on the meta device; memory figures are estimates. Regenerate with `python -m ui.presets` instead of hand-editing.",
+ "_meta": {
+ "Chronos-v3 Tiny (smoke / CI)": {
+ "summary": "1.5M smoke test; trains in minutes on CPU.",
+ "request": {
+ "target_total": 1500000.0,
+ "num_experts": 4,
+ "active_experts": 2,
+ "vocab_size": 6400,
+ "seq_len": 128,
+ "recurrent_depth": 2,
+ "memory_saving": "none",
+ "token_budget": 2000000.0,
+ "ram_gb": 16
+ },
+ "total_params": 1511106,
+ "active_params": 1289922,
+ "active_union_upper": 1363650,
+ "params_label": "measured (meta-device count)",
+ "training_peak_gb": 2.13,
+ "training_peak_label": "estimated",
+ "notes": [
+ "C = 1.00e+13 FLOPs is outside the DeepSeek fit range 1e+17–2e+19; LR/batch are extrapolated and clamped"
+ ]
+ },
+ "Chronos-v3 50M (en→zh recipe)": {
+ "summary": "Exactly the enzh_50m_72h recipe (16k Chronos tokenizer, shards_v1).",
+ "request": {
+ "target_total": 50000000.0,
+ "num_experts": 36,
+ "active_experts": 2,
+ "data_paths": [
+ "Translation Training Data/shards_v1"
+ ],
+ "memory_saving": "none",
+ "ram_gb": 32
+ },
+ "total_params": 49691717,
+ "active_params": 12090437,
+ "active_union_upper": 16514117,
+ "params_label": "measured (meta-device count)",
+ "training_peak_gb": 16.4,
+ "training_peak_label": "estimated",
+ "notes": [
+ "token counts from Translation Training Data/shards_v1/shards_manifest.json"
+ ]
+ },
+ "Chronos-v3 200M (32 GB)": {
+ "summary": "General 200M MoE, balanced memory saving, minimind 6400 vocab.",
+ "request": {
+ "target_total": 200000000.0,
+ "num_experts": 32,
+ "active_experts": 2,
+ "vocab_size": 6400,
+ "seq_len": 1024,
+ "memory_saving": "balanced",
+ "ram_gb": 32
+ },
+ "total_params": 200724295,
+ "active_params": 28200775,
+ "active_union_upper": 54079303,
+ "params_label": "measured (meta-device count)",
+ "training_peak_gb": 10.14,
+ "training_peak_label": "estimated",
+ "notes": []
+ },
+ "Chronos-v3 500M (32 GB)": {
+ "summary": "500M MoE, aggressive memory saving (int8 KV, shared routing, checkpointing).",
+ "request": {
+ "target_total": 500000000.0,
+ "num_experts": 48,
+ "active_experts": 3,
+ "vocab_size": 6400,
+ "seq_len": 1024,
+ "memory_saving": "aggressive",
+ "ram_gb": 32
+ },
+ "total_params": 499529738,
+ "active_params": 61585418,
+ "active_union_upper": 133249034,
+ "params_label": "measured (meta-device count)",
+ "training_peak_gb": 9.26,
+ "training_peak_label": "estimated",
+ "notes": []
+ },
+ "Chronos-v3 1B (64 GB)": {
+ "summary": "1B MoE, 64 fine-grained experts, aggressive memory saving.",
+ "request": {
+ "target_total": 1000000000.0,
+ "num_experts": 64,
+ "active_experts": 4,
+ "vocab_size": 6400,
+ "seq_len": 2048,
+ "memory_saving": "aggressive",
+ "ram_gb": 64
+ },
+ "total_params": 1002169739,
+ "active_params": 99739019,
+ "active_union_upper": 250144139,
+ "params_label": "measured (meta-device count)",
+ "training_peak_gb": 18.92,
+ "training_peak_label": "estimated",
+ "notes": []
+ }
+ },
+ "presets": {
+ "Chronos-v3 Tiny (smoke / CI)": {
+ "architecture_version": 3,
+ "recurrent_attention_mode": "shared_first",
+ "rope_layout": "per_head",
+ "hidden_size": 128,
+ "num_hidden_layers": 3,
+ "num_attention_heads": 2,
+ "num_key_value_heads": 1,
+ "kv_latent_dim": 32,
+ "sliding_window_size": 128,
+ "use_moe": true,
+ "moe_intermediate_size": 96,
+ "recurrent_groups": 1,
+ "recurrent_depth": 2,
+ "min_recurrent_depth": 1,
+ "max_inference_depth": 2,
+ "halting_threshold": 0.95,
+ "depth_schedule_warmup_fraction": 0.3,
+ "recurrent_latent_refinement": false,
+ "ponder_loss_coef": 0.003,
+ "lambda_balance": 0.005,
+ "fallback_mask_prob": 0.08,
+ "dropout": 0.0,
+ "kv_cache_dtype": "auto",
+ "mla_cache_rope": true,
+ "router_scope": "per_iteration",
+ "ram_budget_mb": null,
+ "vram_budget_gb": 0.25,
+ "dtype": "bfloat16",
+ "learning_rate": 0.003,
+ "batch_size": 128,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "epochs": 1,
+ "max_steps": 122,
+ "weight_decay": 0.1,
+ "save_interval": 100,
+ "stage_overrides": {
+ "pretrain": {
+ "learning_rate": 0.003,
+ "batch_size": 128,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 122,
+ "weight_decay": 0.1,
+ "save_interval": 100,
+ "lambda_router_anchor": 0.0
+ },
+ "sft": {
+ "learning_rate": 0.001,
+ "batch_size": 128,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 500,
+ "lambda_router_anchor": 0.0
+ },
+ "dpo": {
+ "learning_rate": 0.00015,
+ "batch_size": 64,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "orpo": {
+ "learning_rate": 0.0003,
+ "batch_size": 64,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "lambda_or": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "grpo": {
+ "learning_rate": 2e-05,
+ "batch_size": 32,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.04,
+ "num_generations": 8,
+ "temperature": 1.0,
+ "max_gen_len": 64,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "distill": {
+ "learning_rate": 0.001,
+ "batch_size": 128,
+ "accumulation_steps": 1,
+ "max_seq_len": 128,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "alpha": 0.7,
+ "temperature": 4.0,
+ "lambda_router_anchor": 0.0,
+ "save_interval": 500
+ }
+ }
+ },
+ "Chronos-v3 50M (en→zh recipe)": {
+ "architecture_version": 3,
+ "recurrent_attention_mode": "shared_first",
+ "rope_layout": "per_head",
+ "hidden_size": 384,
+ "num_hidden_layers": 6,
+ "num_attention_heads": 6,
+ "num_key_value_heads": 2,
+ "kv_latent_dim": 128,
+ "sliding_window_size": 128,
+ "vocab_size": 16384,
+ "use_moe": true,
+ "num_experts": 36,
+ "moe_intermediate_size": 160,
+ "recurrent_groups": 4,
+ "halting_threshold": 0.95,
+ "depth_schedule_warmup_fraction": 0.3,
+ "recurrent_latent_refinement": false,
+ "ponder_loss_coef": 0.003,
+ "lambda_balance": 0.005,
+ "fallback_mask_prob": 0.08,
+ "dropout": 0.0,
+ "kv_cache_dtype": "auto",
+ "mla_cache_rope": true,
+ "router_scope": "per_iteration",
+ "ram_budget_mb": null,
+ "vram_budget_gb": 0.25,
+ "dtype": "bfloat16",
+ "learning_rate": 0.0021,
+ "batch_size": 32,
+ "epochs": 1,
+ "weight_decay": 0.1,
+ "save_interval": 3810,
+ "stage_overrides": {
+ "pretrain": {
+ "learning_rate": 0.0021,
+ "batch_size": 32,
+ "accumulation_steps": 8,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 3810,
+ "lambda_router_anchor": 0.0
+ },
+ "sft": {
+ "learning_rate": 0.0007,
+ "batch_size": 32,
+ "accumulation_steps": 4,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 31560,
+ "lambda_router_anchor": 0.0
+ },
+ "dpo": {
+ "learning_rate": 0.0001,
+ "batch_size": 16,
+ "accumulation_steps": 4,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "orpo": {
+ "learning_rate": 0.00021,
+ "batch_size": 16,
+ "accumulation_steps": 4,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "lambda_or": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "grpo": {
+ "learning_rate": 2e-05,
+ "batch_size": 8,
+ "accumulation_steps": 1,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.04,
+ "num_generations": 8,
+ "temperature": 1.0,
+ "max_gen_len": 256,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "distill": {
+ "learning_rate": 0.0007,
+ "batch_size": 32,
+ "accumulation_steps": 4,
+ "max_seq_len": 512,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "alpha": 0.7,
+ "temperature": 4.0,
+ "lambda_router_anchor": 0.0,
+ "save_interval": 31560
+ }
+ },
+ "tokenizer_path": "./Translation Training Data/tokenizer_v1"
+ },
+ "Chronos-v3 200M (32 GB)": {
+ "architecture_version": 3,
+ "recurrent_attention_mode": "shared_first",
+ "rope_layout": "per_head",
+ "hidden_size": 576,
+ "num_attention_heads": 9,
+ "num_key_value_heads": 3,
+ "kv_latent_dim": 192,
+ "sliding_window_size": 256,
+ "use_moe": true,
+ "num_experts": 32,
+ "moe_intermediate_size": 416,
+ "recurrent_groups": 6,
+ "halting_threshold": 0.95,
+ "depth_schedule_warmup_fraction": 0.3,
+ "recurrent_latent_refinement": false,
+ "ponder_loss_coef": 0.003,
+ "lambda_balance": 0.005,
+ "fallback_mask_prob": 0.08,
+ "dropout": 0.0,
+ "kv_cache_dtype": "int8",
+ "mla_cache_rope": false,
+ "router_scope": "per_iteration",
+ "ram_budget_mb": 263.2,
+ "vram_budget_gb": 0.25,
+ "dtype": "bfloat16",
+ "learning_rate": 0.0015,
+ "batch_size": 4,
+ "accumulation_steps": 64,
+ "max_seq_len": 1024,
+ "epochs": 1,
+ "max_steps": 980096,
+ "weight_decay": 0.1,
+ "save_interval": 49000,
+ "stage_overrides": {
+ "pretrain": {
+ "learning_rate": 0.0015,
+ "batch_size": 4,
+ "accumulation_steps": 64,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 980096,
+ "weight_decay": 0.1,
+ "save_interval": 49000,
+ "lambda_router_anchor": 0.0
+ },
+ "sft": {
+ "learning_rate": 0.0005,
+ "batch_size": 4,
+ "accumulation_steps": 32,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 500,
+ "lambda_router_anchor": 0.0
+ },
+ "dpo": {
+ "learning_rate": 7.5e-05,
+ "batch_size": 2,
+ "accumulation_steps": 32,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "orpo": {
+ "learning_rate": 0.00015,
+ "batch_size": 2,
+ "accumulation_steps": 32,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "lambda_or": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "grpo": {
+ "learning_rate": 1.5e-05,
+ "batch_size": 1,
+ "accumulation_steps": 1,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.04,
+ "num_generations": 4,
+ "temperature": 1.0,
+ "max_gen_len": 256,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "distill": {
+ "learning_rate": 0.0005,
+ "batch_size": 4,
+ "accumulation_steps": 32,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "alpha": 0.7,
+ "temperature": 4.0,
+ "lambda_router_anchor": 0.0,
+ "save_interval": 500
+ }
+ }
+ },
+ "Chronos-v3 500M (32 GB)": {
+ "architecture_version": 3,
+ "recurrent_attention_mode": "shared_first",
+ "rope_layout": "per_head",
+ "num_hidden_layers": 11,
+ "num_attention_heads": 12,
+ "kv_latent_dim": 256,
+ "sliding_window_size": 256,
+ "use_moe": true,
+ "num_experts": 48,
+ "num_experts_per_tok": 3,
+ "moe_intermediate_size": 384,
+ "recurrent_groups": 9,
+ "halting_threshold": 0.95,
+ "depth_schedule_warmup_fraction": 0.3,
+ "recurrent_latent_refinement": false,
+ "ponder_loss_coef": 0.003,
+ "lambda_balance": 0.005,
+ "fallback_mask_prob": 0.08,
+ "dropout": 0.0,
+ "kv_cache_dtype": "int8",
+ "mla_cache_rope": false,
+ "router_scope": "shared",
+ "ram_budget_mb": 445.5,
+ "activation_checkpointing": true,
+ "vram_budget_gb": 0.35,
+ "dtype": "bfloat16",
+ "learning_rate": 0.0012,
+ "batch_size": 2,
+ "accumulation_steps": 256,
+ "max_seq_len": 1024,
+ "epochs": 1,
+ "max_steps": 4878080,
+ "weight_decay": 0.1,
+ "save_interval": 100000,
+ "stage_overrides": {
+ "pretrain": {
+ "learning_rate": 0.0012,
+ "batch_size": 2,
+ "accumulation_steps": 256,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 4878080,
+ "weight_decay": 0.1,
+ "save_interval": 100000,
+ "lambda_router_anchor": 0.0
+ },
+ "sft": {
+ "learning_rate": 0.0004,
+ "batch_size": 2,
+ "accumulation_steps": 128,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 500,
+ "lambda_router_anchor": 0.0
+ },
+ "dpo": {
+ "learning_rate": 6e-05,
+ "batch_size": 1,
+ "accumulation_steps": 128,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "orpo": {
+ "learning_rate": 0.00012,
+ "batch_size": 1,
+ "accumulation_steps": 128,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "lambda_or": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "grpo": {
+ "learning_rate": 1.2e-05,
+ "batch_size": 1,
+ "accumulation_steps": 1,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.04,
+ "num_generations": 4,
+ "temperature": 1.0,
+ "max_gen_len": 256,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "distill": {
+ "learning_rate": 0.0004,
+ "batch_size": 2,
+ "accumulation_steps": 128,
+ "max_seq_len": 1024,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "alpha": 0.7,
+ "temperature": 4.0,
+ "lambda_router_anchor": 0.0,
+ "save_interval": 500
+ }
+ }
+ },
+ "Chronos-v3 1B (64 GB)": {
+ "architecture_version": 3,
+ "recurrent_attention_mode": "shared_first",
+ "rope_layout": "per_head",
+ "num_hidden_layers": 12,
+ "num_attention_heads": 12,
+ "kv_latent_dim": 256,
+ "sliding_window_size": 512,
+ "use_moe": true,
+ "num_experts": 64,
+ "num_experts_per_tok": 4,
+ "moe_intermediate_size": 544,
+ "recurrent_groups": 10,
+ "halting_threshold": 0.95,
+ "depth_schedule_warmup_fraction": 0.3,
+ "recurrent_latent_refinement": false,
+ "ponder_loss_coef": 0.003,
+ "lambda_balance": 0.005,
+ "fallback_mask_prob": 0.08,
+ "dropout": 0.0,
+ "kv_cache_dtype": "int8",
+ "mla_cache_rope": false,
+ "router_scope": "shared",
+ "ram_budget_mb": 918.0,
+ "activation_checkpointing": true,
+ "vram_budget_gb": 0.65,
+ "dtype": "bfloat16",
+ "learning_rate": 0.0011,
+ "batch_size": 2,
+ "accumulation_steps": 128,
+ "max_seq_len": 2048,
+ "epochs": 1,
+ "max_steps": 4893312,
+ "weight_decay": 0.1,
+ "save_interval": 100000,
+ "stage_overrides": {
+ "pretrain": {
+ "learning_rate": 0.0011,
+ "batch_size": 2,
+ "accumulation_steps": 128,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 4893312,
+ "weight_decay": 0.1,
+ "save_interval": 100000,
+ "lambda_router_anchor": 0.0
+ },
+ "sft": {
+ "learning_rate": 0.00037,
+ "batch_size": 2,
+ "accumulation_steps": 64,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "save_interval": 500,
+ "lambda_router_anchor": 0.0
+ },
+ "dpo": {
+ "learning_rate": 5.5e-05,
+ "batch_size": 1,
+ "accumulation_steps": 64,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "orpo": {
+ "learning_rate": 0.00011,
+ "batch_size": 1,
+ "accumulation_steps": 64,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "lambda_or": 0.1,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "grpo": {
+ "learning_rate": 1.1e-05,
+ "batch_size": 1,
+ "accumulation_steps": 1,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.01,
+ "beta": 0.04,
+ "num_generations": 4,
+ "temperature": 1.0,
+ "max_gen_len": 256,
+ "lambda_router_anchor": 0.01,
+ "save_interval": 500
+ },
+ "distill": {
+ "learning_rate": 0.00037,
+ "batch_size": 2,
+ "accumulation_steps": 64,
+ "max_seq_len": 2048,
+ "grad_clip": 1.0,
+ "epochs": 1,
+ "max_steps": 0,
+ "weight_decay": 0.1,
+ "alpha": 0.7,
+ "temperature": 4.0,
+ "lambda_router_anchor": 0.0,
+ "save_interval": 500
+ }
+ }
+ }
+ }
+}
diff --git a/ui/tabs/autotune_tab.py b/ui/tabs/autotune_tab.py
index 6651991..818c592 100644
--- a/ui/tabs/autotune_tab.py
+++ b/ui/tabs/autotune_tab.py
@@ -161,21 +161,23 @@ def poll_tune():
best = _tuner.result.best_params
return _tuner.status, _read_log(), best
- def apply_best(best):
+ def apply_best(best, previous):
"""Write best_params back into the Config tab's slider widgets.
Returns a list of gr.update for every config_input — untouched
inputs get gr.update() (no-op). None values are dropped so
Gradio's numeric widgets don't reject them."""
updates = [gr.update() for _ in config_inputs]
+ cfg = dict(previous or {})
if not best:
- return updates
+ return [cfg] + updates
for k, v in best.items():
if v is None:
continue
idx = PARAM_TO_CONFIG_IDX.get(k)
if idx is not None:
updates[idx] = gr.update(value=v)
- return updates
+ cfg[k] = v
+ return [cfg] + updates
start_btn.click(
fn=start_tune,
@@ -187,7 +189,7 @@ def apply_best(best):
)
stop_btn.click(fn=stop_tune, outputs=[status_box])
clear_btn.click(fn=clear_log_file, outputs=[log_box])
- apply_btn.click(fn=apply_best, inputs=[best_box], outputs=config_inputs)
+ apply_btn.click(fn=apply_best, inputs=[best_box, config_state], outputs=[config_state] + config_inputs)
timer = gr.Timer(value=2.0)
timer.tick(fn=poll_tune, outputs=[status_box, log_box, best_box])
diff --git a/ui/tabs/benchmark_tab.py b/ui/tabs/benchmark_tab.py
index 711d7d5..52323e8 100644
--- a/ui/tabs/benchmark_tab.py
+++ b/ui/tabs/benchmark_tab.py
@@ -116,6 +116,13 @@ def _load_existing():
def build_benchmark_tab():
+ # Resolve the initial state before constructing components. Assigning a
+ # pandas DataFrame to ``component.value`` after construction bypasses
+ # Gradio's postprocessing step (notably in Gradio 5.x), causing the
+ # nativeplot frontend to receive a string representation of the frame and
+ # fail during hydration (the whole UI remains stuck on "Loading...").
+ initial_results, initial_markdown, initial_df, initial_log = _load_existing()
+
with gr.Tab(t("tab.benchmark")) as tab:
register_translatable(tab, "tab.benchmark")
@@ -123,11 +130,11 @@ def build_benchmark_tab():
register_translatable(run_btn, "bench.run")
# Formatted summary
- summary_md = gr.Markdown(value=_format_table({}))
+ summary_md = gr.Markdown(value=initial_markdown)
# Bar chart — one BarPlot per model so units stack cleanly per metric.
chart = gr.BarPlot(
- value=_empty_df(),
+ value=initial_df,
x="metric", y="value", color="model",
title="MiniMind vs Chronos — comparison metrics",
tooltip=["model", "metric", "value", "unit"],
@@ -135,11 +142,12 @@ def build_benchmark_tab():
)
with gr.Accordion("Raw JSON", open=False):
- results_box = gr.JSON(label=t("bench.results"))
+ results_box = gr.JSON(label=t("bench.results"), value=initial_results)
register_translatable(results_box, "bench.results")
log_box = gr.Textbox(
- label=t("bench.log"), lines=20, interactive=False, autoscroll=True
+ label=t("bench.log"), lines=20, interactive=False, autoscroll=True,
+ value=initial_log,
)
register_translatable(log_box, "bench.log")
@@ -192,11 +200,4 @@ def run_benchmark():
outputs=[results_box, summary_md, chart, log_box],
)
- # Render existing results on tab open
- d, md, df, log = _load_existing()
- results_box.value = d
- summary_md.value = md
- chart.value = df
- log_box.value = log
-
return tab
diff --git a/ui/tabs/config_tab.py b/ui/tabs/config_tab.py
index 2488244..cc51526 100644
--- a/ui/tabs/config_tab.py
+++ b/ui/tabs/config_tab.py
@@ -15,10 +15,12 @@
from ui.i18n import t, register_translatable
from ui.estimator import (
ArchConfig, total_params, active_params, memory_footprint,
- estimated_decode_tps, fmt_bytes, fmt_params,
+ compute_params, active_param_bounds, parameter_breakdown, estimated_decode_tps, fmt_bytes, fmt_params,
)
+from ui.planner_panel import build_planner_panel
from ui.presets import (
- preset_names, get_preset, values_in_input_order, save_config, load_config,
+ CONFIG_INPUT_ORDER, DEFAULT_PRESET, MINIMIND_MOE_DEFAULTS, preset_names, get_preset, values_in_input_order,
+ save_config, load_config,
)
@@ -59,38 +61,67 @@ def _estimate(cfg_dict: dict):
from hidden_size", matching MiniMindConfig's default rule. This keeps
the estimator consistent with what the trainer actually builds.
"""
- mis = int(cfg_dict.get("moe_intermediate_size", 0) or 0)
- its = int(cfg_dict.get("intermediate_size", 0) or 0)
- arch = ArchConfig(
- hidden_size=int(cfg_dict.get("hidden_size", 256)),
- num_hidden_layers=int(cfg_dict.get("num_hidden_layers", 4)),
- num_attention_heads=int(cfg_dict.get("num_attention_heads", 8)),
- num_key_value_heads=int(cfg_dict.get("num_key_value_heads", 4)),
- vocab_size=int(cfg_dict.get("vocab_size", 6400)),
- intermediate_size=its if its > 0 else None,
- moe_intermediate_size=mis if mis > 0 else None,
- num_experts=int(cfg_dict.get("num_experts", 4)),
- num_experts_per_tok=int(cfg_dict.get("num_experts_per_tok", 1)),
- num_shared_experts=int(cfg_dict.get("num_shared_experts", 1)),
- lookahead_steps=int(cfg_dict.get("lookahead_steps", 2)),
- kv_latent_dim=int(cfg_dict.get("kv_latent_dim", 64)),
- rope_dim=int(cfg_dict.get("rope_dim", 32)),
- sliding_window_size=int(cfg_dict.get("sliding_window_size", 2048)),
- use_hybrid_attention=bool(cfg_dict.get("use_hybrid_attention", True)),
- tie_word_embeddings=bool(cfg_dict.get("tie_word_embeddings", True)),
- dtype=_normalize_dtype_name(str(cfg_dict.get("dtype", "auto"))),
- )
- mem = memory_footprint(arch)
+ arch = ArchConfig.from_config(cfg_dict)
+ seq_len = int(cfg_dict.get("max_seq_len", cfg_dict.get("max_position_embeddings", 1024)))
+ mem = memory_footprint(arch, seq_len)
+ low, high = active_param_bounds(arch)
+ detail = {
+ "parameters": parameter_breakdown(arch),
+ "active_unique_min": low, "active_unique_max": high,
+ "physical_blocks": arch.num_hidden_layers,
+ "prelude_groups_coda": [arch.prelude_layers, arch.recurrent_groups, arch.coda_layers],
+ "decode_depth_ceiling": arch.executed_depth,
+ "weight_use_count": compute_params(arch),
+ "inference_batch_size": 1, "context_tokens": seq_len,
+ "dtype_assumption": arch.dtype if arch.dtype != "auto" else "float32 (auto upper-bound scenario)",
+ "memory_bytes": mem,
+ "training_peak_excludes": ["activations", "attention workspace", "rollback snapshots", "allocator pools"],
+ }
return (
fmt_params(total_params(arch)),
- fmt_params(active_params(arch)),
- fmt_bytes(mem["vram_estimate_bytes"]),
- fmt_bytes(mem["ssd_estimate_bytes"]),
- fmt_bytes(mem["kv_cache_bytes"]),
- f"{estimated_decode_tps(arch):.1f}",
+ fmt_params(low) if low == high else f"{fmt_params(low)} – {fmt_params(high)}",
+ fmt_params(compute_params(arch)),
+ fmt_bytes(mem["vram_estimate_bytes"]), fmt_bytes(mem["ram_estimate_bytes"]),
+ fmt_bytes(mem["ssd_estimate_bytes"]), fmt_bytes(mem["kv_cache_bytes"]),
+ f"{estimated_decode_tps(arch):.1f}", detail,
)
+def merge_config_inputs(previous: dict, values: list) -> dict:
+ """Apply editor fields by name while retaining loaded fields without widgets."""
+ if len(values) != len(CONFIG_INPUT_ORDER):
+ raise ValueError(f"Config editor expects {len(CONFIG_INPUT_ORDER)} values, received {len(values)}")
+ cfg = dict(previous or MINIMIND_MOE_DEFAULTS)
+ for key, value in zip(CONFIG_INPUT_ORDER, values):
+ if key == "modalities":
+ value = value if isinstance(value, list) else str(value or "").split(",")
+ value = sorted(set(m.strip().lower() for m in value if m.strip() and m.strip().lower() != "text"))
+ elif key == "dtype":
+ value = _normalize_dtype_name(value)
+ else:
+ default = MINIMIND_MOE_DEFAULTS.get(key)
+ if value is None:
+ value = default
+ if isinstance(default, bool):
+ value = bool(value)
+ elif isinstance(default, int):
+ value = int(value)
+ elif isinstance(default, float):
+ value = float(value)
+ cfg[key] = value
+ # A loaded checkpoint/HF config may contain the canonical alias too.
+ # Keep the visible sequence-length control authoritative when it is edited.
+ if "max_position_embeddings" in cfg:
+ cfg["max_position_embeddings"] = cfg["max_seq_len"]
+ # The shared training widgets are the pretrain row of the per-stage table:
+ # an edited widget must not be silently overridden by a stale plan value.
+ stages = cfg.get("stage_overrides")
+ if isinstance(stages, dict) and isinstance(stages.get("pretrain"), dict):
+ pretrain = {k: (cfg[k] if k in CONFIG_INPUT_ORDER and k in cfg else v) for k, v in stages["pretrain"].items()}
+ cfg["stage_overrides"] = {**stages, "pretrain": pretrain}
+ return cfg
+
+
def build_config_tab():
"""Returns (config_state, list_of_input_components, save_dir).
@@ -98,7 +129,7 @@ def build_config_tab():
data_path has been moved to the Train tab, so the tuple now has 3
elements. Callers must be updated.
"""
- initial_preset = "Recommended-CN (≈120M)"
+ initial_preset = DEFAULT_PRESET
initial_cfg = get_preset(initial_preset)
config_state = gr.State(dict(initial_cfg))
@@ -130,6 +161,8 @@ def build_config_tab():
load_btn = gr.Button(t("config.load_cfg"), variant="primary", scale=1)
register_translatable(load_btn, "config.load_cfg")
save_status = gr.Markdown("", visible=True)
+ # Auto-plan accordion; filled in below once the editor outputs exist.
+ planner_slot = gr.Column()
with gr.Row():
with gr.Column(scale=3):
@@ -210,6 +243,49 @@ def build_config_tab():
register_translatable(use_hybrid_attn, "config.hybrid_attn")
register_translatable(storage_format, "config.storage_format")
+ gr.Markdown(f"### {t('config.recurrent_section')}")
+ with gr.Row():
+ recurrent_enabled = gr.Checkbox(value=bool(D.get("recurrent_enabled", True)), label=t("config.recurrent_enabled"))
+ recurrent_groups = gr.Slider(1, 128, value=int(D.get("recurrent_groups", 2)), step=1, label=t("config.recurrent_groups"))
+ recurrent_depth = gr.Slider(1, 16, value=int(D.get("recurrent_depth", 4)), step=1, label=t("config.recurrent_depth"))
+ min_recurrent_depth = gr.Slider(1, 16, value=int(D.get("min_recurrent_depth", 2)), step=1, label=t("config.min_recurrent_depth"))
+ adaptive_halting = gr.Checkbox(value=bool(D.get("adaptive_halting", True)), label=t("config.adaptive_halting"))
+ with gr.Row():
+ halting_threshold = gr.Slider(0.5, 1.0, value=float(D.get("halting_threshold", .99)), step=.001, label=t("config.halting_threshold"))
+ max_inference_depth = gr.Slider(1, 32, value=int(D.get("max_inference_depth", D.get("recurrent_depth", 4))), step=1, label=t("config.max_inference_depth"))
+ ponder_loss_coef = gr.Number(value=float(D.get("ponder_loss_coef", .001)), label=t("config.ponder_loss_coef"), precision=6)
+ recurrent_distill_coef = gr.Number(value=float(D.get("recurrent_distill_coef", .05)), label=t("config.recurrent_distill_coef"), precision=6)
+ recurrent_stability_coef = gr.Number(value=float(D.get("recurrent_stability_coef", .001)), label=t("config.recurrent_stability_coef"), precision=6)
+ recurrent_latent_refinement = gr.Checkbox(
+ value=bool(D.get("recurrent_latent_refinement", True)),
+ label=t("config.recurrent_latent_refinement"),
+ )
+ depth_schedule = gr.Dropdown(
+ choices=["fixed_then_adaptive", "fixed", "adaptive"],
+ value=str(D.get("depth_schedule", "fixed_then_adaptive")), label=t("config.depth_schedule"),
+ )
+ recurrent_strategy = gr.Dropdown(
+ choices=["fixed", "scheduled", "adaptive"],
+ value=str(D.get("recurrent_strategy", "scheduled")),
+ label="recurrent strategy",
+ )
+ recurrent_depth_jitter = gr.Slider(
+ 0.0, 1.0, value=float(D.get("recurrent_depth_jitter", 0.0)),
+ step=.05, label="depth jitter",
+ )
+ with gr.Row():
+ depth_schedule_warmup_fraction = gr.Slider(
+ 0.0, 1.0, value=float(D.get("depth_schedule_warmup_fraction", .25)), step=.01,
+ label=t("config.depth_schedule_warmup_fraction"),
+ )
+ layer_aware_cache = gr.Checkbox(value=bool(D.get("layer_aware_cache", True)), label=t("config.layer_aware_cache"))
+ prefetch_depth = gr.Slider(0, 64, value=int(D.get("prefetch_depth", 2)), step=1, label=t("config.prefetch_depth"))
+ training_paging = gr.Checkbox(value=bool(D.get("training_paging", False)), label=t("config.training_paging"))
+ optimizer_paging = gr.Checkbox(value=bool(D.get("optimizer_paging", False)), label=t("config.optimizer_paging"))
+ page_size_mb = gr.Number(value=int(D.get("page_size_mb", 64)), precision=0, label=t("config.page_size_mb"))
+ optimizer_page_dir = gr.Textbox(value=str(D.get("optimizer_page_dir", "./optimizer_pages")), label=t("config.optimizer_page_dir"))
+ modalities = gr.Textbox(value=",".join(D.get("modalities", [])), label=t("config.modalities"))
+
gr.Markdown(f"### {t('config.train')}")
with gr.Row():
learning_rate = gr.Number(value=D["learning_rate"], label=t("config.lr"), precision=6)
@@ -297,13 +373,19 @@ def build_config_tab():
gr.Markdown(f"### 🧬 {t('designer.title')}")
total_box = gr.Textbox(label=t("designer.total"), interactive=False)
active_box = gr.Textbox(label=t("designer.active"), interactive=False)
+ compute_box = gr.Textbox(label=t("designer.compute"), interactive=False)
vram_box = gr.Textbox(label=t("designer.vram"), interactive=False)
+ ram_box = gr.Textbox(label=t("designer.ram"), interactive=False)
ssd_box = gr.Textbox(label=t("designer.ssd"), interactive=False)
kv_box = gr.Textbox(label=t("designer.kv"), interactive=False)
tps_box = gr.Textbox(label=t("designer.tps"), interactive=False)
+ gr.Markdown(t("designer.notes"))
+ detail_box = gr.JSON(label=t("designer.details"))
register_translatable(total_box, "designer.total")
register_translatable(active_box, "designer.active")
+ register_translatable(compute_box, "designer.compute")
register_translatable(vram_box, "designer.vram")
+ register_translatable(ram_box, "designer.ram")
register_translatable(ssd_box, "designer.ssd")
register_translatable(kv_box, "designer.kv")
register_translatable(tps_box, "designer.tps")
@@ -311,111 +393,139 @@ def build_config_tab():
# IMPORTANT: this list also defines the order Autotune uses to push
# best-params back via gr.update(). Don't reorder without updating
# ui/tabs/autotune_tab.py.
- all_inputs = [
- hidden_size, num_layers, num_experts, experts_per_tok,
- num_shared, lookahead, kv_latent_dim, sliding_window,
- num_heads, num_kv_heads, rope_dim, moe_intermediate,
- vocab_size, dtype, tie_word_embeddings,
- lambda_balance, lambda_temporal, lambda_lookahead, lambda_lookahead_topk, lambda_router_anchor,
- fallback_mask_prob,
- vram_budget, pinned_frac, use_hybrid_attn, storage_format,
- learning_rate, batch_size, accum_steps, max_seq_len,
- epochs, max_steps, save_interval, log_interval, save_dir,
- weight_decay, grad_clip,
- cpu_threads, cpu_budget_percent, num_workers,
- reward_spec, max_gen_len, num_generations, temperature,
- beta, alpha, lambda_or,
- ]
- estimator_outs = [total_box, active_box, vram_box, ssd_box, kv_box, tps_box]
-
- def update_config(*vals):
- (hs, nl, ne, ept, ns, la, kv, sw,
- nh, nkv, rd, moe_i,
- vs, dt, tie,
- lb, lt, ll, llt, lra, fmp,
- vb, pf, ha, sf,
- lr, bs, ac, msl, ep, max_st, si, li, sd,
- wd, gc, ct, cbp, nw,
- reward, mgl, ng, temp, beta, alpha, lor) = vals
- cfg = {
- "hidden_size": int(hs), "num_hidden_layers": int(nl),
- "num_experts": int(ne), "num_experts_per_tok": int(ept),
- "num_shared_experts": int(ns), "lookahead_steps": int(la),
- "kv_latent_dim": int(kv), "sliding_window_size": int(sw),
- "num_attention_heads": int(nh), "num_key_value_heads": int(nkv),
- "rope_dim": int(rd), "moe_intermediate_size": int(moe_i),
- "vocab_size": int(vs), "dtype": _normalize_dtype_name(str(dt)),
- "tie_word_embeddings": bool(tie),
- "lambda_balance": float(lb), "lambda_temporal": float(lt),
- "lambda_lookahead": float(ll), "lambda_lookahead_topk": float(llt),
- "lambda_router_anchor": float(lra),
- "fallback_mask_prob": float(fmp),
- "vram_budget_gb": float(vb), "pinned_memory_max_fraction": float(pf),
- "use_hybrid_attention": bool(ha),
- "storage_format": str(sf),
- "learning_rate": float(lr), "batch_size": int(bs),
- "accumulation_steps": int(ac), "max_seq_len": int(msl),
- "epochs": int(ep), "max_steps": max(0, int(max_st or 0)),
- "save_interval": int(si),
- "weight_decay": float(wd),
- "grad_clip": float(gc),
- "log_interval": int(li),
- "reward_spec": str(reward or "toy"),
- "max_gen_len": int(mgl),
- "num_generations": int(ng),
- "temperature": float(temp),
- "beta": float(beta),
- "alpha": float(alpha),
- "lambda_or": float(lor),
- "save_dir": sd,
- "cpu_threads": str(ct or "auto"),
- "cpu_budget_percent": float(cbp or 100),
- "num_workers": str(nw or "auto"),
- }
- est = _estimate(cfg)
- return (cfg, cfg) + est
+ config_components = {
+ "hidden_size": hidden_size,
+ "num_hidden_layers": num_layers,
+ "num_experts": num_experts,
+ "num_experts_per_tok": experts_per_tok,
+ "num_shared_experts": num_shared,
+ "lookahead_steps": lookahead,
+ "kv_latent_dim": kv_latent_dim,
+ "sliding_window_size": sliding_window,
+ "num_attention_heads": num_heads,
+ "num_key_value_heads": num_kv_heads,
+ "rope_dim": rope_dim,
+ "moe_intermediate_size": moe_intermediate,
+ "vocab_size": vocab_size,
+ "dtype": dtype,
+ "tie_word_embeddings": tie_word_embeddings,
+ "lambda_balance": lambda_balance,
+ "lambda_temporal": lambda_temporal,
+ "lambda_lookahead": lambda_lookahead,
+ "lambda_lookahead_topk": lambda_lookahead_topk,
+ "lambda_router_anchor": lambda_router_anchor,
+ "fallback_mask_prob": fallback_mask_prob,
+ "vram_budget_gb": vram_budget,
+ "pinned_memory_max_fraction": pinned_frac,
+ "use_hybrid_attention": use_hybrid_attn,
+ "storage_format": storage_format,
+ "learning_rate": learning_rate,
+ "batch_size": batch_size,
+ "accumulation_steps": accum_steps,
+ "max_seq_len": max_seq_len,
+ "epochs": epochs,
+ "max_steps": max_steps,
+ "save_interval": save_interval,
+ "log_interval": log_interval,
+ "save_dir": save_dir,
+ "weight_decay": weight_decay,
+ "grad_clip": grad_clip,
+ "cpu_threads": cpu_threads,
+ "cpu_budget_percent": cpu_budget_percent,
+ "num_workers": num_workers,
+ "reward_spec": reward_spec,
+ "max_gen_len": max_gen_len,
+ "num_generations": num_generations,
+ "temperature": temperature,
+ "beta": beta,
+ "alpha": alpha,
+ "lambda_or": lambda_or,
+ "recurrent_enabled": recurrent_enabled,
+ "recurrent_groups": recurrent_groups,
+ "recurrent_depth": recurrent_depth,
+ "min_recurrent_depth": min_recurrent_depth,
+ "adaptive_halting": adaptive_halting,
+ "halting_threshold": halting_threshold,
+ "max_inference_depth": max_inference_depth,
+ "ponder_loss_coef": ponder_loss_coef,
+ "recurrent_distill_coef": recurrent_distill_coef,
+ "recurrent_stability_coef": recurrent_stability_coef,
+ "recurrent_latent_refinement": recurrent_latent_refinement,
+ "depth_schedule": depth_schedule,
+ "recurrent_strategy": recurrent_strategy,
+ "recurrent_depth_jitter": recurrent_depth_jitter,
+ "depth_schedule_warmup_fraction": depth_schedule_warmup_fraction,
+ "layer_aware_cache": layer_aware_cache,
+ "prefetch_depth": prefetch_depth,
+ "training_paging": training_paging,
+ "optimizer_paging": optimizer_paging,
+ "page_size_mb": page_size_mb,
+ "optimizer_page_dir": optimizer_page_dir,
+ "modalities": modalities,
+ }
+ if set(config_components) != set(CONFIG_INPUT_ORDER):
+ raise RuntimeError("Config component registry differs from CONFIG_INPUT_ORDER")
+ all_inputs = [config_components[key] for key in CONFIG_INPUT_ORDER]
+ estimator_outs = [total_box, active_box, compute_box, vram_box, ram_box, ssd_box, kv_box, tps_box, detail_box]
+
+ def estimator_values(cfg):
+ try:
+ return list(_estimate(cfg))
+ except (ValueError, TypeError, ZeroDivisionError) as exc:
+ return ["—"] * 8 + [{"configuration_error": str(exc)}]
+
+ def update_config(previous, *values):
+ cfg = merge_config_inputs(previous, values)
+ return [cfg, cfg] + estimator_values(cfg)
+ # User input only: preset/load updates must not trigger 68 competing
+ # change handlers that overwrite each other's state.
for inp in all_inputs:
- inp.change(
- fn=update_config,
- inputs=all_inputs,
- outputs=[config_state, config_display] + estimator_outs,
- )
+ inp.input(fn=update_config, inputs=[config_state] + all_inputs,
+ outputs=[config_state, config_display] + estimator_outs)
+
+ # External producers (autotune) update the state as well as widgets.
+ config_state.change(fn=lambda cfg: [cfg] + estimator_values(cfg),
+ inputs=[config_state], outputs=[config_display] + estimator_outs)
+
+ config_outputs = [config_state, config_display] + all_inputs + estimator_outs
+
+ def config_result(cfg):
+ return [dict(cfg), dict(cfg)] + [gr.update(value=v) for v in values_in_input_order(cfg)] + estimator_values(cfg)
- # ── Preset / Save / Load wiring ───────────────────────
def apply_preset(name):
- cfg = get_preset(name)
- return [dict(cfg), dict(cfg)] + [gr.update(value=v) for v in values_in_input_order(cfg)]
+ return config_result(get_preset(name))
def reset_minimind():
- cfg = get_preset("MiniMind-MoE (default)")
- return [dict(cfg), dict(cfg)] + [gr.update(value=v) for v in values_in_input_order(cfg)]
+ return config_result(get_preset("MiniMind-MoE (default)"))
- def do_save(cfg, path):
+ def do_save(previous, path, *values):
try:
- abs_path = save_config(cfg or {}, path)
+ # Read the widgets at click time; a pending input event must
+ # not cause Save to write a stale or default configuration.
+ cfg = merge_config_inputs(previous, values)
+ abs_path = save_config(cfg, path or "./out/chronos_config.json")
return f"✅ Saved to `{abs_path}`"
- except Exception as e:
- return f"❌ Save failed: {e}"
+ except Exception as exc:
+ return f"❌ Save failed: {exc}"
def do_load(path):
try:
cfg = load_config(path)
- vals = values_in_input_order(cfg)
- return [f"✅ Loaded from `{path}`", dict(cfg), dict(cfg)] + [gr.update(value=v) for v in vals]
- except FileNotFoundError:
- return [f"❌ Not found: `{path}`", gr.update(), gr.update()] + [gr.update() for _ in all_inputs]
- except Exception as e:
- return [f"❌ Load failed: {e}", gr.update(), gr.update()] + [gr.update() for _ in all_inputs]
-
- apply_preset_btn.click(fn=apply_preset, inputs=[preset_dd], outputs=[config_state, config_display] + all_inputs)
- # Selecting a preset in the dropdown should sync immediately;
- # the explicit "Load Preset" button stays as a re-apply affordance.
- preset_dd.change(fn=apply_preset, inputs=[preset_dd], outputs=[config_state, config_display] + all_inputs)
- reset_btn.click(fn=reset_minimind, outputs=[config_state, config_display] + all_inputs)
- save_btn.click(fn=do_save, inputs=[config_state, cfg_path], outputs=[save_status])
- load_btn.click(fn=do_load, inputs=[cfg_path], outputs=[save_status, config_state, config_display] + all_inputs)
-
- total_box.value, active_box.value, vram_box.value, ssd_box.value, kv_box.value, tps_box.value = _estimate(initial_cfg)
+ return [f"✅ Loaded from `{path}`"] + config_result(cfg)
+ except Exception as exc:
+ return [f"❌ Load failed: {exc}"] + [gr.update() for _ in config_outputs]
+
+ with planner_slot:
+ build_planner_panel(config_state, config_result, config_outputs)
+
+ apply_preset_btn.click(fn=apply_preset, inputs=[preset_dd], outputs=config_outputs)
+ preset_dd.input(fn=apply_preset, inputs=[preset_dd], outputs=config_outputs)
+ reset_btn.click(fn=reset_minimind, outputs=config_outputs)
+ save_btn.click(fn=do_save, inputs=[config_state, cfg_path] + all_inputs, outputs=[save_status])
+ load_btn.click(fn=do_load, inputs=[cfg_path], outputs=[save_status] + config_outputs)
+
+ for box, value in zip(estimator_outs, estimator_values(initial_cfg)):
+ box.value = value
return config_state, all_inputs, save_dir
diff --git a/ui/tabs/data_tab.py b/ui/tabs/data_tab.py
index 0311ef6..222600c 100644
--- a/ui/tabs/data_tab.py
+++ b/ui/tabs/data_tab.py
@@ -1,18 +1,20 @@
"""
-ui/tabs/data_tab.py — Dataset inspector.
+ui/tabs/data_tab.py — Dataset inspector for any task.
-Streams a JSONL file (no full-load) and reports:
- - record count
+Streams a file, a directory or a glob (jsonl / json / parquet / csv / tsv /
+txt; never a full load) and reports, without assuming what the data is for:
+ - record count and the record schemas found (chat, instruction, prompt /
+ response, preference, parallel pair, plain text, other), with examples
+ - the field inventory (dotted paths, types, how often each is present)
- char-length distribution (min / mean / median / p95 / max)
- - tokenized-length distribution if a tokenizer is available
- - unique-record ratio (cheap dedup heuristic via line hash)
- - first ~3 sample previews
- - matplotlib histogram of token lengths
+ - script mix of the text (Han, Latin, Kana, Hangul, Cyrillic …)
+ - unique-record ratio (cheap dedup heuristic via record hash)
+ - media modalities (image / audio) and the first few previews
+ - a histogram of char lengths
The point: before training, the user can verify the corpus is what they
-expect — right field name, sane lengths, low duplication. Catches
-"wrong tokenizer" / "all records empty" / "huge dup ratio" before they
-burn 10 hours of training.
+expect — right fields, sane lengths, low duplication — whatever the model
+is meant to do.
"""
from __future__ import annotations
@@ -20,71 +22,138 @@
import json
import os
import statistics
+from collections import Counter
from ui.gradio_compat import gr
import chronos.deps # noqa: F401
from ui.i18n import t, register_translatable
-
-def _extract_text(rec: dict) -> str:
- for k in ("text", "content", "story", "passage", "document", "article"):
- if k in rec and rec[k]:
- return str(rec[k])
- if "conversations" in rec and isinstance(rec["conversations"], list):
- return " ".join(str(c.get("content", c.get("value", ""))) for c in rec["conversations"])
- if "messages" in rec and isinstance(rec["messages"], list):
- return " ".join(str(m.get("content", "")) for m in rec["messages"])
- return " ".join(str(v) for v in rec.values()
- if isinstance(v, (str, int, float)))
+_CHAT_KEYS = ("conversations", "messages", "dialog", "dialogue")
+_PROMPT_KEYS = ("prompt", "question", "query", "input", "instruction")
+_ANSWER_KEYS = ("response", "answer", "completion", "output", "target", "chosen")
+
+
+def record_schema(rec) -> str:
+ """Coarse shape of one record; never task-specific beyond the field names."""
+ if isinstance(rec, str):
+ return "plain_text"
+ if not isinstance(rec, dict):
+ return "other"
+ if any(isinstance(rec.get(k), list) for k in _CHAT_KEYS):
+ return "chat"
+ if "chosen" in rec and "rejected" in rec:
+ return "preference"
+ if "instruction" in rec and any(k in rec for k in ("output", "response", "answer")):
+ return "instruction"
+ if any(k in rec for k in _PROMPT_KEYS) and any(k in rec for k in _ANSWER_KEYS):
+ return "prompt_response"
+ nested = [v for v in rec.values() if isinstance(v, dict)
+ and len(v) >= 2 and all(isinstance(x, str) for x in v.values())]
+ short = [k for k, v in rec.items() if isinstance(v, str) and len(k) <= 3]
+ if nested or len(short) >= 2:
+ return "parallel_pair"
+ if any(isinstance(rec.get(k), str) for k in ("text", "content", "document", "passage", "article", "story")):
+ return "plain_text"
+ return "other"
+
+
+def _extract_text(rec) -> str:
+ """All text a record carries, in field order (chat turns, pairs, any schema)."""
+ from chronos.tokenizer.extract import _texts_from_record
+ parts = list(_texts_from_record(rec, None))
+ if not parts and isinstance(rec, dict):
+ parts = [str(v) for v in rec.values() if isinstance(v, str)]
+ return " ".join(parts)
+
+
+def _fields(rec, prefix: str = "", out: dict | None = None, depth: int = 0) -> dict:
+ out = {} if out is None else out
+ if not isinstance(rec, dict) or depth > 2:
+ return out
+ for k, v in rec.items():
+ key = f"{prefix}.{k}" if prefix else str(k)
+ if isinstance(v, dict):
+ _fields(v, key, out, depth + 1)
+ else:
+ out[key] = type(v).__name__ if v is not None else "null"
+ return out
def _scan(path: str, max_records: int = 50_000):
- """Stream up to `max_records` lines; return summary dict + length list."""
- if not path or not os.path.exists(path):
- return {"error": f"file not found: {path!r}"}, [], []
- char_lens = []
- samples = []
- seen_hashes = set()
- dup = 0
- n_total = 0
- with open(path, "rb") as f:
- for i, line in enumerate(f):
- if i >= max_records:
- break
- line = line.strip()
- if not line:
- continue
- n_total += 1
- try:
- rec = json.loads(line)
- except json.JSONDecodeError:
- continue
- text = _extract_text(rec)
- char_lens.append(len(text))
- h = hashlib.md5(line).hexdigest()
- if h in seen_hashes:
- dup += 1
- else:
- seen_hashes.add(h)
- if len(samples) < 3:
- samples.append(text[:200])
+ """Stream up to ``max_records`` records; return summary dict + length list + previews."""
+ from chronos.tokenizer.extract import expand, iter_records, script_bucket
+ if not path or not path.strip():
+ return {"error": "no path given"}, [], []
+ path = os.path.expanduser(path.strip())
+ files = [path] if os.path.isfile(path) else expand([path])
+ if not files:
+ return {"error": f"no readable data files at: {path!r} (jsonl, json, parquet, csv, tsv, txt)"}, [], []
+ char_lens, samples = [], []
+ schemas, scripts, field_hits, field_types = Counter(), Counter(), Counter(), {}
+ examples: dict[str, str] = {}
+ seen, dup, n_total, errors = set(), 0, 0, []
+ modal = Counter()
+ try:
+ from chronos.data.flexible_dataset import detect_modalities
+ except Exception: # pragma: no cover - torch-free environments
+ detect_modalities = None
+ for file in files:
+ if n_total >= max_records:
+ break
+ try:
+ for rec in iter_records(file):
+ if n_total >= max_records:
+ break
+ n_total += 1
+ kind = record_schema(rec)
+ schemas[kind] += 1
+ for key, typ in _fields(rec).items():
+ field_hits[key] += 1
+ field_types.setdefault(key, typ)
+ if detect_modalities is not None and isinstance(rec, dict):
+ for mod in detect_modalities(rec):
+ modal[mod] += 1
+ text = _extract_text(rec)
+ char_lens.append(len(text))
+ if text:
+ scripts[script_bucket(text[:400])] += 1
+ digest = hashlib.md5(json.dumps(rec, sort_keys=True, ensure_ascii=False, default=str)
+ .encode("utf-8")).hexdigest()
+ if digest in seen:
+ dup += 1
+ else:
+ seen.add(digest)
+ if kind not in examples:
+ examples[kind] = json.dumps(rec, ensure_ascii=False, default=str)[:300]
+ if len(samples) < 3:
+ samples.append(text[:200])
+ except Exception as exc: # unreadable file: report it, keep scanning the rest
+ errors.append(f"{os.path.basename(file)}: {type(exc).__name__}: {exc}"[:200])
if not char_lens:
- return {"error": "no parseable records"}, [], samples
-
+ return {"error": "no parseable records", "files": len(files), "read_errors": errors}, [], samples
+ ordered = sorted(char_lens)
summary = {
+ "files": len(files),
"scanned": n_total,
- "parseable": len(char_lens),
"dup_ratio": round(dup / max(n_total, 1), 4),
- "char_min": min(char_lens),
+ "schemas": dict(schemas.most_common()),
+ "schema_examples": examples,
+ "fields": {k: {"type": field_types[k], "present": round(c / n_total, 3)}
+ for k, c in field_hits.most_common(24)},
+ "scripts": {k: round(c / max(1, sum(scripts.values())), 3) for k, c in scripts.most_common()},
+ "char_min": ordered[0],
"char_mean": round(statistics.mean(char_lens), 1),
"char_median": int(statistics.median(char_lens)),
- "char_p95": int(sorted(char_lens)[int(0.95 * len(char_lens))]),
- "char_max": max(char_lens),
- "file_size_mb": round(os.path.getsize(path) / (1024 * 1024), 1),
- "note": (f"sampled first {max_records} records"
- if n_total >= max_records else "full scan"),
+ "char_p95": int(ordered[int(0.95 * (len(ordered) - 1))]),
+ "char_max": ordered[-1],
+ "size_mb": round(sum(os.path.getsize(f) for f in files) / (1024 * 1024), 1),
+ "note": (f"sampled first {max_records} records" if n_total >= max_records else "full scan"),
}
+ if modal:
+ summary["modalities"] = dict(modal)
+ if errors:
+ summary["read_errors"] = errors[:8]
return summary, char_lens, samples
@@ -103,7 +172,7 @@ def _hist(lens):
fig.patch.set_facecolor("#1a1a2e")
ax.set_facecolor("#16213e")
ax.hist(lens, bins=40, color="#39d98a", edgecolor="#1a1a2e")
- ax.set_xlabel("chars per record", color="#aaa")
+ ax.set_xlabel("chars per record (all text fields)", color="#aaa")
ax.set_ylabel("frequency", color="#aaa")
ax.tick_params(colors="#aaa")
for s in ax.spines.values():
@@ -119,7 +188,7 @@ def build_data_tab():
with gr.Row():
data_path = gr.Textbox(
label=t("data.path"), scale=4,
- placeholder="/Users/Fona/Downloads/Hybrid_LLM/Dataset/pretrain_t2t_mini.jsonl",
+ placeholder="./dataset/train.jsonl | ./data/ | ./data/*.parquet",
)
scan_btn = gr.Button(t("data.scan"), variant="primary", scale=1)
register_translatable(data_path, "data.path")
diff --git a/ui/tabs/export_tab.py b/ui/tabs/export_tab.py
index 49995f3..49502b9 100644
--- a/ui/tabs/export_tab.py
+++ b/ui/tabs/export_tab.py
@@ -59,6 +59,17 @@ def _export(model_path: str, output_dir: str, formats, config_path: str,
"tensors": r.tensors,
"expert_cache": _result_payload(r).get("expert_cache"),
"cluster_build": _result_payload(r).get("cluster_build"),
+ "preserves_recurrent_depth": r.format != "ollama-compatible-gguf",
+ "preserves_lazy_expert_paging": r.format != "ollama-compatible-gguf",
+ "stock_ollama_executable": bool(
+ _result_payload(r).get("compatibility_notice", {}).get(
+ "stock_runtime_executable", False
+ )
+ ),
+ "requires_chronos_backend": True,
+ "multimodal_encoders_embedded": bool(
+ _result_payload(r).get("chronos", {}).get("modalities")
+ ),
}
for r in results
}
@@ -66,7 +77,9 @@ def _export(model_path: str, output_dir: str, formats, config_path: str,
"\n\nCompatibility note: safetensors and GGUF outputs are standard containers "
"with Chronos architecture metadata. Stock Ollama/llama.cpp builds need a "
"Chronos architecture adapter to execute hybrid attention, lookahead MoE "
- "prediction, and lazy expert loading correctly."
+ "prediction, and lazy expert loading correctly. The current "
+ "ollama-compatible artifact is explicitly manifest-only and is not "
+ "reported as stock-runtime executable."
)
return format_export_report(results) + compatibility, summary
except Exception as exc:
diff --git a/ui/tabs/inference_tab.py b/ui/tabs/inference_tab.py
index c28c985..5727d87 100644
--- a/ui/tabs/inference_tab.py
+++ b/ui/tabs/inference_tab.py
@@ -1,8 +1,9 @@
"""
ui/tabs/inference_tab.py — Real-time generation with tokens/s display
"""
-import time
import math
+import os
+import time
from ui.gradio_compat import gr
import pandas as pd
@@ -42,10 +43,18 @@
("cache_hit_rate", "Cache hit rate", "ratio"),
("resident_hit_rate", "Resident hit rate", "ratio"),
("prediction_hit_rate", "Prediction hit rate", "ratio"),
+ ("prediction_topk_recall", "Prediction top-k recall", "ratio"),
+ ("resident_vram_hit_rate", "Resident VRAM hit", "ratio"),
+ ("ram_promotion_hit_rate", "RAM promotion hit", "ratio"),
+ ("ssd_cold_miss_rate", "SSD cold miss", "ratio"),
("cold_miss_predict_hit_rate", "Cold miss predict hit", "ratio"),
("fallback_weight_rate", "Fallback weight", "ratio"),
("on_demand_loads", "On-demand loads", "count"),
("on_demand_load_time_s", "On-demand load time", "s"),
+ ("average_ponder_steps", "Average recurrent depth", "steps"),
+ ("early_exit_ratio", "Recurrent early exit", "ratio"),
+ ("recurrent_contraction", "Recurrent contraction", "ratio"),
+ ("cluster_recall", "Cluster recall", "ratio"),
)
INFERENCE_SWEEP_CHART_METRICS = (
("tokens_per_sec", "Decode speed", "tokens/s"),
@@ -248,6 +257,15 @@ def _apply_offload_expert_budget(engine, model_cfg, ram_load_ratio: float | str
store = getattr(cache_manager, "expert_store", None)
if store is None:
return budget
+ # The hard budget (max_resident_experts / ram_budget_mb) always wins
+ # over the ratio-derived working set.
+ hard_vram = getattr(store, "max_resident_pages", None)
+ hard_ram = getattr(store, "ram_budget_pages", None)
+ if hard_vram is not None:
+ vram_cap = max(1, min(vram_cap, int(hard_vram)))
+ if hard_ram is not None:
+ ram_cap = max(1, min(ram_cap, int(hard_ram)))
+ budget["effective_vram_expert_budget"], budget["effective_ram_expert_budget"] = vram_cap, ram_cap
store.vram_capacity = vram_cap
store.ram_capacity = ram_cap
store.vram_lru.capacity = vram_cap
@@ -263,8 +281,8 @@ def _replace_torch_experts_with_placeholders(model, model_cfg) -> None:
constructor's random experts alive until model.to(mps/cuda) defeats the
SSD+DRAM lazy-load design by moving full dummy experts onto the device.
"""
- import torch
from chronos.model.moe_chronos import ChronosMOEFeedForward, LazyExpertPlaceholder
+ from chronos.precision import torch_model_dtype
layers = getattr(getattr(model, "model", None), "layers", [])
hidden = int(getattr(model_cfg, "hidden_size", 0) or 0)
@@ -276,7 +294,9 @@ def _replace_torch_experts_with_placeholders(model, model_cfg) -> None:
if not isinstance(moe, ChronosMOEFeedForward):
continue
for idx in range(len(moe.experts)):
- moe.experts[idx] = LazyExpertPlaceholder(hidden, intermediate, torch.float16)
+ # Placeholder dtype follows the trunk; place_torch_model() updates
+ # it again when the model is moved to its compute dtype.
+ moe.experts[idx] = LazyExpertPlaceholder(hidden, intermediate, torch_model_dtype(model))
def _replace_mlx_experts_with_placeholders(model) -> None:
@@ -297,14 +317,22 @@ def _replace_mlx_experts_with_placeholders(model) -> None:
pass
+def _store_torch_dtype(store):
+ """Torch dtype of the MLX store's pages (= the MLX trunk compute dtype)."""
+ import torch
+ name = str(getattr(store, "page_dtype", "float32")).rsplit(".", 1)[-1]
+ return getattr(torch, name)
+
+
def _populate_mlx_cache_from_state_dict(store, state_dict: dict) -> None:
import os
import json
import torch
- from safetensors.numpy import save_file as np_save_file
+ from safetensors.torch import save_file as np_save_file # BF16-capable
from chronos.io.storage import ClusterManifest, KEY_SEP, MANIFEST_FILENAME
os.makedirs(store.ssd_dir, exist_ok=True)
+ page_dtype = _store_torch_dtype(store)
manifest_clusters = {}
expert_to_cluster = {}
for eid in range(int(store.num_experts)):
@@ -315,7 +343,7 @@ def _populate_mlx_cache_from_state_dict(store, state_dict: dict) -> None:
key = prefix + pname
if key in state_dict:
tensors[f"l{li}_e{eid}{KEY_SEP}{pname}"] = (
- state_dict[key].detach().to(dtype=torch.float16, device="cpu").numpy()
+ state_dict[key].detach().to(dtype=page_dtype, device="cpu").contiguous()
)
if tensors:
file_name = f"cluster_{eid}.ctsr"
@@ -342,10 +370,11 @@ def _populate_mlx_cache_from_reader(store, reader) -> None:
import os
import json
import torch
- from safetensors.numpy import save_file as np_save_file
+ from safetensors.torch import save_file as np_save_file # BF16-capable
from chronos.io.storage import ClusterManifest, KEY_SEP, MANIFEST_FILENAME
os.makedirs(store.ssd_dir, exist_ok=True)
+ page_dtype = _store_torch_dtype(store)
keys = set(reader.keys())
manifest_clusters = {}
expert_to_cluster = {}
@@ -357,7 +386,7 @@ def _populate_mlx_cache_from_reader(store, reader) -> None:
key = prefix + pname
if key in keys:
tensors[f"l{li}_e{eid}{KEY_SEP}{pname}"] = (
- reader.get_tensor(key).detach().to(dtype=torch.float16, device="cpu").numpy()
+ reader.get_tensor(key).detach().to(dtype=page_dtype, device="cpu").contiguous()
)
if tensors:
file_name = f"cluster_{eid}.ctsr"
@@ -511,7 +540,8 @@ def _run_torch_inference(backend: str, model_cfg, model_path_val: str, token_ids
ram_load_ratio: float | str | None = 1.0,
miss_policy: str = "on_demand",
cpu_threads: int | str | None = "auto",
- cpu_budget_percent: int | float | str | None = 100) -> tuple[list[int], dict]:
+ cpu_budget_percent: int | float | str | None = 100,
+ speculative: dict | None = None) -> tuple[list[int], dict]:
import os
import importlib
import gc
@@ -552,7 +582,9 @@ def _run_torch_inference(backend: str, model_cfg, model_path_val: str, token_ids
base_load_mem = _memory_snapshot_gb()
device_map = {"cuda": "cuda", "xpu": "xpu", "mps": "mps", "cpu": "cpu"}[backend]
- model = model.to(device_map).eval()
+ from chronos.precision import place_torch_model
+ model, precision = place_torch_model(model, device_map)
+ model = model.eval()
device_mem = _memory_snapshot_gb()
input_ids = torch.tensor([token_ids]).to(device_map)
attn = torch.tensor([attention_mask]).to(device_map)
@@ -604,15 +636,28 @@ def _run_torch_inference(backend: str, model_cfg, model_path_val: str, token_ids
setup_stats[f"{prefix}_setup_delta_gb"] = _memory_delta(backend_mem_setup, backend_mem0, key)
try:
- out = engine.generate(
- input_ids,
- attention_mask=attn,
- max_new_tokens=int(max_tok),
- temperature=float(temp),
- eos_token_id=eos_token_id,
- do_sample=do_sample,
- miss_policy=miss_policy,
- )
+ if speculative:
+ out = engine.generate_speculative(
+ input_ids,
+ max_new_tokens=int(max_tok),
+ temperature=float(temp),
+ top_p=0.85,
+ top_k=50,
+ do_sample=do_sample,
+ eos_token_id=eos_token_id,
+ miss_policy=miss_policy,
+ **speculative,
+ )
+ else:
+ out = engine.generate(
+ input_ids,
+ attention_mask=attn,
+ max_new_tokens=int(max_tok),
+ temperature=float(temp),
+ eos_token_id=eos_token_id,
+ do_sample=do_sample,
+ miss_policy=miss_policy,
+ )
stats = dict(getattr(engine, "last_stats", {}))
finally:
engine.teardown()
@@ -655,7 +700,8 @@ def _run_torch_full_dram(backend: str, model_cfg, model_path_val: str, token_ids
attention_mask: list[int], max_tok: int, temp: float,
eos_token_id: int | None, do_sample: bool = True,
cpu_threads: int | str | None = "auto",
- cpu_budget_percent: int | float | str | None = 100) -> tuple[list[int], dict]:
+ cpu_budget_percent: int | float | str | None = 100,
+ speculative: dict | None = None) -> tuple[list[int], dict]:
import os
import time as _time
import gc
@@ -684,7 +730,9 @@ def _run_torch_full_dram(backend: str, model_cfg, model_path_val: str, token_ids
del weights
gc.collect()
weight_load_mem = _memory_snapshot_gb()
- model = model.to(device_map).eval()
+ from chronos.precision import place_torch_model
+ model, precision = place_torch_model(model, device_map)
+ model = model.eval()
setup_mem1 = _memory_snapshot_gb()
backend_mem_setup = _backend_memory_snapshot(backend)
input_ids = torch.tensor([token_ids], device=device_map)
@@ -700,16 +748,46 @@ def _run_torch_full_dram(backend: str, model_cfg, model_path_val: str, token_ids
prefill_mem1 = prefill_mem0
backend_mem_prefill1 = backend_mem_prefill0
prefill_done_t = prefill_t0
+ spec_stats = None
+ if speculative:
+ from chronos.runtime.speculative import speculative_generate, speculative_supported
+ full_depth = int(getattr(model_cfg, "max_inference_depth", None) or model_cfg.recurrent_depth)
+ use_spec = speculative_supported(model_cfg) and int(speculative["draft_depth"]) < full_depth
+ first = {}
+
+ def _fwd(ids, cache, depth):
+ out = model(ids, past_key_values=cache, use_cache=True, recurrent_depth=depth)
+ if "t" not in first:
+ first["t"] = _time.monotonic()
+ return out
+ gen_out, spec = speculative_generate(
+ model, input_ids, max_new_tokens=int(max_tok),
+ draft_depth=int(speculative["draft_depth"]) if use_spec else full_depth,
+ gamma=int(speculative["gamma"]), gamma_max=int(speculative.get("gamma_max", 8)),
+ adaptive_gamma=bool(speculative.get("adaptive_gamma", True)), do_sample=do_sample,
+ temperature=max(float(temp), 1e-6), top_k=50, top_p=0.85, eos_token_id=eos_token_id,
+ forward_fn=_fwd)
+ input_ids = gen_out
+ tokens_generated = int(gen_out.shape[1] - prompt_len)
+ prefill_done_t = first.get("t", prefill_t0)
+ prefill_time_s = prefill_done_t - prefill_t0
+ prefill_mem1 = _memory_snapshot_gb()
+ backend_mem_prefill1 = _backend_memory_snapshot(backend)
+ spec_stats = spec.as_dict() | {"enabled": use_spec, "full_depth": full_depth,
+ "draft_depth": int(speculative["draft_depth"]) if use_spec else full_depth,
+ "gamma_final": spec.gamma_history[-1] if spec.gamma_history else int(speculative["gamma"])}
with torch.inference_mode():
- for step in range(int(max_tok)):
- past_len = past_key_values[0][0].shape[1] if past_key_values else 0
- outputs, _ = model(
+ for step in range(0 if speculative else int(max_tok)):
+ from chronos.model.model_chronos import cache_sequence_length
+ past_len = cache_sequence_length(past_key_values) if past_key_values else 0
+ result = model(
input_ids[:, past_len:],
attention_mask=attn,
past_key_values=past_key_values,
use_cache=True,
)
+ outputs = result
if attn is not None:
attn = torch.cat([attn, attn.new_ones(attn.shape[0], 1)], -1)
next_token = _next_token_from_logits(
@@ -777,6 +855,8 @@ def _run_torch_full_dram(backend: str, model_cfg, model_path_val: str, token_ids
"cache_misses": 0,
**cpu_thread_stats,
}
+ if spec_stats is not None:
+ stats["speculative"] = spec_stats
for snapshot, suffix in [
(backend_mem0, "before_setup"),
(backend_mem_setup, "after_setup"),
@@ -792,7 +872,9 @@ def _run_torch_full_dram(backend: str, model_cfg, model_path_val: str, token_ids
def _run_mlx_full_dram(model_cfg, model_path_val: str, token_ids: list[int],
- max_tok: int, temp: float, do_sample: bool = True) -> tuple[list[int], dict]:
+ max_tok: int, temp: float, do_sample: bool = True,
+ eos_token_id: int | None = None,
+ speculative: dict | None = None) -> tuple[list[int], dict]:
import os
import time as _time
import gc
@@ -822,7 +904,9 @@ def _run_mlx_full_dram(model_cfg, model_path_val: str, token_ids: list[int],
weights = {}
gc.collect()
weight_load_mem = _memory_snapshot_gb()
- mlx_model = ChronosMLXModel.from_chronos_pytorch(pt_model, model_cfg)
+ from chronos.precision import resolve_mlx
+ precision = resolve_mlx("auto")
+ mlx_model = ChronosMLXModel.from_chronos_pytorch(pt_model.eval(), model_cfg, dtype=precision.compute)
del pt_model
gc.collect()
setup_mem1 = _memory_snapshot_gb()
@@ -837,6 +921,9 @@ def _run_mlx_full_dram(model_cfg, model_path_val: str, token_ids: list[int],
max_new_tokens=int(max_tok),
temperature=float(temp) if do_sample else 0.0,
top_p=0.85,
+ eos_token_id=eos_token_id,
+ speculative=bool(speculative),
+ **(speculative or {}),
))
run_elapsed = _time.monotonic() - run_t0
end_mem = _memory_snapshot_gb()
@@ -887,7 +974,9 @@ def _run_mlx_full_dram(model_cfg, model_path_val: str, token_ids: list[int],
def _run_mlx_lazy_inference(model_cfg, model_path_val: str, token_ids: list[int],
max_tok: int, temp: float, do_sample: bool = True,
ram_load_ratio: float | str | None = 1.0,
- quality_safe: bool = True) -> tuple[list[int], dict]:
+ quality_safe: bool = True,
+ eos_token_id: int | None = None,
+ speculative: dict | None = None) -> tuple[list[int], dict]:
import os
import gc
import torch
@@ -925,7 +1014,10 @@ def _run_mlx_lazy_inference(model_cfg, model_path_val: str, token_ids: list[int]
)
_replace_torch_experts_with_placeholders(pt_model, model_cfg)
model_init_mem = _memory_snapshot_gb()
- mlx_model = ChronosMLXModel.from_chronos_pytorch(pt_model, model_cfg, include_experts=False)
+ from chronos.precision import resolve_mlx
+ precision = resolve_mlx("auto")
+ mlx_model = ChronosMLXModel.from_chronos_pytorch(pt_model.eval(), model_cfg, include_experts=False,
+ dtype=precision.compute)
_replace_mlx_experts_with_placeholders(mlx_model)
del pt_model
gc.collect()
@@ -956,6 +1048,9 @@ def _run_mlx_lazy_inference(model_cfg, model_path_val: str, token_ids: list[int]
max_new_tokens=int(max_tok),
temperature=float(temp) if do_sample else 0.0,
top_p=0.85,
+ eos_token_id=eos_token_id,
+ speculative=bool(speculative),
+ **(speculative or {}),
))
stats = dict(getattr(engine, "last_stats", {}))
store_stats = engine.store.stats()
@@ -1089,6 +1184,43 @@ def _normalize_miss_policy(value) -> str:
return policy if policy in MISS_POLICY_CHOICES else "on_demand"
+def _speculative_options(enabled, draft_depth=1, gamma=4) -> dict | None:
+ """UI controls → options for recurrent-depth self-speculative decoding.
+
+ ``None`` means plain decoding. Speculative decoding needs no extra
+ weights; greedy output is token-for-token identical to plain greedy and
+ sampled output keeps the full-depth distribution (rejection sampling).
+ """
+ if not bool(enabled):
+ return None
+ try:
+ depth = max(1, int(float(draft_depth)))
+ except (TypeError, ValueError):
+ depth = 1
+ try:
+ g = max(1, min(8, int(float(gamma))))
+ except (TypeError, ValueError):
+ g = 4
+ return {"draft_depth": depth, "gamma": g, "gamma_max": 8, "adaptive_gamma": True}
+
+
+def _speculative_row_fields(stats: dict) -> dict:
+ spec = (stats or {}).get("speculative") or {}
+ if not spec:
+ return {}
+ enabled = spec.get("enabled", int(spec.get("draft_calls", 0) or 0) > 0)
+ return {
+ "speculative": bool(enabled),
+ "spec_draft_depth": spec.get("draft_depth"),
+ "spec_full_depth": spec.get("full_depth"),
+ "spec_acceptance_rate": spec.get("acceptance_rate"),
+ "spec_tokens_per_verify": spec.get("tokens_per_verify"),
+ "spec_verify_calls": spec.get("verify_calls"),
+ "spec_draft_calls": spec.get("draft_calls"),
+ "spec_gamma_final": spec.get("gamma_final"),
+ }
+
+
def _format_inference_stats(rows: list[dict], diff: dict | None = None) -> str:
if not rows:
return "_No inference stats yet._"
@@ -1128,6 +1260,14 @@ def fmt_bool(row: dict, key: str) -> str:
("CPU threads", lambda r: fmt_int(r, "cpu_threads")),
("Tokens", lambda r: fmt_int(r, "tokens")),
("Tokens/s", lambda r: fmt_num(r, "tokens_per_sec", precision=2)),
+ ("Self-speculative", lambda r: fmt_bool(r, "speculative")),
+ ("Draft / full depth", lambda r: (f"{r.get('spec_draft_depth')} / {r.get('spec_full_depth')}"
+ if r.get("spec_draft_depth") is not None else "")),
+ ("Draft acceptance", lambda r: fmt_pct(r, "spec_acceptance_rate") if r.get("spec_acceptance_rate") is not None else ""),
+ ("Tokens per verify", lambda r: fmt_num(r, "spec_tokens_per_verify", precision=2)
+ if r.get("spec_tokens_per_verify") is not None else ""),
+ ("Verify / draft forwards", lambda r: (f"{r.get('spec_verify_calls')} / {r.get('spec_draft_calls')}"
+ if r.get("spec_verify_calls") is not None else "")),
("Response time", lambda r: fmt_num(r, "response_time_s", "s")),
("Prefill time", lambda r: fmt_num(r, "prefill_time_s", "s")),
("Decode time", lambda r: fmt_num(r, "decode_time_s", "s")),
@@ -1146,6 +1286,13 @@ def fmt_bool(row: dict, key: str) -> str:
("MLX decode", lambda r: fmt_num(r, "mlx_active_after_decode_gb", " GB")),
("Resident hit", lambda r: fmt_pct(r, "resident_hit_rate", "cache_hit_rate")),
("Predict hit", lambda r: fmt_pct(r, "prediction_hit_rate")),
+ ("Prediction top-k recall", lambda r: fmt_pct(r, "prediction_topk_recall")),
+ ("VRAM hit", lambda r: fmt_pct(r, "resident_vram_hit_rate")),
+ ("RAM promotion hit", lambda r: fmt_pct(r, "ram_promotion_hit_rate")),
+ ("SSD cold miss", lambda r: fmt_pct(r, "ssd_cold_miss_rate")),
+ ("Average recurrent depth", lambda r: fmt_num(r, "average_ponder_steps", precision=2)),
+ ("Early exit", lambda r: fmt_pct(r, "early_exit_ratio")),
+ ("Cluster recall", lambda r: fmt_pct(r, "cluster_recall")),
("Cold miss predict hit", lambda r: fmt_pct(r, "cold_miss_predict_hit_rate")),
("Fallback weight", lambda r: fmt_pct(r, "fallback_weight_rate")),
("On-demand loads", lambda r: fmt_int(r, "on_demand_loads")),
@@ -1261,6 +1408,14 @@ def _row_from_stats(mode: str, backend: str, tokens: list[int], elapsed: float,
"cache_hit_rate": stats.get("cache_hit_rate", 1.0 if mode == "full_dram" else 0),
"resident_hit_rate": stats.get("resident_hit_rate", stats.get("cache_hit_rate", 1.0 if mode == "full_dram" else 0)),
"prediction_hit_rate": stats.get("prediction_hit_rate", 0.0),
+ "prediction_topk_recall": stats.get("prediction_topk_recall", 0.0),
+ "resident_vram_hit_rate": stats.get("resident_vram_hit_rate", 0.0),
+ "ram_promotion_hit_rate": stats.get("ram_promotion_hit_rate", 0.0),
+ "ssd_cold_miss_rate": stats.get("ssd_cold_miss_rate", 0.0),
+ "average_ponder_steps": stats.get("average_ponder_steps", 1.0),
+ "early_exit_ratio": stats.get("early_exit_ratio", 0.0),
+ "recurrent_contraction": stats.get("recurrent_contraction", 0.0),
+ "cluster_recall": stats.get("cluster_recall", 0.0),
"prediction_hits": stats.get("prediction_hits", 0),
"prediction_total": stats.get("prediction_total", 0),
"cold_miss_predict_hit_rate": stats.get("cold_miss_predict_hit_rate", 0.0),
@@ -1367,54 +1522,11 @@ def _build_model_cfg(cfg, model_path_val):
source_text = "; ".join(cfg_sources) if cfg_sources else model_path_val
sniff_note = f"[checkpoint config: {summary} | source={source_text}]\n"
- model_cfg_kwargs = {
- "hidden_size": sniffed.get("hidden_size", cfg.get("hidden_size", 768)),
- "num_hidden_layers": sniffed.get("num_hidden_layers", cfg.get("num_hidden_layers", 8)),
- "num_experts": sniffed.get("num_experts", cfg.get("num_experts", 4)),
- "num_experts_per_tok": sniffed.get("num_experts_per_tok", cfg.get("num_experts_per_tok", 1)),
- "num_shared_experts": sniffed.get("num_shared_experts", cfg.get("num_shared_experts", 1)),
- "lookahead_steps": sniffed.get("lookahead_steps", cfg.get("lookahead_steps", 2)),
- "kv_latent_dim": sniffed.get("kv_latent_dim", cfg.get("kv_latent_dim", 64)),
- "sliding_window_size": sniffed.get("sliding_window_size", cfg.get("sliding_window_size", 2048)),
- "use_hybrid_attention": sniffed.get("use_hybrid_attention", cfg.get("use_hybrid_attention", True)),
- "vram_budget_gb": sniffed.get("vram_budget_gb", cfg.get("vram_budget_gb", 4.0)),
- "pinned_memory_max_fraction": sniffed.get("pinned_memory_max_fraction", cfg.get("pinned_memory_max_fraction", 0.25)),
- "storage_format": sniffed.get("storage_format", cfg.get("storage_format", "safetensors")),
- "offload_miss_policy_default": sniffed.get("offload_miss_policy_default", cfg.get("offload_miss_policy_default", "on_demand")),
- "recommended_resident_experts": sniffed.get("recommended_resident_experts", cfg.get("recommended_resident_experts", None)),
- "use_moe": True,
- }
- if "cluster_manifest_path" in sniffed:
- model_cfg_kwargs["cluster_manifest_path"] = sniffed["cluster_manifest_path"]
- if "rope_dim" in sniffed:
- model_cfg_kwargs["rope_dim"] = sniffed["rope_dim"]
- if "num_attention_heads" in sniffed:
- model_cfg_kwargs["num_attention_heads"] = sniffed["num_attention_heads"]
- if "num_key_value_heads" in sniffed:
- model_cfg_kwargs["num_key_value_heads"] = sniffed["num_key_value_heads"]
- if "vocab_size" in sniffed:
- model_cfg_kwargs["vocab_size"] = sniffed["vocab_size"]
- if "moe_intermediate_size" in sniffed:
- model_cfg_kwargs["intermediate_size"] = sniffed["moe_intermediate_size"]
- model_cfg_kwargs["moe_intermediate_size"] = sniffed["moe_intermediate_size"]
-
- for opt_key, cfg_key in [
- ("num_attention_heads", "num_attention_heads"),
- ("num_key_value_heads", "num_key_value_heads"),
- ("rope_dim", "rope_dim"),
- ("max_position_embeddings", "max_seq_len"),
- ("tie_word_embeddings", "tie_word_embeddings"),
- ]:
- if opt_key in {"num_attention_heads", "num_key_value_heads", "rope_dim"} \
- and opt_key in model_cfg_kwargs:
- continue
- if cfg_key not in cfg:
- continue
- val = cfg[cfg_key]
- if val in (None, "", 0):
- continue
- model_cfg_kwargs[opt_key] = val
-
+ from chronos.config_schema import model_config_kwargs
+ # Checkpoint metadata defines the trained graph. UI values fill missing
+ # fields for legacy checkpoints, including all new recurrent/media fields.
+ model_cfg_kwargs = model_config_kwargs({**cfg, **sniffed})
+ model_cfg_kwargs.setdefault("hidden_size", 768)
if "num_attention_heads_total_dim" in sniffed:
h = model_cfg_kwargs["hidden_size"]
n_heads = model_cfg_kwargs.get("num_attention_heads", cfg.get("num_attention_heads", 8))
@@ -1430,10 +1542,24 @@ def _build_model_cfg(cfg, model_path_val):
return ChronosConfig(**model_cfg_kwargs), sniffed, sniff_note
+def _inference_tokenizer_dir(cfg, model_path_val: str) -> str:
+ """The tokenizer that belongs to the checkpoint, not a fixed default.
+
+ Order: config ``tokenizer_path``; the checkpoint's bundled ``tokenizer/``
+ or the path in its sidecar; the minimind tokenizer only as the last resort
+ (older checkpoints that never recorded one).
+ """
+ from chronos.model.checkpoint import resolve_checkpoint_tokenizer
+ explicit = str((cfg or {}).get("tokenizer_path") or "").strip() or None
+ path = model_path_val if model_path_val and os.path.exists(model_path_val) else None
+ return resolve_checkpoint_tokenizer(path, explicit, fallback=True)
+
+
def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
prompt_val, max_tok, temp, raw_prompt_val,
ram_load_ratio=1.0, sweep_ram_load_ratios=False,
- miss_policy="on_demand") -> dict:
+ miss_policy="on_demand", speculative=False, draft_depth=1,
+ gamma=4) -> dict:
from transformers import AutoTokenizer
from chronos.backend.mac_diagnostics import mac_backend_diagnostics
@@ -1445,7 +1571,8 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
backend, backend_note = _resolve_inference_backend(selected_backend, model_path_val, sniffed)
cpu_threads_cfg = (cfg or {}).get("cpu_threads", "auto")
cpu_budget_cfg = (cfg or {}).get("cpu_budget_percent", 100)
- tokenizer = AutoTokenizer.from_pretrained(chronos.deps.get_tokenizer_path())
+ tokenizer = AutoTokenizer.from_pretrained(_inference_tokenizer_dir(cfg, model_path_val))
+ spec_opts = _speculative_options(speculative, draft_depth, gamma)
token_ids, attn_mask = _encode_prompt(tokenizer, prompt_val, bool(raw_prompt_val))
mode = _normalize_inference_mode(selected_mode)
@@ -1467,6 +1594,7 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
**_bounded_offload_expert_budget(model_cfg, ram_load_ratio),
},
"rows": rows,
+ "speculative": spec_opts or {"enabled": False},
}
ratio_values = [_normalize_ram_load_ratio(ram_load_ratio)]
@@ -1490,6 +1618,8 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
do_sample=(mode != "compare"),
ram_load_ratio=ratio,
quality_safe=(mode == "compare" and not bool(sweep_ram_load_ratios)),
+ eos_token_id=tokenizer.eos_token_id,
+ speculative=spec_opts,
)
else:
lazy_tokens, lazy_stats = _run_torch_inference(
@@ -1506,6 +1636,7 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
miss_policy=miss_policy,
cpu_threads=cpu_threads_cfg,
cpu_budget_percent=cpu_budget_cfg,
+ speculative=spec_opts,
)
lazy_elapsed = time.monotonic() - t0
lazy_mem = _memory_snapshot_gb()
@@ -1515,6 +1646,7 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
"lazy_offload", backend, lazy_tokens, lazy_elapsed,
lazy_mem - mem0, lazy_stats,
)
+ row.update(_speculative_row_fields(lazy_stats))
_attach_decode_diagnostics(row, lazy_diag)
if len(ratio_values) > 1:
row["mode"] = f"lazy_offload_{ratio:g}x"
@@ -1539,6 +1671,8 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
int(max_tok),
float(temp),
do_sample=(mode != "compare"),
+ eos_token_id=tokenizer.eos_token_id,
+ speculative=spec_opts,
)
else:
full_tokens, full_stats = _run_torch_full_dram(
@@ -1553,6 +1687,7 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
do_sample=(mode != "compare"),
cpu_threads=cpu_threads_cfg,
cpu_budget_percent=cpu_budget_cfg,
+ speculative=spec_opts,
)
full_elapsed = time.monotonic() - t1
full_mem = _memory_snapshot_gb()
@@ -1562,6 +1697,7 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
"full_dram", backend, full_tokens, full_elapsed,
full_mem - mem1, full_stats,
)
+ row.update(_speculative_row_fields(full_stats))
_attach_decode_diagnostics(row, full_diag)
rows.append(row)
outputs["full_dram"] = full_text
@@ -1595,12 +1731,12 @@ def _run_inference_modes(cfg, selected_backend, selected_mode, model_path_val,
def generate_api(cfg, selected_backend="auto", selected_mode="compare", model_path_val="",
prompt_val="", max_tok=128, temp=0.85, raw_prompt_val=False,
ram_load_ratio=1.0, sweep_ram_load_ratios=False,
- miss_policy="on_demand") -> dict:
+ miss_policy="on_demand", speculative=False, draft_depth=1, gamma=4) -> dict:
try:
return _run_inference_modes(
cfg, selected_backend, selected_mode, model_path_val, prompt_val,
max_tok, temp, raw_prompt_val, ram_load_ratio, sweep_ram_load_ratios,
- miss_policy,
+ miss_policy, speculative, draft_depth, gamma,
)
except Exception as e:
import traceback
@@ -1662,6 +1798,15 @@ def build_inference_tab(config_state: gr.State):
register_translatable(miss_policy, "infer.miss_policy")
register_translatable(ram_load_ratio, "infer.ram_load_ratio")
register_translatable(sweep_ratios, "infer.sweep_ram_load_ratios")
+ with gr.Row():
+ speculative = gr.Checkbox(value=False, label=t("infer.speculative"))
+ draft_depth = gr.Slider(1, 8, value=1, step=1, label=t("infer.draft_depth"))
+ spec_gamma = gr.Slider(1, 8, value=4, step=1, label=t("infer.spec_gamma"))
+ register_translatable(speculative, "infer.speculative")
+ register_translatable(draft_depth, "infer.draft_depth")
+ register_translatable(spec_gamma, "infer.spec_gamma")
+ spec_note = gr.Markdown(t("infer.spec_note"))
+ register_translatable(spec_note, "infer.spec_note")
gen_btn = gr.Button(t("infer.generate"), variant="primary")
register_translatable(gen_btn, "infer.generate")
@@ -1810,98 +1955,11 @@ def _sniff_checkpoint(path: str) -> dict:
except Exception:
return {}
- def _build_model_cfg(cfg, model_path_val):
- from chronos.model.config import ChronosConfig
- from chronos.model.checkpoint import config_dict_for_checkpoint, sniff_checkpoint_config
- from chronos.export import config_dict_from_export, is_export_artifact
-
- cfg = cfg or {}
- cfg_sources = []
- try:
- if model_path_val and is_export_artifact(model_path_val):
- ckpt_cfg = config_dict_from_export(model_path_val)
- cfg_sources = [model_path_val]
- else:
- ckpt_cfg, cfg_sources = config_dict_for_checkpoint(
- model_path_val if model_path_val else None,
- require_unsniffable=False,
- )
- except Exception:
- ckpt_cfg, cfg_sources = {}, []
- sniffed = ckpt_cfg or (sniff_checkpoint_config(model_path_val) if model_path_val else {})
- sniff_note = ""
- if sniffed:
- summary = ", ".join(
- f"{k}={v}" for k, v in sniffed.items()
- if k in ("vocab_size", "hidden_size", "num_hidden_layers",
- "num_experts", "moe_intermediate_size")
- )
- source_text = "; ".join(cfg_sources) if cfg_sources else model_path_val
- sniff_note = f"[checkpoint config: {summary} | source={source_text}]\n"
-
- model_cfg_kwargs = {
- "hidden_size": sniffed.get("hidden_size", cfg.get("hidden_size", 768)),
- "num_hidden_layers": sniffed.get("num_hidden_layers", cfg.get("num_hidden_layers", 8)),
- "num_experts": sniffed.get("num_experts", cfg.get("num_experts", 4)),
- "num_experts_per_tok": sniffed.get("num_experts_per_tok", cfg.get("num_experts_per_tok", 1)),
- "num_shared_experts": sniffed.get("num_shared_experts", cfg.get("num_shared_experts", 1)),
- "lookahead_steps": sniffed.get("lookahead_steps", cfg.get("lookahead_steps", 2)),
- "kv_latent_dim": sniffed.get("kv_latent_dim", cfg.get("kv_latent_dim", 64)),
- "sliding_window_size": sniffed.get("sliding_window_size", cfg.get("sliding_window_size", 2048)),
- "use_hybrid_attention": sniffed.get("use_hybrid_attention", cfg.get("use_hybrid_attention", True)),
- "vram_budget_gb": sniffed.get("vram_budget_gb", cfg.get("vram_budget_gb", 4.0)),
- "pinned_memory_max_fraction": sniffed.get("pinned_memory_max_fraction", cfg.get("pinned_memory_max_fraction", 0.25)),
- "storage_format": sniffed.get("storage_format", cfg.get("storage_format", "safetensors")),
- "use_moe": True,
- }
- if "cluster_manifest_path" in sniffed:
- model_cfg_kwargs["cluster_manifest_path"] = sniffed["cluster_manifest_path"]
- if "rope_dim" in sniffed:
- model_cfg_kwargs["rope_dim"] = sniffed["rope_dim"]
- if "num_attention_heads" in sniffed:
- model_cfg_kwargs["num_attention_heads"] = sniffed["num_attention_heads"]
- if "num_key_value_heads" in sniffed:
- model_cfg_kwargs["num_key_value_heads"] = sniffed["num_key_value_heads"]
- if "vocab_size" in sniffed:
- model_cfg_kwargs["vocab_size"] = sniffed["vocab_size"]
- if "moe_intermediate_size" in sniffed:
- model_cfg_kwargs["intermediate_size"] = sniffed["moe_intermediate_size"]
- model_cfg_kwargs["moe_intermediate_size"] = sniffed["moe_intermediate_size"]
-
- for opt_key, cfg_key in [
- ("num_attention_heads", "num_attention_heads"),
- ("num_key_value_heads", "num_key_value_heads"),
- ("rope_dim", "rope_dim"),
- ("max_position_embeddings", "max_seq_len"),
- ("tie_word_embeddings", "tie_word_embeddings"),
- ]:
- if opt_key in {"num_attention_heads", "num_key_value_heads", "rope_dim"} \
- and opt_key in model_cfg_kwargs:
- continue
- if cfg_key not in cfg:
- continue
- val = cfg[cfg_key]
- if val in (None, "", 0):
- continue
- model_cfg_kwargs[opt_key] = val
-
- if "num_attention_heads_total_dim" in sniffed:
- h = model_cfg_kwargs["hidden_size"]
- n_heads = model_cfg_kwargs.get("num_attention_heads", cfg.get("num_attention_heads", 8))
- head_dim = h // n_heads
- if head_dim > 0:
- correct_n_heads = sniffed["num_attention_heads_total_dim"] // head_dim
- correct_n_kv = sniffed["num_kv_heads_total_dim"] // head_dim
- if correct_n_heads > 0:
- model_cfg_kwargs["num_attention_heads"] = correct_n_heads
- if correct_n_kv > 0:
- model_cfg_kwargs["num_key_value_heads"] = correct_n_kv
-
- return ChronosConfig(**model_cfg_kwargs), sniffed, sniff_note
def generate_unified(cfg, selected_backend, selected_mode, model_path_val,
prompt_val, max_tok, temp, raw_prompt_val,
- miss_policy_val, ram_load_ratio_val, sweep_ratios_val):
+ miss_policy_val, ram_load_ratio_val, sweep_ratios_val,
+ speculative_val=False, draft_depth_val=1, gamma_val=4):
raw = generate_api(
cfg,
selected_backend=selected_backend,
@@ -1914,6 +1972,9 @@ def generate_unified(cfg, selected_backend, selected_mode, model_path_val,
ram_load_ratio=ram_load_ratio_val,
sweep_ram_load_ratios=sweep_ratios_val,
miss_policy=miss_policy_val,
+ speculative=speculative_val,
+ draft_depth=draft_depth_val,
+ gamma=gamma_val,
)
if raw.get("error"):
text = f"Error: {raw.get('error')}"
@@ -1957,10 +2018,10 @@ def generate_unified(cfg, selected_backend, selected_mode, model_path_val,
inputs=[
config_state, inference_backend, inference_mode, model_path, prompt,
max_tokens, temperature, raw_prompt, miss_policy, ram_load_ratio, sweep_ratios,
+ speculative, draft_depth, spec_gamma,
],
outputs=[output_box, lazy_output_box, full_output_box, stats_md, stats_chart, raw_stats],
api_name=None,
- api_visibility="private",
)
api_btn = gr.Button("api_generate", visible=False)
@@ -1970,6 +2031,7 @@ def generate_unified(cfg, selected_backend, selected_mode, model_path_val,
inputs=[
config_state, inference_backend, inference_mode, model_path, prompt,
max_tokens, temperature, raw_prompt, ram_load_ratio, sweep_ratios, miss_policy,
+ speculative, draft_depth, spec_gamma,
],
outputs=[api_out],
api_name="generate",
diff --git a/ui/tabs/monitor_tab.py b/ui/tabs/monitor_tab.py
new file mode 100644
index 0000000..d8e7da2
--- /dev/null
+++ b/ui/tabs/monitor_tab.py
@@ -0,0 +1,425 @@
+"""ui/tabs/monitor_tab.py — Training monitor inside the WebUI console.
+
+Reads the same run directories as the standalone monitor page
+(``chronos monitor``) through :class:`chronos.monitor.RunMonitor`: run spec
+and training parameters, heartbeat/derived status, progress and ETA, every
+logged metric, validation events, whatever evaluation metrics the run logs
+(discovered, never assumed), per-tensor statistics, MoE routing, samples of
+any task, log tail and checkpoints. Read-only; it never touches the trainer.
+"""
+from __future__ import annotations
+
+import html
+import json
+import math
+import os
+import threading
+import time
+
+from ui.gradio_compat import gr
+from ui.i18n import get_current_lang, register_translatable, t
+
+try: # pandas ships with gradio; the slim CI shim may lack it
+ import pandas as pd
+except ModuleNotFoundError: # pragma: no cover
+ pd = None
+
+DEFAULT_RUNS_DIR = os.environ.get("CHRONOS_RUNS_DIR", "runs")
+_MONITORS: dict[str, object] = {}
+_LOCK = threading.Lock()
+
+# Line plots: (title key, [(series key, label key)...]); one y-axis per plot.
+PLOTS = {
+ "overview": [
+ ("monitor.p_ce", [("primary_loss", "monitor.s_train"), ("valid_ce", "monitor.s_valid")]),
+ ("monitor.p_loss", [("loss", "monitor.s_total")]),
+ ("monitor.p_lr", [("lr", "monitor.s_lr")]),
+ ("monitor.p_grad", [("grad_norm", "monitor.s_grad")]),
+ ("monitor.p_tps", [("tokens_per_s", "monitor.s_counted"), ("processed_tokens_per_s", "monitor.s_processed")]),
+ ("monitor.p_mem", [("peak_mem_gb", "monitor.s_peak"), ("active_mem_gb", "monitor.s_active"),
+ ("cache_mem_gb", "monitor.s_cache")]),
+ ],
+ "recurrent": [
+ ("monitor.p_depth", [("rec_depth_mean", "monitor.s_depth")]),
+ ("monitor.p_contraction", [("rec_contraction", "monitor.s_contraction")]),
+ ("monitor.p_exit", [("rec_early_exit_ratio", "monitor.s_exit")]),
+ ("monitor.p_route_entropy", [("route_entropy", "monitor.s_entropy")]),
+ ("monitor.p_load_cv", [("load_cv", "monitor.s_load_cv")]),
+ ("monitor.p_max_load", [("max_expert_load", "monitor.s_max_load")]),
+ ],
+}
+LOSS_TERMS = ("primary_loss", "balance_loss", "lookahead_loss", "lookahead_topk_loss", "lookahead_union_loss",
+ "temporal_loss", "locality_loss", "anchor_loss", "recurrent_ponder_loss", "recurrent_distill_loss",
+ "recurrent_stability_loss", "recurrent_contraction_loss")
+STATE_ICON = {"running": "●", "paused": "‖", "evaluating": "◐", "checkpointing": "◐", "starting": "○",
+ "finished": "✓", "failed": "✕", "stopped": "■", "pending": "○"}
+# Sequential blue ramp (light→dark), steps 150..700 of the reference palette.
+SEQ = ("#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b")
+SEQ_INK = ("#0b0b0b", "#0b0b0b", "#0b0b0b", "#ffffff", "#ffffff", "#ffffff", "#ffffff")
+
+MONITOR_CSS = """
+.chronos-mon{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;font-size:13px}
+.chronos-mon .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
+.chronos-mon .pill{display:inline-flex;gap:6px;align-items:center;padding:3px 10px;border-radius:999px;
+ border:1px solid var(--border-color-primary);background:var(--background-fill-secondary);font-size:12px}
+.chronos-mon .muted{color:var(--body-text-color-subdued)}
+.chronos-mon .bar{height:8px;border-radius:4px;background:var(--background-fill-secondary);overflow:hidden;margin:6px 0 4px}
+.chronos-mon .bar>span{display:block;height:100%;background:#2a78d6;border-radius:4px}
+.chronos-mon .tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
+.chronos-mon .tile{border:1px solid var(--border-color-primary);border-radius:8px;padding:10px 12px;background:var(--block-background-fill)}
+.chronos-mon .tile b{display:block;font-size:20px;font-weight:600;margin-top:4px;font-variant-numeric:tabular-nums}
+.chronos-mon .tile small{color:var(--body-text-color-subdued);font-size:11px}
+.chronos-mon .note{border:1px solid var(--border-color-primary);border-radius:8px;padding:8px 12px;margin-bottom:10px}
+.chronos-mon .warn{border-color:#fab219}.chronos-mon .err{border-color:#d03b3b}
+.chronos-mon table.heat{border-collapse:separate;border-spacing:2px;font-size:10px;width:auto;margin:6px 0 0}
+.chronos-mon table.heat,.chronos-mon table.heat tr,.chronos-mon table.heat td,.chronos-mon table.heat th{border:0!important;background-clip:padding-box}
+.chronos-mon table.heat td{min-width:16px;height:18px;border-radius:2px;text-align:center;padding:0 2px;font-variant-numeric:tabular-nums}
+.chronos-mon table.heat th{font-weight:400;color:var(--body-text-color-subdued);padding:0 4px;white-space:nowrap}
+.chronos-mon .samp{border-bottom:1px solid var(--border-color-primary);padding:8px 0;line-height:1.6}
+"""
+
+
+# ---- data access -------------------------------------------------------------
+def _monitor(runs_dir: str):
+ from chronos.monitor import RunMonitor
+ root = os.path.abspath(os.path.expanduser(runs_dir or DEFAULT_RUNS_DIR))
+ with _LOCK:
+ mon = _MONITORS.get(root)
+ if mon is None:
+ if len(_MONITORS) > 8:
+ _MONITORS.clear()
+ mon = _MONITORS[root] = RunMonitor(root)
+ return mon
+
+
+def _f(v, digits: int = 4) -> str:
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v):
+ return "—"
+ a = abs(v)
+ if a and (a < 1e-4 or a >= 1e6):
+ return f"{v:.2e}"
+ if isinstance(v, int):
+ return f"{v:,}"
+ return f"{v:.{digits}g}"
+
+
+def _count(v) -> str:
+ if not isinstance(v, (int, float)):
+ return "—"
+ for div, suf in ((1e9, "B"), (1e6, "M"), (1e3, "k")):
+ if abs(v) >= div:
+ return f"{v / div:.1f}{suf}"
+ return str(round(v))
+
+
+def _dur(sec) -> str:
+ if not isinstance(sec, (int, float)):
+ return "—"
+ sec = max(0, int(sec))
+ d, h, m = sec // 86400, sec % 86400 // 3600, sec % 3600 // 60
+ return f"{d}d {h}h" if d else f"{h}h {m}m" if h else f"{m}m"
+
+
+def _esc(v) -> str:
+ return html.escape("" if v is None else str(v), quote=True)
+
+
+def _df(rows: list[dict], columns: list[tuple[str, str]], numeric: tuple[str, ...] = ()):
+ """``columns``: (row key, i18n header key); ``numeric`` keys are pre-formatted (tiny values as 1.23e-06)."""
+ data = [[_f(r.get(k)) if k in numeric and r.get(k) is not None else r.get(k) for k, _ in columns] for r in rows]
+ if pd is None:
+ return data
+ return pd.DataFrame(data, columns=[t(h) for _, h in columns])
+
+
+def _series_df(parts: list[tuple[str, list[dict], str]]):
+ """Long format for gr.LinePlot: ``parts`` = (label, rows, metric key)."""
+ recs = []
+ for label, rows, key in parts:
+ for r in rows:
+ v, step = r.get(key), r.get("step")
+ if isinstance(v, (int, float)) and isinstance(step, (int, float)) and math.isfinite(v):
+ recs.append({"step": step, "value": float(v), "series": label})
+ if pd is None:
+ return recs
+ return pd.DataFrame(recs, columns=["step", "value", "series"])
+
+
+PALETTE = ("#2a78d6", "#eb6834", "#1baf7a")
+
+
+def _plot_update(parts):
+ """Fixed colour per series slot (blue, orange, green). Multi-series plots
+ name their colours in the translated label; the tooltip carries the name."""
+ labels = list(dict.fromkeys(label for label, _, _ in parts))
+ return gr.update(value=_series_df(parts), color_map={lab: PALETTE[i % 3] for i, lab in enumerate(labels)})
+
+
+# ---- HTML fragments (all dynamic text escaped) ----------------------------------
+def _heat_html(title: str, labels: list, grid: list, fmt=lambda v: _f(v, 3), col_label="E") -> str:
+ flat = [v for row in grid or [] for v in row if isinstance(v, (int, float))]
+ if not flat:
+ return f"{_esc(title)}{_esc(t('monitor.none'))}
"
+ lo, hi = min(flat), max(flat)
+ cols = max(len(r) for r in grid)
+ head = "".join(f"{j} | " for j in range(cols))
+ body = []
+ for i, row in enumerate(grid):
+ cells = []
+ for j in range(cols):
+ v = row[j] if j < len(row) else None
+ if not isinstance(v, (int, float)):
+ cells.append(" | ")
+ continue
+ b = min(6, int((v - lo) / (hi - lo) * 7)) if hi > lo else 3
+ tip = f"{labels[i] if i < len(labels) else i} · {col_label}{j}: {fmt(v)}"
+ cells.append(f" | ")
+ body.append(f"| {_esc(labels[i] if i < len(labels) else i)} | {''.join(cells)}
")
+ ramp = "".join(f"" for c in SEQ)
+ return (f"{_esc(title)}"
+ f"
{_esc(fmt(lo))} {ramp} {_esc(fmt(hi))}
")
+
+
+def _status_html(snap: dict) -> str:
+ if not snap.get("run"):
+ return f"{_esc(t('monitor.no_runs'))} {_esc(snap.get('root'))}
"
+ st, p, latest = snap.get("status") or {}, snap.get("progress") or {}, snap.get("latest") or {}
+ state = st.get("state", "pending")
+ notes = []
+ if st.get("error"):
+ notes.append(f"✕ {_esc(t('monitor.error'))}: {_esc(st['error'])}
")
+ if state == "paused":
+ notes.append(f"‖ {_esc(t('monitor.paused'))}
")
+ if st.get("stale"):
+ notes.append(f"! {_esc(t('monitor.stale').format(age=_dur(st.get('last_update_age_s'))))}
")
+ if st.get("source") == "derived" and not snap.get("parameters"):
+ notes.append(f"i {_esc(t('monitor.derived'))}
")
+ frac = p.get("phase_fraction")
+ head = (f"{STATE_ICON.get(state, '○')} {_esc(t('monitor.state.' + state))}"
+ f"{_esc(snap['run'])}{_esc(p.get('current') or '—')} · {_esc(t('monitor.step'))} "
+ f"{_esc(_f(latest.get('step')))} / {_esc(_f(latest.get('total')))}"
+ f"{' · ' + format(frac * 100, '.1f') + '%' if isinstance(frac, (int, float)) else ''}"
+ f"{_esc(t('monitor.eta'))} {_esc((p.get('eta') or '—').replace('T', ' '))} · "
+ f"{_esc(t('monitor.remaining'))} {_esc(_dur(p.get('run_remaining_s')))} · {_esc(t('monitor.budget'))} "
+ f"{_esc(p.get('budget_hours'))} h
")
+ bars = []
+ for ph in p.get("phases") or []:
+ f = 1.0 if ph["state"] == "complete" else (frac or 0.0) if ph["state"] == "active" else 0.0
+ bars.append(f"
"
+ f"
{_esc(ph['name'])} {_esc(t('monitor.ph.' + ph['state']))} · {_esc(ph.get('budget_h'))} h ")
+ best = (snap.get("best_valid") or {}).get(p.get("current")) or {}
+ mem = snap.get("memory") or {}
+ spec = snap.get("spec") or {}
+ cfg = spec.get("model_config") or {}
+ tiles = [
+ ("monitor.k_loss", _f(latest.get("loss")), f"{t('monitor.step')} {_f(latest.get('step'))}"),
+ ("monitor.k_ce", _f(latest.get("primary_loss")),
+ f"ppl {_f(math.exp(latest['primary_loss']), 3)}" if isinstance(latest.get("primary_loss"), (int, float)) else ""),
+ ("monitor.k_valid", _f(best.get("valid_ce")), f"{t('monitor.step')} {_f(best.get('step'))}" if best else ""),
+ ("monitor.k_tps", f"{_count(latest.get('tokens_per_s'))} tok/s", f"{_count(latest.get('processed_tokens_per_s'))} {t('monitor.s_processed')}"),
+ ("monitor.k_mem", f"{_f(mem.get('footprint_gb') or latest.get('peak_mem_gb'), 3)} GB",
+ f"MLX peak {_f(latest.get('peak_mem_gb'), 3)} · active {_f(latest.get('active_mem_gb'), 3)} GB"),
+ ("monitor.k_grad", _f(latest.get("grad_norm")), ""),
+ ("monitor.k_lr", _f(latest.get("lr"), 3), ""),
+ ("monitor.k_depth", _f(latest.get("rec_depth_mean"), 3), f"max {_esc(cfg.get('recurrent_depth', '—'))}"),
+ ("monitor.k_route", _f(latest.get("route_entropy"), 3), f"max {_f(latest.get('route_entropy_max'), 3)} · CV {_f(latest.get('load_cv'), 3)}"),
+ ]
+ tiles_html = "".join(f"{_esc(t(k))}{_esc(v)}{_esc(sub)}
" for k, v, sub in tiles)
+ foot = (f"{_esc(t('monitor.source'))}: {_esc(st.get('source'))} · pid {_esc(st.get('pid'))} "
+ f"({_esc(st.get('ps_state') or '-')}) · {_esc(time.strftime('%H:%M:%S'))}
")
+ return (f"{''.join(notes)}{head}
{''.join(bars)}
"
+ f"
{tiles_html}
{foot}
")
+
+
+def _events_rows(snap: dict) -> list[dict]:
+ out = []
+ for e in reversed(snap.get("events") or []):
+ detail = {k: v for k, v in e.items() if k not in ("time", "event", "phase", "step")}
+ out.append({"time": e.get("time"), "event": e.get("event"), "phase": e.get("phase"), "step": e.get("step"),
+ "valid_ce": e.get("valid_ce"), "detail": json.dumps(detail, ensure_ascii=False)[:400]})
+ return out
+
+
+def _samples_html(snap: dict) -> str:
+ """Samples of any task: the snapshot normalises them to input/reference/output + tags."""
+ rows = list(reversed(snap.get("samples") or []))[:12]
+ if not rows:
+ return f"{_esc(t('monitor.none'))}
"
+ items = []
+ for r in rows:
+ tags = " · ".join(f"{k}={v}" for k, v in (r.get("tags") or {}).items())
+ head = " · ".join(x for x in (_esc(r.get("phase")), f"{_esc(t('monitor.step'))} {_esc(r.get('step'))}", _esc(tags)) if x)
+ body = "".join(f"
{_esc(t(key))} {_esc(r.get(role))}"
+ for role, key in (("input", "monitor.sample_input"), ("reference", "monitor.sample_ref"),
+ ("output", "monitor.sample_output")) if r.get(role) not in (None, ""))
+ items.append(f"{head}{body}
")
+ return f"{''.join(items)}
"
+
+
+def _eval_family(snap: dict, metric: str | None) -> tuple[list[str], str | None, dict | None]:
+ """Metric families the run logs (discovered, not assumed) and the chosen one."""
+ families = (snap.get("evals") or {}).get("families") or []
+ names = [f["metric"] for f in families]
+ chosen = metric if metric in names else (names[0] if names else None)
+ return names, chosen, next((f for f in families if f["metric"] == chosen), None)
+
+
+# ---- refresh -------------------------------------------------------------------------
+def _phase_rows(snap: dict, phase: str | None) -> tuple[str | None, list[dict]]:
+ hist = snap.get("history") or {}
+ phase = phase if phase in hist else (snap.get("progress") or {}).get("current")
+ if phase not in hist:
+ phase = next(reversed(hist), None) if hist else None
+ return phase, hist.get(phase, []) if phase else []
+
+
+def _loss_parts(rows: list[dict], term: str):
+ term = term if term in LOSS_TERMS else "primary_loss"
+ return [(term, rows, term)]
+
+
+def refresh(runs_dir: str, run: str | None, phase: str | None, loss_term: str, eval_metric: str | None = None):
+ """Everything the tab shows, in output order (see ``build_monitor_tab``)."""
+ mon = _monitor(runs_dir)
+ try:
+ snap = mon.snapshot(run or None)
+ except KeyError:
+ snap = mon.snapshot(None)
+ names = [r["name"] for r in snap.get("runs") or []]
+ run_update = gr.update(choices=names, value=snap.get("run"))
+ phases = list((snap.get("history") or {}).keys())
+ phase, rows = _phase_rows(snap, phase)
+ phase_update = gr.update(choices=phases, value=phase)
+ valid = [e for e in snap.get("events") or [] if e.get("event") == "validation" and e.get("phase") == phase]
+ plots = []
+ for group in ("overview", "recurrent"):
+ for _title, series in PLOTS[group]:
+ parts = [(t(label), valid if key == "valid_ce" else rows, key) for key, label in series]
+ plots.append(_plot_update(parts))
+ present = [k for k in LOSS_TERMS if any(isinstance(r.get(k), (int, float)) for r in rows)] or ["primary_loss"]
+ term = loss_term if loss_term in present else present[0]
+ loss_update = _plot_update(_loss_parts(rows, term))
+ term_update = gr.update(choices=present, value=term)
+
+ params = snap.get("parameters") or {}
+ routing = params.get("routing") or {}
+ experts = params.get("experts") or {}
+ groups = [{"group": k, **v} for k, v in (params.get("groups") or {}).items()]
+ if params.get("stats"):
+ groups.append({"group": "total", **params["stats"], "tensors": params.get("tensor_count")})
+ group_df = _df(groups, [("group", "monitor.c_group"), ("tensors", "monitor.c_tensors"), ("numel", "monitor.c_numel"),
+ ("rms", "monitor.c_rms"), ("grad_norm", "monitor.c_grad_norm"), ("update_ratio", "monitor.c_update")],
+ numeric=("rms", "grad_norm", "update_ratio"))
+ full = mon.parameters(snap.get("run")) if params else None
+ tensors = (full or {}).get("tensors") or []
+ tensor_df = _df([{**x, "shape": "×".join(map(str, x.get("shape") or []))} for x in tensors],
+ [("name", "monitor.c_name"), ("group", "monitor.c_group"), ("shape", "monitor.c_shape"), ("dtype", "monitor.c_dtype"),
+ ("rms", "monitor.c_rms"), ("std", "monitor.c_std"), ("absmax", "monitor.c_absmax"),
+ ("grad_rms", "monitor.c_grad_rms"), ("update_ratio", "monitor.c_update")],
+ numeric=("rms", "std", "absmax", "grad_rms", "update_ratio"))
+ tel_note = (t("monitor.telemetry_at").format(step=params.get("step"), ms=round((params.get("telemetry_s") or 0) * 1000))
+ if params else t("monitor.no_telemetry"))
+ load_html = _heat_html(t("monitor.h_load"), routing.get("labels") or [], routing.get("load") or [],
+ fmt=lambda v: f"{v * 100:.1f}%")
+ imp_html = _heat_html(t("monitor.h_importance"), routing.get("labels") or [], routing.get("importance") or [])
+ layers = [f"L{x}" for x in experts.get("layers") or []]
+ wrms_html = _heat_html(t("monitor.h_wrms"), layers, experts.get("weight_rms") or [])
+ grms_html = _heat_html(t("monitor.h_grms"), layers, experts.get("grad_rms") or [])
+
+ events_df = _df(_events_rows(snap), [("time", "monitor.c_time"), ("event", "monitor.c_event"), ("phase", "monitor.c_phase"),
+ ("step", "monitor.step"), ("valid_ce", "monitor.c_valid_ce"), ("detail", "monitor.c_detail")])
+ ckpt_df = _df(snap.get("checkpoints") or [], [("file", "monitor.c_file"), ("size_gb", "monitor.c_size"),
+ ("modified", "monitor.c_time")])
+ names, chosen, family = _eval_family(snap, eval_metric)
+ eval_parts = [(sr["label"], valid, sr["key"]) for sr in (family or {}).get("series", [])[:len(PALETTE)]]
+ eval_update = gr.update(choices=names, value=chosen)
+ eval_plot = _plot_update(eval_parts)
+ training_params = mon.training_parameters(snap.get("run")) if snap.get("run") else {}
+ log = mon.log_tail(snap.get("run"), 24000) if snap.get("run") else ""
+ return (run_update, phase_update, _status_html(snap), *plots, term_update, loss_update,
+ tel_note, group_df, tensor_df, load_html, imp_html, wrms_html, grms_html,
+ eval_update, eval_plot, events_df, _samples_html(snap), training_params, log, ckpt_df)
+
+
+# ---- layout ------------------------------------------------------------------------------
+def _labelled(component, key: str):
+ register_translatable(component, key)
+ return component
+
+
+def build_monitor_tab():
+ with gr.Tab(t("tab.monitor")) as tab:
+ register_translatable(tab, "tab.monitor")
+ gr.Markdown(t("monitor.intro"))
+ with gr.Row():
+ runs_dir = _labelled(gr.Textbox(value=DEFAULT_RUNS_DIR, label=t("monitor.runs_dir"), scale=3), "monitor.runs_dir")
+ run = _labelled(gr.Dropdown(choices=[], value=None, label=t("monitor.run"), scale=2, allow_custom_value=True), "monitor.run")
+ phase = _labelled(gr.Dropdown(choices=[], value=None, label=t("monitor.phase"), scale=1, allow_custom_value=True), "monitor.phase")
+ auto = _labelled(gr.Checkbox(value=True, label=t("monitor.auto"), scale=1), "monitor.auto")
+ refresh_btn = _labelled(gr.Button(t("monitor.refresh"), scale=1), "monitor.refresh")
+ status = gr.HTML()
+ plots = []
+ with gr.Tab(t("monitor.t_overview")) as sub:
+ register_translatable(sub, "monitor.t_overview")
+ for i in range(0, len(PLOTS["overview"]), 2):
+ with gr.Row():
+ for title, _series in PLOTS["overview"][i:i + 2]:
+ plots.append(_labelled(gr.LinePlot(x="step", y="value", color="series", label=t(title), y_title="",
+ height=300, tooltip=["series", "step", "value"]), title))
+ with gr.Tab(t("monitor.t_recurrent")) as sub:
+ register_translatable(sub, "monitor.t_recurrent")
+ for i in range(0, len(PLOTS["recurrent"]), 3):
+ with gr.Row():
+ for title, _series in PLOTS["recurrent"][i:i + 3]:
+ plots.append(_labelled(gr.LinePlot(x="step", y="value", color="series", label=t(title), y_title="",
+ height=270, tooltip=["series", "step", "value"]), title))
+ with gr.Tab(t("monitor.t_loss")) as sub:
+ register_translatable(sub, "monitor.t_loss")
+ loss_term = _labelled(gr.Dropdown(choices=list(LOSS_TERMS), value="primary_loss", label=t("monitor.loss_term")), "monitor.loss_term")
+ loss_plot = gr.LinePlot(x="step", y="value", color="series", y_title="", height=340, tooltip=["series", "step", "value"])
+ with gr.Tab(t("monitor.t_params")) as sub:
+ register_translatable(sub, "monitor.t_params")
+ tel_note = gr.Markdown()
+ group_df = _labelled(gr.Dataframe(label=t("monitor.groups"), interactive=False, wrap=True), "monitor.groups")
+ with gr.Row():
+ load_html, imp_html = gr.HTML(), gr.HTML()
+ with gr.Row():
+ wrms_html, grms_html = gr.HTML(), gr.HTML()
+ tensor_df = _labelled(gr.Dataframe(label=t("monitor.tensors"), interactive=False, max_height=520,
+ column_widths=["30%", "10%", "8%", "8%", "8.8%", "8.8%", "8.8%", "8.8%", "8.8%"]),
+ "monitor.tensors")
+ with gr.Tab(t("monitor.t_evals")) as sub:
+ register_translatable(sub, "monitor.t_evals")
+ eval_note = gr.Markdown(t("monitor.eval_note"))
+ register_translatable(eval_note, "monitor.eval_note")
+ eval_metric = _labelled(gr.Dropdown(choices=[], value=None, label=t("monitor.eval_metric"),
+ allow_custom_value=True), "monitor.eval_metric")
+ eval_plot = gr.LinePlot(x="step", y="value", color="series", y_title="", height=300,
+ tooltip=["series", "step", "value"])
+ events_df = _labelled(gr.Dataframe(label=t("monitor.events"), interactive=False, wrap=True, max_height=420), "monitor.events")
+ samples_html = gr.HTML()
+ with gr.Tab(t("monitor.t_config")) as sub:
+ register_translatable(sub, "monitor.t_config")
+ params_json = _labelled(gr.JSON(label=t("monitor.training_params")), "monitor.training_params")
+ with gr.Tab(t("monitor.t_log")) as sub:
+ register_translatable(sub, "monitor.t_log")
+ log_box = _labelled(gr.Textbox(label=t("monitor.log"), lines=24, max_lines=24, interactive=False, autoscroll=True), "monitor.log")
+ ckpt_df = _labelled(gr.Dataframe(label=t("monitor.checkpoints"), interactive=False), "monitor.checkpoints")
+
+ outputs = [run, phase, status, *plots, loss_term, loss_plot, tel_note, group_df, tensor_df,
+ load_html, imp_html, wrms_html, grms_html, eval_metric, eval_plot, events_df, samples_html,
+ params_json, log_box, ckpt_df]
+ inputs = [runs_dir, run, phase, loss_term, eval_metric]
+ refresh_btn.click(fn=refresh, inputs=inputs, outputs=outputs)
+ run.input(fn=refresh, inputs=inputs, outputs=outputs)
+ phase.input(fn=refresh, inputs=inputs, outputs=outputs)
+ loss_term.input(fn=refresh, inputs=inputs, outputs=outputs)
+ eval_metric.input(fn=refresh, inputs=inputs, outputs=outputs)
+ runs_dir.submit(fn=refresh, inputs=inputs, outputs=outputs)
+
+ tab.select(fn=refresh, inputs=inputs, outputs=outputs) # first paint when the tab opens
+ timer = gr.Timer(15.0)
+ timer.tick(fn=refresh, inputs=inputs, outputs=outputs)
+ auto.change(fn=lambda on: gr.Timer(active=bool(on)), inputs=[auto], outputs=[timer])
+ tab.monitor_wiring = (refresh, inputs, outputs) # app.load() paints it on page open
+ return tab
diff --git a/ui/tabs/serve_tab.py b/ui/tabs/serve_tab.py
new file mode 100644
index 0000000..43e3f1e
--- /dev/null
+++ b/ui/tabs/serve_tab.py
@@ -0,0 +1,202 @@
+"""ui/tabs/serve_tab.py — start/stop ``chronos serve`` from the console.
+
+The server runs as a child process (argument list, no shell) so it keeps its
+own memory accounting and survives UI reloads only while this process lives.
+Security contract (same as the CLI): loopback by default; any other host
+requires *allow remote* plus an API key. The key is passed through the
+``CHRONOS_API_KEY`` environment variable, never on the command line, so it
+does not show up in ``ps``.
+"""
+from __future__ import annotations
+
+import atexit
+import os
+import subprocess
+import sys
+import threading
+from collections import deque
+
+from ui.gradio_compat import gr
+from ui.i18n import register_translatable, t
+
+LOOPBACK = ("127.0.0.1", "localhost", "::1")
+
+
+def build_serve_command(*, models_dir: str, host: str = "127.0.0.1", port: int = 11435,
+ device: str = "auto", dtype: str = "auto", lazy_experts: bool = True,
+ ram_expert_fraction: float = 0.5, allow_remote: bool = False,
+ api_key: str = "", kv_cache_dtype: str = "", mla_decode: str = "",
+ router_scope: str = "", max_resident_experts: int | None = None,
+ ram_budget_mb: float | None = None, monitor_runs_dir: str = "") -> tuple[list[str], dict]:
+ """Return ``(argv, env_overrides)``; raises ValueError on an unsafe or invalid request."""
+ host = (host or "127.0.0.1").strip()
+ models_dir = os.path.expanduser((models_dir or "").strip())
+ if not models_dir or not os.path.isdir(models_dir):
+ raise ValueError("models_dir")
+ port = int(port)
+ if not 1 <= port <= 65535:
+ raise ValueError("port")
+ remote = host not in LOOPBACK
+ if remote and not (allow_remote and (api_key or "").strip()):
+ raise ValueError("remote_needs_key")
+ argv = [sys.executable, "-m", "chronos.serving.ollama_server", "--models-dir", models_dir,
+ "--host", host, "--port", str(port), "--device", device or "auto", "--dtype", dtype or "auto",
+ "--ram-expert-fraction", str(float(ram_expert_fraction))]
+ if not lazy_experts:
+ argv.append("--no-lazy-experts")
+ if allow_remote:
+ argv.append("--allow-remote")
+ for flag, value in (("--kv-cache-dtype", kv_cache_dtype), ("--mla-decode", mla_decode),
+ ("--router-scope", router_scope)):
+ if value and value != "checkpoint":
+ argv += [flag, value]
+ if max_resident_experts:
+ argv += ["--max-resident-experts", str(int(max_resident_experts))]
+ if ram_budget_mb:
+ argv += ["--ram-budget-mb", str(float(ram_budget_mb))]
+ if monitor_runs_dir and monitor_runs_dir.strip():
+ argv += ["--monitor-runs-dir", os.path.expanduser(monitor_runs_dir.strip())]
+ env = {"CHRONOS_API_KEY": api_key.strip()} if (api_key or "").strip() else {}
+ return argv, env
+
+
+def redact(argv: list[str]) -> str:
+ return " ".join(argv[1:]) # the key is never in argv; drop the interpreter path
+
+
+class ServeProcess:
+ def __init__(self):
+ self.proc: subprocess.Popen | None = None
+ self.lines: deque[str] = deque(maxlen=500)
+ self.lock = threading.Lock()
+ self.url = ""
+ self.auth = False
+
+ def running(self) -> bool:
+ return self.proc is not None and self.proc.poll() is None
+
+ def _pump(self, proc: subprocess.Popen) -> None:
+ for line in proc.stdout:
+ with self.lock:
+ self.lines.append(line.rstrip())
+ with self.lock:
+ self.lines.append(f"[exit {proc.wait()}]")
+
+ def start(self, argv: list[str], env_overrides: dict, host: str, port: int) -> str:
+ if self.running():
+ return "busy"
+ env = dict(os.environ)
+ env.pop("CHRONOS_API_KEY", None) # only an explicit key from the form enables auth
+ env.update(env_overrides)
+ env["PYTHONUNBUFFERED"] = "1"
+ repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ self.lines.clear()
+ self.lines.append(f"$ {redact(argv)}")
+ self.proc = subprocess.Popen(argv, cwd=repo, env=env, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True, bufsize=1)
+ threading.Thread(target=self._pump, args=(self.proc,), daemon=True).start()
+ shown = f"[{host}]" if ":" in host else host
+ self.url, self.auth = f"http://{shown}:{int(port)}", bool(env_overrides.get("CHRONOS_API_KEY"))
+ return "started"
+
+ def stop(self, timeout: float = 10.0) -> str:
+ if not self.running():
+ return "idle"
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ self.proc.kill()
+ self.proc.wait()
+ return "stopped"
+
+ def snapshot(self) -> tuple[bool, str]:
+ with self.lock:
+ return self.running(), "\n".join(self.lines)
+
+
+_server = ServeProcess()
+atexit.register(_server.stop) # never leave an orphaned server behind the UI
+
+
+def _status(lang: str | None = None) -> str:
+ if not _server.running():
+ return t("serve.state.stopped", lang)
+ auth = t("serve.auth_on", lang) if _server.auth else t("serve.auth_off", lang)
+ return f"{t('serve.state.running', lang)} · {_server.url} · {auth}"
+
+
+def build_serve_tab():
+ with gr.Tab(t("tab.serve")) as tab:
+ register_translatable(tab, "tab.serve")
+ intro = gr.Markdown(t("serve.intro"))
+ register_translatable(intro, "serve.intro")
+ with gr.Row():
+ models_dir = gr.Textbox(label=t("serve.models_dir"), value="./out", scale=3)
+ host = gr.Textbox(label=t("serve.host"), value="127.0.0.1", scale=1)
+ port = gr.Number(label=t("serve.port"), value=11435, precision=0, scale=1)
+ with gr.Row():
+ device = gr.Dropdown(["auto", "cpu", "mps", "cuda"], value="auto", label=t("serve.device"))
+ dtype = gr.Dropdown(["auto", "float32", "bfloat16", "float16"], value="auto", label=t("serve.dtype"))
+ lazy = gr.Checkbox(value=True, label=t("serve.lazy"))
+ ram_fraction = gr.Slider(0.05, 1.0, value=0.5, step=0.05, label=t("serve.ram_fraction"))
+ with gr.Row():
+ kv_dtype = gr.Dropdown(["checkpoint", "auto", "int8"], value="checkpoint", label=t("serve.kv_dtype"))
+ mla_decode = gr.Dropdown(["checkpoint", "auto", "absorbed", "expanded"], value="checkpoint",
+ label=t("serve.mla_decode"))
+ router_scope = gr.Dropdown(["checkpoint", "per_iteration", "shared"], value="checkpoint",
+ label=t("serve.router_scope"))
+ max_resident = gr.Number(label=t("serve.max_resident"), value=0, precision=0)
+ ram_budget = gr.Number(label=t("serve.ram_budget"), value=0, precision=0)
+ with gr.Row():
+ allow_remote = gr.Checkbox(value=False, label=t("serve.allow_remote"))
+ api_key = gr.Textbox(label=t("serve.api_key"), type="password", value="")
+ monitor_dir = gr.Textbox(label=t("serve.monitor_dir"), value="", placeholder="./runs")
+ warning = gr.Markdown(t("serve.warn_noauth"))
+ with gr.Row():
+ start_btn = gr.Button(t("serve.start"), variant="primary")
+ stop_btn = gr.Button(t("serve.stop"), variant="stop")
+ status = gr.Textbox(label=t("serve.status"), interactive=False, value=_status())
+ log_box = gr.Textbox(label=t("serve.log"), lines=14, interactive=False, autoscroll=True)
+ usage = gr.Markdown(t("serve.usage"))
+ for comp, key in ((models_dir, "serve.models_dir"), (host, "serve.host"), (port, "serve.port"),
+ (device, "serve.device"), (dtype, "serve.dtype"), (lazy, "serve.lazy"),
+ (ram_fraction, "serve.ram_fraction"), (kv_dtype, "serve.kv_dtype"),
+ (mla_decode, "serve.mla_decode"), (router_scope, "serve.router_scope"),
+ (max_resident, "serve.max_resident"), (ram_budget, "serve.ram_budget"),
+ (allow_remote, "serve.allow_remote"), (api_key, "serve.api_key"),
+ (monitor_dir, "serve.monitor_dir"), (warning, "serve.warn_noauth"),
+ (start_btn, "serve.start"), (stop_btn, "serve.stop"), (status, "serve.status"),
+ (log_box, "serve.log"), (usage, "serve.usage")):
+ register_translatable(comp, key)
+
+ def start(md, h, p, dev, dt, lz, rf, kv, mla, rs, mr, rb, ar, key, mon):
+ try:
+ argv, env = build_serve_command(
+ models_dir=md, host=h, port=int(p or 11435), device=dev, dtype=dt, lazy_experts=bool(lz),
+ ram_expert_fraction=float(rf), allow_remote=bool(ar), api_key=key or "",
+ kv_cache_dtype=kv, mla_decode=mla, router_scope=rs,
+ max_resident_experts=int(mr or 0) or None, ram_budget_mb=float(rb or 0) or None,
+ monitor_runs_dir=mon or "")
+ except ValueError as exc:
+ return t(f"serve.err.{exc}"), gr.update()
+ outcome = _server.start(argv, env, (h or "127.0.0.1").strip(), int(p or 11435))
+ if outcome == "busy":
+ return t("serve.err.busy"), gr.update()
+ return _status(), _server.snapshot()[1]
+
+ def stop():
+ _server.stop()
+ return _status(), _server.snapshot()[1]
+
+ def poll():
+ return _status(), _server.snapshot()[1]
+
+ start_btn.click(fn=start, inputs=[models_dir, host, port, device, dtype, lazy, ram_fraction, kv_dtype,
+ mla_decode, router_scope, max_resident, ram_budget, allow_remote,
+ api_key, monitor_dir],
+ outputs=[status, log_box])
+ stop_btn.click(fn=stop, outputs=[status, log_box])
+ timer = gr.Timer(2.0)
+ timer.tick(fn=poll, outputs=[status, log_box])
+ return tab
diff --git a/ui/tabs/tokenizer_tab.py b/ui/tabs/tokenizer_tab.py
new file mode 100644
index 0000000..6812ce6
--- /dev/null
+++ b/ui/tabs/tokenizer_tab.py
@@ -0,0 +1,191 @@
+"""ui/tabs/tokenizer_tab.py — Chronos Tokenizer Builder.
+
+Trains a tokenizer from user-selected datasets (jsonl / parquet / csv / tsv /
+txt, files, directories or globs) with ``chronos.tokenizer.build_tokenizer``
+in a background thread, then shows the held-out evaluation it wrote to
+``tokenizer_report.json``. The same builder backs ``chronos tokenizer``.
+"""
+from __future__ import annotations
+
+import json
+import os
+import threading
+import time
+from collections import deque
+
+from ui.gradio_compat import gr
+from ui.i18n import register_translatable, t
+
+EVAL_COLUMNS = ["bucket", "texts", "bytes/token", "chars/token", "tokens/word",
+ "byte_fallback_rate", "roundtrip_failures"]
+
+
+def parse_paths(text: str) -> list[str]:
+ """One path per line or comma; ``~`` expanded; blanks dropped."""
+ out = []
+ for chunk in (text or "").replace(",", "\n").splitlines():
+ chunk = chunk.strip().strip('"').strip("'")
+ if chunk:
+ out.append(os.path.expanduser(chunk))
+ return out
+
+
+def report_rows(report: dict) -> list[list]:
+ """Per-bucket rows from a ``heldout_eval`` dict (``vocab_utilization`` skipped)."""
+ rows = []
+ for bucket, stats in (report or {}).items():
+ if not isinstance(stats, dict):
+ continue
+ rows.append([bucket, stats.get("texts"), stats.get("bytes_per_token"), stats.get("chars_per_token"),
+ stats.get("tokens_per_word"), stats.get("byte_fallback_rate"),
+ stats.get("roundtrip_failures")])
+ return rows
+
+
+def read_report(out_dir: str) -> dict:
+ path = os.path.join(os.path.expanduser(out_dir or ""), "tokenizer_report.json")
+ if not os.path.isfile(path):
+ return {}
+ with open(path, encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def report_summary(report: dict) -> dict:
+ if not report:
+ return {}
+ keep = ("vocab_size", "fingerprint", "algorithm", "elapsed_s", "special_tokens")
+ out = {k: report[k] for k in keep if k in report}
+ heldout = report.get("heldout_eval") or {}
+ if "vocab_utilization" in heldout:
+ out["vocab_utilization"] = heldout["vocab_utilization"]
+ if report.get("sources"):
+ out["sources"] = report["sources"]
+ return out
+
+
+class TokenizerJob:
+ """One background build at a time; log lines are kept in a bounded deque."""
+
+ def __init__(self):
+ self.lock = threading.Lock()
+ self.thread: threading.Thread | None = None
+ self.lines: deque[str] = deque(maxlen=400)
+ self.state = "idle"
+ self.out_dir = ""
+ self.error = ""
+
+ def log(self, message) -> None:
+ with self.lock:
+ self.lines.append(f"{time.strftime('%H:%M:%S')} {message}")
+
+ def running(self) -> bool:
+ return self.thread is not None and self.thread.is_alive()
+
+ def start(self, paths: list[str], out_dir: str, vocab_size: int, per_source: int,
+ fields: list[str] | None, temperature: float) -> str:
+ if self.running():
+ return "busy"
+ if not paths:
+ return "no_paths"
+ if not out_dir:
+ return "no_out"
+ from chronos.tokenizer.extract import expand
+ files = expand(paths)
+ if not files:
+ return "no_files"
+ self.lines.clear()
+ self.state, self.out_dir, self.error = "running", os.path.expanduser(out_dir), ""
+ self.log(f"{len(files)} files: " + ", ".join(os.path.basename(f) for f in files[:8])
+ + (" …" if len(files) > 8 else ""))
+
+ def work():
+ from chronos.tokenizer import BPETrainerConfig, ExtractConfig, build_tokenizer
+ try:
+ build_tokenizer(
+ paths, self.out_dir, vocab_size=int(vocab_size),
+ extract=ExtractConfig(per_source_texts=int(per_source), fields=fields or None),
+ trainer=BPETrainerConfig(vocab_size=int(vocab_size),
+ language_temperature=float(temperature)),
+ log=self.log,
+ )
+ self.state = "done"
+ except Exception as exc: # surfaced in the UI, never raised into Gradio
+ self.error = f"{type(exc).__name__}: {exc}"
+ self.log(f"error: {self.error}")
+ self.state = "failed"
+
+ self.thread = threading.Thread(target=work, name="chronos-tokenizer", daemon=True)
+ self.thread.start()
+ return "started"
+
+ def snapshot(self) -> tuple[str, str]:
+ with self.lock:
+ return self.state, "\n".join(self.lines)
+
+
+_job = TokenizerJob()
+
+
+def _status_text(state: str, lang: str | None = None) -> str:
+ return t(f"tok.state.{state}", lang)
+
+
+def build_tokenizer_tab():
+ with gr.Tab(t("tab.tokenizer")) as tab:
+ register_translatable(tab, "tab.tokenizer")
+ intro = gr.Markdown(t("tok.intro"))
+ register_translatable(intro, "tok.intro")
+
+ with gr.Row():
+ paths = gr.Textbox(label=t("tok.paths"), lines=4, scale=3,
+ placeholder="./dataset/corpus/\n./dataset/*.jsonl\n./dataset/*.parquet")
+ with gr.Column(scale=2):
+ out_dir = gr.Textbox(label=t("tok.out_dir"), value="./out/tokenizer")
+ fields = gr.Textbox(label=t("tok.fields"), placeholder="text, messages, prompt, response")
+ with gr.Row():
+ vocab = gr.Number(label=t("tok.vocab_size"), value=16384, precision=0)
+ per_source = gr.Number(label=t("tok.per_source"), value=400_000, precision=0)
+ temperature = gr.Slider(0.1, 1.0, value=0.5, step=0.05, label=t("tok.temperature"))
+ with gr.Row():
+ build_btn = gr.Button(t("tok.build"), variant="primary")
+ load_btn = gr.Button(t("tok.load_report"))
+ status = gr.Textbox(label=t("tok.status"), interactive=False)
+ log_box = gr.Textbox(label=t("tok.log"), lines=12, interactive=False, autoscroll=True)
+ summary = gr.JSON(label=t("tok.summary"), value={})
+ table = gr.Dataframe(headers=EVAL_COLUMNS, label=t("tok.eval"), interactive=False)
+ for comp, key in ((paths, "tok.paths"), (out_dir, "tok.out_dir"), (fields, "tok.fields"),
+ (vocab, "tok.vocab_size"), (per_source, "tok.per_source"),
+ (temperature, "tok.temperature"), (build_btn, "tok.build"),
+ (load_btn, "tok.load_report"), (status, "tok.status"), (log_box, "tok.log"),
+ (summary, "tok.summary"), (table, "tok.eval")):
+ register_translatable(comp, key)
+
+ def start(p, o, v, n, f, temp):
+ field_list = [x.strip() for x in (f or "").split(",") if x.strip()]
+ outcome = _job.start(parse_paths(p), (o or "").strip(), int(v or 16384), int(n or 400_000),
+ field_list, float(temp or 0.5))
+ if outcome != "started":
+ return t(f"tok.err.{outcome}")
+ return _status_text("running")
+
+ def show(o):
+ report = read_report(o)
+ if not report:
+ return t("tok.err.no_report"), {}, []
+ return t("tok.state.loaded"), report_summary(report), report_rows(report.get("heldout_eval"))
+
+ def poll(o):
+ state, text = _job.snapshot()
+ if state in ("done",) and _job.out_dir:
+ report = read_report(_job.out_dir)
+ return (_status_text(state), text, report_summary(report),
+ report_rows(report.get("heldout_eval")))
+ message = _status_text(state) + (f" — {_job.error}" if _job.error else "")
+ return message, text, gr.update(), gr.update()
+
+ build_btn.click(fn=start, inputs=[paths, out_dir, vocab, per_source, fields, temperature],
+ outputs=[status])
+ load_btn.click(fn=show, inputs=[out_dir], outputs=[status, summary, table])
+ timer = gr.Timer(2.0)
+ timer.tick(fn=poll, inputs=[out_dir], outputs=[status, log_box, summary, table])
+ return tab
diff --git a/ui/tabs/train_tab.py b/ui/tabs/train_tab.py
index 07fc397..ee3d46b 100644
--- a/ui/tabs/train_tab.py
+++ b/ui/tabs/train_tab.py
@@ -80,7 +80,14 @@
"kv_latent_dim",
"rope_dim",
"num_attention_heads",
- "num_key_value_heads",
+ "num_key_value_heads", "head_dim", "use_moe", "use_hybrid_attention", "tie_word_embeddings",
+ "architecture_version", "recurrent_enabled", "recurrent_groups", "recurrent_depth",
+ "prelude_layers", "coda_layers", "layer_aware_lookahead", "layer_aware_cache",
+ "modalities", "image_patch_size", "modality_fusion_heads",
+ # v3 semantics that do not change tensor shapes but do change what the
+ # weights compute; a stage must inherit them from its init checkpoint.
+ "recurrent_attention_mode", "rope_layout", "rope_theta", "sliding_window_size",
+ "intermediate_size", "router_scope",
)
STAGE_HELP_TEXT = {
"pretrain": {
@@ -444,6 +451,8 @@ def _effective_training_config(
"teacher_path",
"data_path",
"data_path_source",
+ "lambda_router_anchor",
+ "stage_overrides_applied",
]
out = {
"mode": mode,
@@ -538,6 +547,13 @@ def _planned_optimizer_updates(train_steps: int, accumulation_steps: int) -> int
return (train_steps + accum - 1) // accum if train_steps else 0
+_STAGE_OVERRIDE_KEYS = frozenset((
+ "learning_rate", "batch_size", "accumulation_steps", "epochs", "max_steps", "max_seq_len",
+ "weight_decay", "grad_clip", "save_interval", "lambda_router_anchor", "beta", "alpha",
+ "lambda_or", "temperature", "num_generations", "max_gen_len",
+))
+
+
def _prepare_training_run_config(
cfg: dict | None,
train_mode: str,
@@ -552,6 +568,11 @@ def _prepare_training_run_config(
) -> tuple[dict, dict]:
merged = dict(MINIMIND_MOE_DEFAULTS)
merged.update(dict(cfg) if cfg else {})
+ # Per-stage hyperparameters (Config tab → auto plan) override the shared
+ # values for this stage only; architecture keys are never stage-specific.
+ stage_values = (merged.get("stage_overrides") or {}).get(train_mode) or {}
+ merged.update({k: v for k, v in stage_values.items() if k in _STAGE_OVERRIDE_KEYS})
+ merged["stage_overrides_applied"] = sorted(k for k in stage_values if k in _STAGE_OVERRIDE_KEYS)
cfg = merged
requested_backend = (selected_backend or "auto").strip().lower() or "auto"
resolved_backend, resolved_device = resolve_training_device(requested_backend)
@@ -891,6 +912,8 @@ def __init__(self):
self.last_sample: str = ""
self.last_sample_step: int = 0
self.effective_config: dict = {}
+ self.tokenizer_path: str = ""
+ self.vocab_size: int = 0
def _normalise_effective_cpu_config(self, cfg: dict, resolved_backend: str) -> None:
cfg["cpu_budget_percent"] = _cpu_budget_for_backend(cfg, resolved_backend)
@@ -908,6 +931,8 @@ def start(self, cfg: dict, mode: str):
self._metrics.clear()
self.total_steps = 0
self.t_start = time.monotonic()
+ self.tokenizer_path = str(cfg.get("tokenizer_path") or "").strip()
+ self.vocab_size = int(cfg.get("vocab_size") or 0)
self._thread = threading.Thread(
target=self._run, args=(cfg, mode), daemon=True
)
@@ -972,7 +997,7 @@ def _resolve_default_init_path(self, save_dir: str, mode: str, hidden_size: int)
def _topology_mismatches(self, sniffed: dict, model_cfg_kwargs: dict) -> list[str]:
mismatches = []
for k in CHECKPOINT_TOPOLOGY_KEYS:
- if k in sniffed and k in model_cfg_kwargs and int(sniffed[k]) != int(model_cfg_kwargs[k]):
+ if k in sniffed and k in model_cfg_kwargs and sniffed[k] != model_cfg_kwargs[k]:
mismatches.append(f"{k}: ckpt={sniffed[k]} != ui={model_cfg_kwargs[k]}")
return mismatches
@@ -983,11 +1008,9 @@ def _adopt_checkpoint_topology(self, model_cfg_kwargs: dict, sniffed: dict) -> d
continue
current = model_cfg_kwargs.get(key)
value = sniffed[key]
- if current is None or int(current) != int(value):
+ if current is None or current != value:
adopted[key] = value
model_cfg_kwargs[key] = value
- if "moe_intermediate_size" in sniffed:
- model_cfg_kwargs["intermediate_size"] = sniffed["moe_intermediate_size"]
return adopted
def _build_stage_args(self, cfg: dict, mode: str, save_dir: str, hidden_size: int):
@@ -1122,8 +1145,8 @@ def _generate_live_sample(self, model, device: str, cfg: dict, mode: str):
with torch.no_grad():
gen = ids.clone()
for _ in range(80):
- with autocast_context(device, cfg.get("dtype", "auto")):
- o, _ = model(gen, use_cache=False)
+ with autocast_context(device, cfg.get("dtype", "auto"), model=model):
+ o = model(gen, use_cache=False)
nxt = o.logits[:, -1, :].argmax(dim=-1, keepdim=True)
gen = torch.cat([gen, nxt], dim=-1)
if int(nxt.item()) == (tokenizer.eos_token_id or -1):
@@ -1149,58 +1172,21 @@ def _run(self, cfg: dict, mode: str):
from chronos.model.moe_chronos import ChronosMOEFeedForward
self._put(f"[{mode.upper()}] Building model...")
- # Pass the FULL config dict so user-set arch overrides (e.g.
- # moe_intermediate_size, num_attention_heads, vocab_size) are
- # actually honored. Previously this method only forwarded a
- # hardcoded subset, so the trainer silently used minimind's
- # auto-derived intermediate_size while the UI estimator showed
- # the user's slider — causing 10x param-count mismatches.
- model_cfg_kwargs = {
- "hidden_size": cfg.get("hidden_size", 512),
- "num_hidden_layers": cfg.get("num_hidden_layers", 8),
- "num_experts": cfg.get("num_experts", 4),
- "num_experts_per_tok": cfg.get("num_experts_per_tok", 1),
- "num_shared_experts": cfg.get("num_shared_experts", 1),
- "lookahead_steps": cfg.get("lookahead_steps", 2),
- "kv_latent_dim": cfg.get("kv_latent_dim", 64),
- "sliding_window_size": cfg.get("sliding_window_size", 2048),
- "lambda_balance": cfg.get("lambda_balance", 5e-4),
- "lambda_temporal": cfg.get("lambda_temporal", 3e-3),
- "lambda_lookahead": cfg.get("lambda_lookahead", 0.1),
- "lambda_lookahead_topk": cfg.get("lambda_lookahead_topk", 0.15),
- "lambda_lookahead_union": cfg.get("lambda_lookahead_union", 0.05),
- "lambda_router_locality": cfg.get("lambda_router_locality", 0.02),
- "fallback_mask_prob": cfg.get("fallback_mask_prob", 0.08),
- "vram_budget_gb": cfg.get("vram_budget_gb", 4.0),
- "use_hybrid_attention": cfg.get("use_hybrid_attention", True),
- "use_moe": True,
- }
- # Optional overrides: only forward when the user actually set them.
- # For the "auto" sentinel fields (0 = let MiniMindConfig derive
- # it), we skip forwarding when value is 0 so ceil(H·π/64)·64
- # takes effect instead of Linear(H, 0) crashing MPS init.
- AUTO_SENTINEL_KEYS = {"intermediate_size", "moe_intermediate_size"}
- for opt_key, cfg_key in [
- ("num_attention_heads", "num_attention_heads"),
- ("num_key_value_heads", "num_key_value_heads"),
- ("rope_dim", "rope_dim"),
- ("vocab_size", "vocab_size"),
- ("max_position_embeddings", "max_seq_len"),
- ("intermediate_size", "moe_intermediate_size"),
- ("moe_intermediate_size", "moe_intermediate_size"),
- ("lambda_router_anchor", "lambda_router_anchor"),
- ("pinned_memory_max_fraction", "pinned_memory_max_fraction"),
- ("storage_format", "storage_format"),
- ("tie_word_embeddings", "tie_word_embeddings"),
- ]:
- if cfg_key not in cfg:
- continue
- val = cfg[cfg_key]
- if val in (None, ""):
- continue
- if opt_key in AUTO_SENTINEL_KEYS and val == 0:
- continue
- model_cfg_kwargs[opt_key] = val
+ from chronos.config_schema import model_config_kwargs
+ model_cfg_kwargs = model_config_kwargs(cfg)
+ model_cfg_kwargs.setdefault("hidden_size", 512)
+ model_cfg_kwargs.setdefault("num_hidden_layers", 8)
+ detected_modalities_pre = []
+ if data_path_for_cleanup and os.path.exists(data_path_for_cleanup):
+ try:
+ from chronos.data.flexible_dataset import dataset_modality_summary
+ detected_modalities_pre = dataset_modality_summary(data_path_for_cleanup).get(
+ "detected_modalities", []
+ )
+ except Exception as exc:
+ self._put(f"[data] modality detection warning: {exc}")
+ if cfg.get("auto_detect_modalities", True):
+ model_cfg_kwargs["modalities"] = detected_modalities_pre
save_dir = cfg.get("save_dir", "./out")
init_weight = (cfg.get("init_weight") or "").strip()
load_path = ""
@@ -1276,6 +1262,13 @@ def _run(self, cfg: dict, mode: str):
requested_backend = (cfg.get("train_backend") or "auto").strip().lower() or "auto"
resolved_backend, device = resolve_training_device(requested_backend)
+ if resolved_backend == "mlx" and model_cfg.modalities:
+ if requested_backend != "auto":
+ raise ValueError("MLX 尚未实现多模态编码器训练,请选择 auto 或 PyTorch MPS/CUDA 后端。")
+ supported = set(training_available())
+ target = next(name for name in ("cuda", "xpu", "mps", "cpu") if name in supported)
+ resolved_backend, device = resolve_training_device(target)
+ self._put(f"[data] Modalities {model_cfg.modalities}: using {resolved_backend} for encoder training.")
cfg["train_backend"] = requested_backend
cfg["device"] = device
cfg["dtype"] = _normalize_dtype_for_trainer(cfg.get("dtype"))
@@ -1324,12 +1317,34 @@ def _run(self, cfg: dict, mode: str):
else:
tokenizer = self._load_tokenizer()
from chronos.data.flexible_dataset import (
- FlexibleDataset, StreamingSFTDataset, StreamingDPODataset,
+ FlexibleDataset, StreamingSFTDataset, MultimodalStreamingDataset,
+ MultimodalStreamingSFTDataset, StreamingDPODataset,
+ dataset_modality_summary, multimodal_collate,
)
+ modality_summary = dataset_modality_summary(data_path)
+ detected_modalities = modality_summary.get("detected_modalities", [])
if mode == "pretrain":
- full_ds = FlexibleDataset(data_path, tokenizer, max_length=max_seq_len)
+ if cfg.get("auto_detect_modalities", True) and detected_modalities:
+ model_cfg_kwargs["modalities"] = detected_modalities
+ full_ds = (
+ MultimodalStreamingDataset(data_path, tokenizer, max_length=max_seq_len,
+ image_size=int(cfg.get("image_size", 224)),
+ audio_sample_rate=int(cfg.get("audio_sample_rate", 16000)),
+ max_audio_seconds=float(cfg.get("max_audio_seconds", 30.0)),
+ media_error_policy=cfg.get("media_error_policy", "warn"))
+ if detected_modalities else FlexibleDataset(data_path, tokenizer, max_length=max_seq_len)
+ )
elif mode in {"sft", "distill"}:
- full_ds = StreamingSFTDataset(data_path, tokenizer, max_length=max_seq_len)
+ if cfg.get("auto_detect_modalities", True) and detected_modalities:
+ model_cfg_kwargs["modalities"] = detected_modalities
+ full_ds = (
+ MultimodalStreamingSFTDataset(data_path, tokenizer, max_length=max_seq_len,
+ image_size=int(cfg.get("image_size", 224)),
+ audio_sample_rate=int(cfg.get("audio_sample_rate", 16000)),
+ max_audio_seconds=float(cfg.get("max_audio_seconds", 30.0)),
+ media_error_policy=cfg.get("media_error_policy", "warn"))
+ if detected_modalities else StreamingSFTDataset(data_path, tokenizer, max_length=max_seq_len)
+ )
elif mode in {"dpo", "orpo"}:
full_ds = StreamingDPODataset(data_path, tokenizer, max_length=max_seq_len)
else:
@@ -1352,6 +1367,7 @@ def _run(self, cfg: dict, mode: str):
DataLoader(
train_ds,
batch_size=batch_size,
+ collate_fn=multimodal_collate if detected_modalities and mode in {"pretrain", "sft", "distill"} else None,
**dataloader_kwargs(device=device, num_workers=cfg.get("num_workers", "auto"), shuffle=True),
)
if train_ds is not None else None
@@ -1360,6 +1376,7 @@ def _run(self, cfg: dict, mode: str):
DataLoader(
val_ds,
batch_size=batch_size,
+ collate_fn=multimodal_collate if detected_modalities and mode in {"pretrain", "sft", "distill"} else None,
**dataloader_kwargs(device=device, num_workers=cfg.get("num_workers", "auto"), shuffle=False),
)
if val_ds is not None else None
@@ -1413,10 +1430,16 @@ def _run(self, cfg: dict, mode: str):
if mlx_checkpoint_path:
self._put(f"[MLX {mode.upper()}] Loading checkpoint → {mlx_checkpoint_path}")
else:
- self._put(f"[MLX {mode.upper()}] Starting from current in-memory random init.")
+ self._put(f"[MLX {mode.upper()}] Starting from random init.")
def _mlx_progress(event: dict):
kind = str(event.get("event", "step"))
+ if kind == "validation":
+ value = float(event["ce"])
+ self._put(f"[MLX {mode.upper()} validation] step={event['step']} CE={value:.4f} tokens={event['tokens']}")
+ self._put_metric({"step": event["step"], "total": value, "primary": value,
+ "ce": value, "aux": 0., "temporal": 0., "tps": 0., "val": True, "val_loss": value})
+ return
if kind == "start":
total = int(event.get("steps", 0) or 0)
if total > 0:
@@ -1426,6 +1449,7 @@ def _mlx_progress(event: dict):
f"total_steps={self.total_steps} accum={event.get('accumulation_steps', '')} "
f"save_interval={event.get('save_interval', '')}"
)
+ self._put(f"[MLX optimizer] {event.get('optimizer', '')}")
return
if kind == "step":
step = int(event.get("step", event.get("steps", 0)) or 0)
@@ -1439,17 +1463,25 @@ def _mlx_progress(event: dict):
metric = {
"step": step,
"total": loss,
- "primary": float(event.get("ce", loss) or loss),
- "secondary": float(event.get("aux", 0.0) or 0.0),
- "tertiary": float(event.get("temporal", 0.0) or 0.0),
- "ce": float(event.get("ce", loss) or loss),
+ "primary": float(event.get("primary", event.get("ce", loss))),
+ "secondary": float(event.get("secondary", event.get("aux", 0.0))),
+ "tertiary": float(event.get("tertiary", event.get("temporal", 0.0))),
+ "ce": float(event.get("ce", 0.0)),
"aux": float(event.get("aux", 0.0) or 0.0),
"temporal": float(event.get("temporal", 0.0) or 0.0),
"tps": tps,
+ "tokens_per_s": float(event.get("tokens_per_s", 0)),
+ "grad_norm": float(event.get("grad_norm", 0)),
+ "loss_components": event.get("loss_components", {}),
+ "recurrent_loss": float(event.get("recurrent_loss", 0)),
+ "lookahead_loss": float(event.get("lookahead_loss", 0)),
}
self._put_metric(metric)
self._put(
f"[MLX {mode.upper()}] step={step}/{self.total_steps} loss={loss:.4f} "
+ f"primary={metric['primary']:.4f} aux={metric['aux']:.4f} "
+ f"recurrent={metric['recurrent_loss']:.4f} grad_norm={metric['grad_norm']:.3f} "
+ f"tokens/s={metric['tokens_per_s']:.0f} "
f"steps/s={tps:.2f} lr={float(event.get('lr', 0.0) or 0.0):.2e} "
f"elapsed={float(event.get('elapsed_s', 0.0) or 0.0):.1f}s"
)
@@ -1480,7 +1512,7 @@ def _mlx_progress(event: dict):
return
if kind == "failed":
self._put(
- f"[MLX {mode.upper()}] stopped after rollback limit at "
+ f"[MLX {mode.upper()}] failed ({event.get('reason', 'numerical_error')}) at "
f"step={int(event.get('step', 0) or 0)}/{int(event.get('steps', self.total_steps) or self.total_steps)}"
)
return
@@ -1496,6 +1528,7 @@ def _mlx_progress(event: dict):
checkpoint_path=mlx_checkpoint_path,
save_dir=save_dir,
loader=loader,
+ validation_loader=val_loader,
prompts=prompts,
teacher_path=stage_args.teacher_path if mode == "distill" else None,
args=stage_args,
@@ -1515,7 +1548,7 @@ def _mlx_progress(event: dict):
if result.stopped:
self.status = "error"
self._put(
- f"[MLX {mode.upper()}] Stopped after rollback limit in {elapsed:.1f}s. "
+ f"[MLX {mode.upper()}] Failed ({getattr(result, 'failure_reason', 'numerical_error')}) in {elapsed:.1f}s. "
f"Last finite checkpoint → {result.checkpoint_path}"
)
if result.checkpoint_saved:
@@ -1527,10 +1560,19 @@ def _mlx_progress(event: dict):
return
model = model.to(device)
- from chronos.trainer.optim_utils import build_optimizer, get_lr, apply_lr
- optimizer = build_optimizer(model, lr=base_lr, weight_decay=weight_decay)
- train_autocast = autocast_context(device, cfg.get("dtype", "auto"))
- scaler = grad_scaler(device, cfg.get("dtype", "auto"))
+ from chronos.trainer.optim_utils import build_configured_optimizer, get_lr, apply_lr
+ optimizer = build_configured_optimizer(
+ model, model_cfg, lr=base_lr, weight_decay=weight_decay,
+ )
+ from chronos.trainer.paged_store import PagedTrainingController
+ paged_training = PagedTrainingController(model, model_cfg)
+ # One compute dtype end to end (weights, activations, KV) with
+ # FP32 masters in the optimizer; no autocast per-op casts.
+ from chronos.trainer.precision_training import attach_precision
+ precision, train_autocast, scaler = attach_precision(
+ model, optimizer, device, cfg.get("dtype", "auto"))
+ self._put(f"[precision] {precision.backend}/{precision.device}: compute={precision.compute} "
+ f"master={precision.master} ({precision.reason})")
model.train()
global_step = 0
@@ -1546,12 +1588,27 @@ def _eval_val() -> float:
model.eval()
tot, n = 0.0, 0
with torch.no_grad():
- for k, (vx, vy) in enumerate(val_loader):
+ for k, vbatch in enumerate(val_loader):
if k >= 50: # cap eval cost
break
+ if isinstance(vbatch, dict):
+ vx, vy = vbatch["input_ids"], vbatch["labels"]
+ vmedia, vmedia_mask = vbatch.get("modality_inputs"), vbatch.get("modality_mask")
+ else:
+ vx, vy = vbatch
+ vmedia = vmedia_mask = None
vx = vx.to(device); vy = vy.to(device)
- with autocast_context(device, cfg.get("dtype", "auto")):
- vout, _ = model(vx, labels=vy)
+ if vmedia:
+ vmedia = {key: value.to(device) for key, value in vmedia.items()}
+ if vmedia_mask is not None:
+ vmedia_mask = vmedia_mask.to(device)
+ with autocast_context(device, cfg.get("dtype", "auto"), model=model):
+ eval_masks = paged_training.masks_for_forward(device, allow_evict=False)
+ vout = model(
+ vx, labels=vy,
+ modality_inputs=vmedia, modality_mask=vmedia_mask,
+ available_expert_masks=eval_masks,
+ )
tot += float(vout.loss.item()); n += 1
model.train()
return tot / max(n, 1)
@@ -1564,19 +1621,35 @@ def _eval_val() -> float:
trainer = ChronosSFTTrainer(model, model_cfg, stage_args, tokenizer)
if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
first_batch = next(iter(loader))
- trainer.set_calibration_batch(first_batch[0])
+ if isinstance(first_batch, dict):
+ trainer.set_calibration_batch(
+ first_batch["input_ids"],
+ modality_inputs=first_batch.get("modality_inputs"),
+ modality_mask=first_batch.get("modality_mask"),
+ )
+ else:
+ trainer.set_calibration_batch(first_batch[0])
self._put("[SFT] Router anchor reference captured.")
iters = _planned_epoch_iters(loader, stage_args.steps)
stage_global_step = 0
for epoch in range(stage_args.epochs):
epoch_start = time.monotonic()
- for step_idx, (ids, labels) in enumerate(loader, start=1):
+ for step_idx, batch in enumerate(loader, start=1):
if self._stop.is_set():
break
if step_idx > iters or (stage_args.steps is not None and stage_global_step >= stage_args.steps):
break
stage_global_step += 1
- result = trainer.train_step(ids, labels, stage_global_step, self.total_steps)
+ if isinstance(batch, dict):
+ ids, labels = batch["input_ids"], batch["labels"]
+ result = trainer.train_step(
+ ids, labels, stage_global_step, self.total_steps,
+ modality_inputs=batch.get("modality_inputs"),
+ modality_mask=batch.get("modality_mask"),
+ )
+ else:
+ ids, labels = batch
+ result = trainer.train_step(ids, labels, stage_global_step, self.total_steps)
if step_idx % stage_args.log_interval == 0 or step_idx == iters:
elapsed = max(time.monotonic() - epoch_start, 1e-6)
self._record_stage_metric(mode, stage_global_step, result, tps=step_idx / elapsed)
@@ -1691,19 +1764,33 @@ def _eval_val() -> float:
trainer = ChronosDistillTrainer(model, teacher, model_cfg, stage_args, tokenizer)
if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
first = next(iter(loader))
- trainer.set_calibration_batch(first[0])
+ if isinstance(first, dict):
+ trainer.set_calibration_batch(
+ first["input_ids"], modality_inputs=first.get("modality_inputs"),
+ modality_mask=first.get("modality_mask"),
+ )
+ else:
+ trainer.set_calibration_batch(first[0])
self._put("[DISTILL] Router anchor reference captured.")
iters = _planned_epoch_iters(loader, stage_args.steps)
stage_global_step = 0
for epoch in range(stage_args.epochs):
epoch_start = time.monotonic()
- for step_idx, (ids, labels) in enumerate(loader, start=1):
+ for step_idx, batch in enumerate(loader, start=1):
if self._stop.is_set():
break
if step_idx > iters or (stage_args.steps is not None and stage_global_step >= stage_args.steps):
break
stage_global_step += 1
- result = trainer.train_step(ids, labels, stage_global_step, self.total_steps)
+ if isinstance(batch, dict):
+ result = trainer.train_step(
+ batch["input_ids"], batch["labels"], stage_global_step, self.total_steps,
+ modality_inputs=batch.get("modality_inputs"),
+ modality_mask=batch.get("modality_mask"),
+ )
+ else:
+ ids, labels = batch
+ result = trainer.train_step(ids, labels, stage_global_step, self.total_steps)
if step_idx % stage_args.log_interval == 0 or step_idx == iters:
elapsed = max(time.monotonic() - epoch_start, 1e-6)
self._record_stage_metric(mode, stage_global_step, result, tps=step_idx / elapsed)
@@ -1724,17 +1811,33 @@ def _eval_val() -> float:
for epoch in range(epochs):
if self._stop.is_set() or stop_outer:
break
- for step, (input_ids, labels) in enumerate(loader, start=1):
+ for step, batch in enumerate(loader, start=1):
if self._stop.is_set():
break
if max_steps and global_step >= max_steps:
stop_outer = True
break
+ if isinstance(batch, dict):
+ input_ids, labels = batch["input_ids"], batch["labels"]
+ media, media_mask = batch.get("modality_inputs"), batch.get("modality_mask")
+ else:
+ input_ids, labels = batch
+ media = media_mask = None
input_ids = input_ids.to(device)
labels = labels.to(device)
+ if media:
+ media = {key: value.to(device) for key, value in media.items()}
+ if media_mask is not None:
+ media_mask = media_mask.to(device)
with train_autocast:
- out, lp = model(input_ids, labels=labels)
+ paged_masks = paged_training.masks_for_forward(device)
+ result = model(
+ input_ids, labels=labels,
+ modality_inputs=media, modality_mask=media_mask,
+ available_expert_masks=paged_masks,
+ )
+ out, lp = result.legacy_tuple()
moe_layers = [l.mlp for l in model.model.layers
if isinstance(l.mlp, ChronosMOEFeedForward)]
@@ -1786,6 +1889,7 @@ def _eval_val() -> float:
model.parameters(),
1.0,
)
+ paged_training.observe()
if global_step % log_interval == 0:
now = time.monotonic()
@@ -1865,8 +1969,8 @@ def _eval_val() -> float:
with torch.no_grad():
gen = ids.clone()
for _ in range(80):
- with autocast_context(device, cfg.get("dtype", "auto")):
- o, _ = model(gen, use_cache=False)
+ with autocast_context(device, cfg.get("dtype", "auto"), model=model):
+ o = model(gen, use_cache=False)
nxt = o.logits[:, -1, :].argmax(dim=-1, keepdim=True)
gen = torch.cat([gen, nxt], dim=-1)
if int(nxt.item()) == (tokenizer.eos_token_id or -1):
@@ -1919,7 +2023,24 @@ def _synthetic_loader(self, vocab_size, seq_len, batch_size, n=50):
def _load_tokenizer(self):
from transformers import AutoTokenizer
- return AutoTokenizer.from_pretrained(chronos.deps.get_tokenizer_path())
+ path = resolve_tokenizer_dir(self.tokenizer_path)
+ tok = AutoTokenizer.from_pretrained(path)
+ if self.vocab_size and len(tok) > self.vocab_size:
+ raise ValueError(
+ f"tokenizer at {path} has {len(tok)} tokens but the model vocab_size is "
+ f"{self.vocab_size}; set vocab_size >= {len(tok)} or pick the matching tokenizer"
+ )
+ return tok
+
+
+def resolve_tokenizer_dir(configured: str | None) -> str:
+ """Config ``tokenizer_path`` (a Chronos Tokenizer Builder output) wins; else minimind."""
+ configured = os.path.expanduser(str(configured or "").strip())
+ if configured:
+ if not os.path.isfile(os.path.join(configured, "tokenizer.json")):
+ raise FileNotFoundError(f"tokenizer_path {configured!r} has no tokenizer.json")
+ return configured
+ return chronos.deps.get_tokenizer_path()
_session = TrainSession()