Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lqh/skills/job_recovery/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Never diagnose from the run name or from memory. Read the status card first.
| **preempted** | the provider reclaimed the GPU | Infrastructure. One fresh attempt is reasonable — smaller if the run is long. On a second preemption, shrink the exposure instead of retrying. |
| **orphaned** | the sandbox stopped appearing in the provider's live list, with no terminal event | An observation, not a cause. Usually a preemption — but a workload that died while the backend was restarting looks identical from here. Check `artifacts` and `stderr.log` FIRST; if nothing points at the run, treat it as preempted. |
| **timeout** | hit the wall-clock cap | Sizing, NOT infrastructure — the cap was consented to at submit time and the job outgrew it. An identical resubmit fails identically. Shrink first, always. |
| **oom** | memory exceeded | Config. Lower `per_device_train_batch_size` (raise `gradient_accumulation_steps` to keep the effective batch size), lower `max_seq_length`, enable gradient checkpointing, or use a smaller model. |
| **oom** | memory exceeded | Config. Lower `per_device_train_batch_size` (raise `gradient_accumulation_steps` to keep the effective batch size), lower `max_seq_length` (a `start_training` argument, SFT only), enable gradient checkpointing, or use a smaller model. |
| **crashed** | the trainer raised | Read `stderr.log`. Fix the exception. Do not resubmit unchanged. |
| **config** | bad input | Fix the input. Do not blame infrastructure. |

Expand Down
1 change: 1 addition & 0 deletions lqh/skills/train/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ Defaults live in `lqh/train/defaults.py`; a sweep overrides `learning_rate` /
for ~20 updates and look like bad data. Two caveats: the 16 floor means a
few-hundred-row dataset still lands below the target, and the ~100 target is a
judgement call rather than a measured optimum (see `defaults.py`).
- **`max_seq_length`** (default: 2048, `MAX_SEQ_LENGTH` in `defaults.py`; 512–131072) — SFT only: prompt plus response token budget; raise it only when rows exceed it. DPO/GRPO reject it — their internal budgets mean the total-length contract would not hold.
- **`num_iterations`** (default: 5) — DPO only.
- **`dpo_beta`** (default: 0.1) — DPO KL anchor strength.

Expand Down
12 changes: 12 additions & 0 deletions lqh/tools/definitions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from lqh.train.defaults import MAX_SEQ_LENGTH, MAX_SEQ_LENGTH_BOUNDS


METADATA_KEY = "x-lqh"

Expand Down Expand Up @@ -1746,6 +1748,16 @@ def _build_all_tools(*, auto_mode: bool = False) -> list[dict]:
"\'training isn\'t working\' branch."
),
},
"max_seq_length": {
"type": "integer",
"minimum": MAX_SEQ_LENGTH_BOUNDS[0],
"maximum": MAX_SEQ_LENGTH_BOUNDS[1],
"description": (
"SFT only: max tokens for prompt plus response "
f"(default {MAX_SEQ_LENGTH}). Raise only when "
"rows exceed it."
),
},
"num_iterations": {
"type": "integer",
"description": (
Expand Down
23 changes: 23 additions & 0 deletions lqh/tools/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5101,6 +5101,7 @@ async def handle_start_training(
lora: bool = True,
num_epochs: int | None = None,
learning_rate: float | None = None,
max_seq_length: int | None = None,
num_iterations: int = 5,
dpo_beta: float = 0.1,
golden_source: str = "dataset",
Expand Down Expand Up @@ -5168,6 +5169,26 @@ async def handle_start_training(
on-policy DPO builds its preference pairs from scored rollouts every
iteration, so a scorer is mandatory for DPO to run at all.
"""
if max_seq_length is not None:
# DPO and GRPO consume training.max_seq_length with different
# semantics (rollout max_new_tokens; prompt cap next to a separate
# completion budget), so the documented prompt-plus-response
# contract holds for SFT alone.
if type != "sft":
return ToolResult.fail(
"config",
"Error: max_seq_length applies to SFT only.",
)
from lqh.train.defaults import MAX_SEQ_LENGTH_BOUNDS

lo, hi = MAX_SEQ_LENGTH_BOUNDS
if not lo <= max_seq_length <= hi:
return ToolResult.fail(
"config",
f"Error: max_seq_length must be between {lo} and {hi} "
f"tokens, got {max_seq_length}.",
)

# Compute target is fixed per project — there is no per-call override.
# When the project has a real choice to make (a BYOC remote and/or a
# local GPU) but hasn't pinned a target yet, defer to the one-time
Expand Down Expand Up @@ -5480,6 +5501,8 @@ async def handle_start_training(
}
if epochs is not None:
config["training"]["num_epochs"] = epochs
if max_seq_length is not None:
config["training"]["max_seq_length"] = max_seq_length
if is_vision:
config["modality"] = "vision"
# Per-image token budget for the processor. Effective text budget
Expand Down
6 changes: 6 additions & 0 deletions lqh/train/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@

MAX_SEQ_LENGTH = 2048

# Accepted range for a caller's max_seq_length override (start_training).
# The ceiling is the LFM2.5 architectural limit (max_position_embeddings);
# whether a run fits in memory is the calibration probe's problem, not this
# guard's — it exists to catch nonsense values, not to police training.
MAX_SEQ_LENGTH_BOUNDS = (512, 131072)

# NOT measured — carried over unchanged, and the study cannot settle it.
# hpd-stageA's 3-epoch configs did win, but that comparison is confounded: its
# sweep derived one batch from the 3-epoch default and then overrode epochs
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_start_training_sweep_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,45 @@ def test_dpo_config_carries_no_num_epochs(launch):
rec = launch(type="on_policy_dpo")
assert "num_epochs" not in rec["config"]["base_config"]["training"]
assert rec["config"]["base_config"]["num_iterations"] == 5


def test_max_seq_length_defaults_to_the_defaults_module(launch):
from lqh.train import defaults

training = launch(type="sft")["config"]["training"]
assert training["max_seq_length"] == defaults.MAX_SEQ_LENGTH


def test_explicit_max_seq_length_wins_over_the_default(launch):
training = launch(type="sft", max_seq_length=4096)["config"]["training"]
assert training["max_seq_length"] == 4096


def test_max_seq_length_out_of_bounds_is_rejected_before_launch(launch):
from lqh.train.defaults import MAX_SEQ_LENGTH_BOUNDS

lo, hi = MAX_SEQ_LENGTH_BOUNDS
for bad in (lo - 1, hi + 1):
rec = launch(type="sft", max_seq_length=bad)
assert rec["result"].ok is False
assert rec["result"].error_kind == "config"
assert "max_seq_length" in rec["result"].content
assert "config" not in rec


def test_max_seq_length_is_rejected_outside_sft(launch):
rec = launch(type="on_policy_dpo", max_seq_length=4096)
assert rec["result"].ok is False
assert rec["result"].error_kind == "config"
assert "SFT only" in rec["result"].content
assert "config" not in rec


def test_max_seq_length_schema_bounds_match_the_handler():
from lqh.tools.definitions import get_all_tools
from lqh.train.defaults import MAX_SEQ_LENGTH_BOUNDS

tool = next(t for t in get_all_tools() if t["function"]["name"] == "start_training")
prop = tool["function"]["parameters"]["properties"]["max_seq_length"]
assert prop["type"] == "integer"
assert (prop["minimum"], prop["maximum"]) == MAX_SEQ_LENGTH_BOUNDS
Loading