longcat: training wants 8n+1 frames, generation wants 4n+1 (v0.2.60)

Two rules, and I had conflated them. Generation obeys the VAE's 4n+1 — 93, 173 and
333 are whole LongCat segments, which is what the dataset builder and the township
templates use. TRAINING is stricter: SimpleTuner's longcat_video family rounds DOWN
to 8n+1 (frames % 8 == 1) and says nothing about it.

So a job asking for 93 trained at 89, the config recorded 93, and frame_problems()
validated the generation rule and passed it. Three places agreeing on a number that
was not the one being used.

training_frames() now applies the same rounding SimpleTuner does — verified against
its adjust_video_frames() for every count from 0 to 400 — and the job is adjusted
BEFORE validation, so a legal clip length is never rejected for being legal in the
other sense. The adjustment is emitted to the progress stream and the log:

  num_frames 93 -> 89 (8n+1)

frame_problems() now states the training rule and explains why the number looked
valid, rather than asserting a constraint the trainer does not use.

Found by reading SimpleTuner's model.py while answering a question about whether
clips are needed at all — not from a failure. It would have shipped as a quiet
discrepancy between the configured and actual frame count.

23 tests.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent ab28a07b
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.59" __version__ = "0.2.60"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -137,3 +137,61 @@ def test_the_config_is_also_written_where_the_default_lookup_finds_it(): ...@@ -137,3 +137,61 @@ def test_the_config_is_also_written_where_the_default_lookup_finds_it():
src = (ROOT / "tools" / "longcat_train.py").read_text(encoding="utf-8") src = (ROOT / "tools" / "longcat_train.py").read_text(encoding="utf-8")
assert 'fallback = workdir / "config"' in src assert 'fallback = workdir / "config"' in src
assert '(fallback / "config.json").write_text' in src assert '(fallback / "config.json").write_text' in src
# ── two frame rules, not one ────────────────────────────────────────────────
# Generation obeys the VAE's 4n+1 (93, 173, 333 are whole LongCat segments).
# TRAINING is stricter: SimpleTuner's longcat_video family rounds DOWN to 8n+1
# and says nothing, so a job asking for 93 trained at 89 and the config did not
# say so. Our validator was checking the generation rule.
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location("lc_train", ROOT / "tools" / "longcat_train.py")
LT = _ilu.module_from_spec(_spec)
sys.modules["lc_train"] = LT
_spec.loader.exec_module(LT)
@pytest.mark.parametrize("asked,expected", [
(93, 89), # a whole LongCat segment — legal to GENERATE, not to train at
(173, 169), # two segments
(333, 329), # four
(89, 89), (97, 97), (105, 105), # already 8n+1
(96, 89), (8, 1), (1, 1), (0, 1),
])
def test_the_training_frame_count_rounds_the_way_simpletuner_does(asked, expected):
assert LT.training_frames(asked) == expected
def test_it_matches_simpletuners_own_formula():
"""Mirrored from longcat_video/model.py: ((n - 1) // 8) * 8 + 1, floored at 1."""
for n in range(0, 400):
theirs = n if n % 8 == 1 else max(((n - 1) // 8) * 8 + 1, 1)
assert LT.training_frames(n) == max(theirs, 1), n
def test_a_rounded_count_always_validates():
for n in (93, 173, 333, 96, 7):
assert LT.frame_problems(LT.training_frames(n), "480x832") == []
def test_the_validator_now_states_the_training_rule():
problems = LT.frame_problems(93, "480x832")
assert problems and "8n+1" in problems[0]
assert "4n+1" in problems[0], "it should say why 93 looked valid"
def test_resolution_is_still_checked():
assert LT.frame_problems(89, "481x832")
assert LT.frame_problems(89, "480x833")
assert LT.frame_problems(89, "nonsense")
assert LT.frame_problems(89, "480x832") == []
def test_a_clip_length_is_not_rejected_merely_for_being_4n_plus_1():
"""93 is what the dataset builder uses. Blocking it would stop a run that
previously worked; it is adjusted and reported instead."""
src = (ROOT / "tools" / "longcat_train.py").read_text(encoding="utf-8")
assert "is not 8n+1, which training requires" in src
assert 'job["num_frames"] = trains_at' in src
...@@ -71,7 +71,7 @@ def build_config(job: dict) -> dict: ...@@ -71,7 +71,7 @@ def build_config(job: dict) -> dict:
"learning_rate": float(job.get("lr") or 1e-4), "learning_rate": float(job.get("lr") or 1e-4),
"seed": int(job.get("seed") or 42), "seed": int(job.get("seed") or 42),
"validation_resolution": job.get("resolution") or "480x832", "validation_resolution": job.get("resolution") or "480x832",
"validation_num_video_frames": int(job.get("num_frames") or 93), "validation_num_video_frames": training_frames(job.get("num_frames") or 93),
"tracker_project_name": "coderai-longcat-lora", "tracker_project_name": "coderai-longcat-lora",
# SimpleTuner has no default for this — it is a REQUIRED argument, and # SimpleTuner has no default for this — it is a REQUIRED argument, and
# omitting it fails at argparse with "the following arguments are # omitting it fails at argparse with "the following arguments are
...@@ -93,16 +93,37 @@ def build_config(job: dict) -> dict: ...@@ -93,16 +93,37 @@ def build_config(job: dict) -> dict:
return cfg return cfg
def training_frames(num_frames: int) -> int:
"""The frame count SimpleTuner will actually train at.
TWO rules apply and they are not the same. Generation obeys the VAE's 4n+1 —
93, 173, 333 are whole LongCat segments. TRAINING is stricter: SimpleTuner's
longcat_video family rounds DOWN to 8n+1 (frames % 8 == 1) and says nothing,
so asking for 93 trains at 89 and the number in the config is not the number
that ran. Rounding here means the config states what will happen.
"""
n = int(num_frames or 0)
if n < 1:
return 1
if n % 8 == 1:
return n
return max(((n - 1) // 8) * 8 + 1, 1)
def frame_problems(num_frames: int, resolution: str) -> list: def frame_problems(num_frames: int, resolution: str) -> list:
"""Constraints SimpleTuner's quickstart states, checked before a long run starts. """Constraints checked before a long run starts.
These are the VAE's, not preferences: ``(num_frames - 1)`` must be divisible by 4 — These are the model's, not preferences. Note the frame rule here is the
the same 4n+1 rule coderai already applies to Wan — and each side must be divisible TRAINING one (8n+1), which is stricter than the 4n+1 the VAE imposes on
by 16.""" generation — see training_frames(). Each side of the resolution must be
divisible by 16."""
problems = [] problems = []
n = int(num_frames or 0) n = int(num_frames or 0)
if n < 1 or (n - 1) % 4 != 0: if n < 1 or (n - 1) % 8 != 0:
problems.append(f"num_frames must be 4n+1 (93, 89, 85 …); got {n}") problems.append(
f"num_frames must be 8n+1 for training (89, 97, 105 …); got {n}. "
f"Generation's rule is 4n+1, which is looser — 93 is a valid clip "
f"length but not a valid training length.")
try: try:
w, h = (int(x) for x in str(resolution).lower().split("x")) w, h = (int(x) for x in str(resolution).lower().split("x"))
except Exception: except Exception:
...@@ -128,6 +149,18 @@ def main(argv=None) -> int: ...@@ -128,6 +149,18 @@ def main(argv=None) -> int:
fh.write(json.dumps(kw) + "\n") fh.write(json.dumps(kw) + "\n")
try: try:
# Round to the training rule BEFORE validating, so a clip length that is
# legal for generation (4n+1) does not block a run — it is adjusted and
# said out loud, rather than silently changed inside SimpleTuner.
asked = int(job.get("num_frames") or 93)
trains_at = training_frames(asked)
if trains_at != asked:
job["num_frames"] = trains_at
emit(message=f"{asked} frames is not 8n+1, which training requires — "
f"using {trains_at} (generation's 4n+1 rule is looser)")
print(f"[longcat-train] num_frames {asked} -> {trains_at} (8n+1)",
flush=True)
problems = frame_problems(job.get("num_frames") or 93, problems = frame_problems(job.get("num_frames") or 93,
job.get("resolution") or "480x832") job.get("resolution") or "480x832")
if problems: if problems:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment